diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..a377db8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-Wl,--gc-sections"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f62622..4844f4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,11 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build release binary + env: + # fat LTO is memory-heavy; serialize codegen to avoid OOM on CI runners + CARGO_BUILD_JOBS: 1 run: cargo build --release --target ${{ matrix.target }} - - name: Strip binary - run: strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} - - name: Rename artifact run: | cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} \ diff --git a/CHANGELOG.md b/CHANGELOG.md index b8da789..d814906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,14 @@ All notable changes to the `nmrs-gui` crate will be documented in this file. ## [Unreleased] + +## [1.6.0] - 2026-05-20 +### Added +- VPN management: list, connect, disconnect, and add WireGuard and OpenVPN profiles ([#21](https://github.com/networkmanager-rs/nmrs-gui/pull/21)) + ### Changed +- Header shows a connection-type icon and active network name (VPN, wired, Wi‑Fi, or disconnected) ([#21](https://github.com/networkmanager-rs/nmrs-gui/pull/21)) +- Network scan progress uses a header spinner instead of "Scanning..." text ([#21](https://github.com/networkmanager-rs/nmrs-gui/pull/21)) - Use `Arc` for monitor callbacks to satisfy `Send` bound ([#359](https://github.com/cachebag/nmrs/pull/359)) ## [1.5.1] - 2026-04-10 diff --git a/Cargo.lock b/Cargo.lock index aceab57..efa57d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,9 +541,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -969,9 +969,9 @@ dependencies = [ [[package]] name = "nmrs" -version = "3.1.4" +version = "3.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9493a0dfbe50783b53ef52ec8ddb8d1f85fd45098be8210205236ff0f43bb7b" +checksum = "d907d6da103106d84189d3fd229d9aac921f9131b8ba38b40341c53d24409077" dependencies = [ "async-trait", "base64", @@ -989,7 +989,7 @@ dependencies = [ [[package]] name = "nmrs-gui" -version = "1.5.1" +version = "1.6.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index a89c34d..4a4d06a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,54 +1,39 @@ -[workspace] -resolver = "2" - -[workspace.package] +[package] +name = "nmrs-gui" +version = "1.6.0" +authors = ["Akrm Al-Hakimi "] edition = "2024" +rust-version = "1.85.0" +description = "GTK4 GUI for managing NetworkManager connections" license = "MIT" repository = "https://github.com/networkmanager-rs/nmrs-gui" +keywords = ["networkmanager", "gui", "gtk", "linux"] +categories = ["gui"] +publish = true -[workspace.lints.rust] +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +incremental = false +panic = "abort" +strip = true + +[lints.rust] unused = { level = "warn", priority = -1 } -[workspace.lints.clippy] +[lints.clippy] too_many_arguments = "allow" type_complexity = "allow" -[workspace.dependencies] -nmrs = "3.0" +[dependencies] +nmrs = "3.1.5" gtk = { version = "0.11.0", package = "gtk4" } glib = "0.22" dirs = "6.0.0" fs2 = "0.4.3" anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio-util = "0.7.18" log = "0.4" - -[package] -name = "nmrs-gui" -version = "1.5.1" -authors = ["Akrm Al-Hakimi "] -edition.workspace = true -rust-version = "1.85.0" -description = "GTK4 GUI for managing NetworkManager connections" -license.workspace = true -repository.workspace = true -keywords = ["networkmanager", "gui", "gtk", "linux"] -categories = ["gui"] -publish = true - -[lints] -workspace = true - -[dependencies] -nmrs.workspace = true -gtk.workspace = true -glib.workspace = true -tokio.workspace = true -log.workspace = true -dirs.workspace = true -fs2.workspace = true -anyhow.workspace = true -clap.workspace = true -tokio-util.workspace = true diff --git a/package.nix b/package.nix index bc57e02..815e45c 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-KuSq4fHazMTsXsFRMUKjtIF2rismBcNSv/cA5hr7Xn0="; + cargoHash = "sha256-tbR4zADI2JsiCAeEzubrhM20Rqy2M1mDD1hq80gEG9g="; nativeBuildInputs = [ pkg-config diff --git a/src/style.css b/src/style.css index 10d3eaa..c463105 100644 --- a/src/style.css +++ b/src/style.css @@ -35,6 +35,14 @@ window { color: var(--text-primary); } +/* Global pointer cursor for interactive elements */ +button, +switch, +.network-selection, +.vpn-add-row { + cursor: pointer; +} + /* Header */ headerbar { background: var(--bg-secondary); @@ -42,6 +50,27 @@ headerbar { border-bottom: 1px solid var(--border-color); } +/* Connection status in header */ +.conn-status-icon { + color: var(--text-secondary); + min-width: 16px; + min-height: 16px; +} +.conn-status-name { + color: var(--text-primary); + font-weight: 500; + font-size: 0.95em; +} +.conn-status-separator { + color: var(--text-tertiary); + margin: 0 2px; +} +.scan-spinner { + color: var(--text-secondary); + min-width: 14px; + min-height: 14px; +} + /* Switch */ switch { background-color: var(--bg-tertiary); @@ -312,3 +341,117 @@ popover row label { opacity: 0.5; } +/* VPN styles */ +.vpn-section-header { + font-weight: 700; + font-size: 14px; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.8px; + opacity: 0.8; +} + +.vpn-list { + background: var(--bg-primary); + margin-bottom: 4px; +} + +.vpn-type-label { + font-size: 12px; + color: var(--text-tertiary); + margin-left: 4px; + opacity: 0.7; +} + +.vpn-icon { + color: var(--accent-color); + opacity: 0.8; + margin-left: 6px; +} + +.vpn-add-row { + opacity: 0.7; +} +.vpn-add-row:hover { + opacity: 1; +} + +.vpn-add-icon { + color: var(--accent-color); + opacity: 0.8; +} + +.vpn-add-label { + color: var(--text-secondary); +} + +.vpn-action-btn { + padding: 6px 18px; + border-radius: 6px; + font-weight: 600; +} + +.vpn-connect-btn { + background: var(--accent-color); + color: white; + padding: 6px 18px; + border-radius: 6px; + font-weight: 600; +} +.vpn-connect-btn:hover { + opacity: 0.9; +} + +.vpn-disconnect-btn { + background: var(--error-color); + color: white; + padding: 6px 18px; + border-radius: 6px; + font-weight: 600; +} +.vpn-disconnect-btn:hover { + opacity: 0.9; +} + +.vpn-entry { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 8px; + margin-bottom: 4px; +} +.vpn-entry:focus { + border-color: var(--accent-color); +} + +.vpn-tab-btn { + padding: 4px 14px; + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-weight: 600; +} +.vpn-tab-btn:hover { + background: var(--border-color-hover); +} +.vpn-tab-active { + background: var(--accent-color); + color: white; +} + +.vpn-browse-btn { + background: var(--bg-tertiary); + color: var(--text-primary); + border-radius: 4px; + padding: 4px 12px; +} +.vpn-browse-btn:hover { + background: var(--border-color-hover); +} + +.vpn-status-label { + color: var(--text-secondary); + font-size: 13px; +} + diff --git a/src/ui/header.rs b/src/ui/header.rs index 963b0ac..517e3bc 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -10,6 +10,7 @@ use nmrs::models; use crate::ui::networks; use crate::ui::networks::NetworksContext; +use crate::ui::vpn_list; use crate::ui::wired_devices; pub struct ThemeDef { @@ -56,10 +57,42 @@ pub fn build_header( let list_container = list_container.clone(); - // Left side: status label - ctx.status.set_hexpand(true); + // Left side: connection status (icon + name) and status label + let left_box = GtkBox::new(Orientation::Horizontal, 8); + left_box.set_halign(Align::Start); + + ctx.conn_icon.set_valign(Align::Center); + left_box.append(&ctx.conn_icon); + + ctx.conn_name.set_valign(Align::Center); + left_box.append(&ctx.conn_name); + + let separator = Label::new(Some("·")); + separator.add_css_class("conn-status-separator"); + separator.set_valign(Align::Center); + separator.set_visible(false); + + ctx.status.set_valign(Align::Center); ctx.status.set_halign(Align::Start); - header.pack_start(&ctx.status); + + // Show separator only when status has text + { + let sep = separator.clone(); + ctx.status + .connect_notify_local(Some("label"), move |label, _| { + let has_text = !label.text().is_empty(); + sep.set_visible(has_text); + }); + } + + left_box.append(&separator); + left_box.append(&ctx.status); + + ctx.scan_spinner.set_valign(Align::Center); + left_box.append(&ctx.scan_spinner); + + left_box.set_hexpand(true); + header.pack_start(&left_box); // Right side: settings gear let settings_btn = gtk::Button::from_icon_name("emblem-system-symbolic"); @@ -126,6 +159,7 @@ pub fn build_header( apply_airplane_icon(&airplane_btn, &ctx).await; apply_connectivity_status(&ctx).await; + apply_connection_status(&ctx).await; match ctx.nm.wifi_state().await.map(|s| s.enabled) { Ok(enabled) => { @@ -261,6 +295,47 @@ async fn apply_connectivity_status(ctx: &NetworksContext) { } } +async fn apply_connection_status(ctx: &NetworksContext) { + // Check for active VPN first (takes priority in display) + if let Ok(vpns) = ctx.nm.list_vpn_connections().await { + for vpn in &vpns { + if vpn.active { + ctx.conn_icon.set_icon_name(Some("network-vpn-symbolic")); + ctx.conn_name.set_text(&vpn.name); + return; + } + } + } + + // Check for active wired connection + if let Ok(wired_devices) = ctx.nm.list_wired_devices().await { + for dev in &wired_devices { + if dev.state == models::DeviceState::Activated { + ctx.conn_icon.set_icon_name(Some("network-wired-symbolic")); + ctx.conn_name.set_text(&dev.interface); + return; + } + } + } + + // Check for active Wi-Fi connection + if let Some((ssid, freq)) = ctx.nm.current_connection_info().await { + ctx.conn_icon + .set_icon_name(Some("network-wireless-symbolic")); + let display = match freq.and_then(crate::ui::freq_to_band) { + Some(band) => format!("{} ({})", ssid, band), + None => ssid, + }; + ctx.conn_name.set_text(&display); + return; + } + + // No active connection + ctx.conn_icon + .set_icon_name(Some("network-offline-symbolic")); + ctx.conn_name.set_text("Disconnected"); +} + fn connectivity_label(state: &ConnectivityState, portal_url: Option<&str>) -> String { match state { ConnectivityState::Full => String::new(), @@ -281,13 +356,13 @@ pub async fn refresh_networks( is_scanning: &Rc>, ) { if is_scanning.get() { - ctx.status.set_text("Scan already in progress"); return; } is_scanning.set(true); clear_children(list_container); - ctx.status.set_text("Scanning..."); + ctx.scan_spinner.set_visible(true); + ctx.scan_spinner.start(); // Fetch wired devices first match ctx.nm.list_wired_devices().await { @@ -361,6 +436,8 @@ pub async fn refresh_networks( if let Err(err) = ctx.nm.scan_networks(None).await { ctx.status.set_text(&format!("Scan failed: {err}")); + ctx.scan_spinner.stop(); + ctx.scan_spinner.set_visible(false); is_scanning.set(false); return; } @@ -416,8 +493,22 @@ pub async fn refresh_networks( .set_text(&format!("Error fetching networks: {err}")), } + // VPN section + if let Ok(vpns) = ctx.nm.list_vpn_connections().await { + vpn_list::vpn_section( + ctx.clone(), + &vpns, + ctx.vpn_details_page.clone(), + list_container, + ); + } + vpn_list::vpn_add_button(&ctx, list_container); + apply_connectivity_status(&ctx).await; + apply_connection_status(&ctx).await; + ctx.scan_spinner.stop(); + ctx.scan_spinner.set_visible(false); is_scanning.set(false); } @@ -546,7 +637,19 @@ pub async fn refresh_networks_no_scan( } } + // VPN section + if let Ok(vpns) = ctx.nm.list_vpn_connections().await { + vpn_list::vpn_section( + ctx.clone(), + &vpns, + ctx.vpn_details_page.clone(), + list_container, + ); + } + vpn_list::vpn_add_button(&ctx, list_container); + apply_connectivity_status(&ctx).await; + apply_connection_status(&ctx).await; is_scanning.set(false); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dd5da19..fb5b766 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,13 +3,16 @@ pub mod header; pub mod network_page; pub mod networks; pub mod settings_page; +pub mod vpn_add_page; +pub mod vpn_details_page; +pub mod vpn_list; pub mod wired_devices; pub mod wired_page; use gtk::prelude::*; use gtk::{ - Application, ApplicationWindow, Box as GtkBox, Label, Orientation, ScrolledWindow, Spinner, - Stack, pango::EllipsizeMode, + Application, ApplicationWindow, Box as GtkBox, Image, Label, Orientation, ScrolledWindow, + Spinner, Stack, pango::EllipsizeMode, }; use std::cell::Cell; use std::rc::Rc; @@ -31,7 +34,7 @@ pub fn freq_to_band(freq: u32) -> Option<&'static str> { pub fn build_ui(app: &Application) { let win = ApplicationWindow::new(app); win.set_title(Some("")); - win.set_default_size(100, 600); + win.set_default_size(450, 600); win.add_css_class("dark-theme"); let vbox = GtkBox::new(Orientation::Vertical, 0); @@ -39,6 +42,20 @@ pub fn build_ui(app: &Application) { status.set_xalign(0.0); status.set_ellipsize(EllipsizeMode::End); status.set_max_width_chars(36); + + let conn_icon = Image::from_icon_name("network-offline-symbolic"); + conn_icon.add_css_class("conn-status-icon"); + + let conn_name = Label::new(Some("Disconnected")); + conn_name.set_ellipsize(EllipsizeMode::End); + conn_name.set_max_width_chars(20); + conn_name.add_css_class("conn-status-name"); + + let scan_spinner = Spinner::new(); + scan_spinner.set_size_request(14, 14); + scan_spinner.add_css_class("scan-spinner"); + scan_spinner.set_visible(false); + let list_container = GtkBox::new(Orientation::Vertical, 0); let stack = Stack::new(); let is_scanning = Rc::new(Cell::new(false)); @@ -79,21 +96,41 @@ pub fn build_ui(app: &Application) { wired_details_scroller.set_child(Some(wired_details_page.widget())); stack_clone.add_named(&wired_details_scroller, Some("wired-details")); + let vpn_details_page = Rc::new(vpn_details_page::VpnDetailsPage::new(&stack_clone)); + let vpn_details_scroller = ScrolledWindow::new(); + vpn_details_scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + vpn_details_scroller.set_child(Some(vpn_details_page.widget())); + stack_clone.add_named(&vpn_details_scroller, Some("vpn-details")); + + let vpn_add = vpn_add_page::VpnAddPage::new(&stack_clone, &win_clone); + let vpn_add_scroller = ScrolledWindow::new(); + vpn_add_scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + vpn_add_scroller.set_child(Some(vpn_add.widget())); + stack_clone.add_named(&vpn_add_scroller, Some("vpn-add")); + let settings = settings_page::SettingsPage::new(&stack_clone, &win_clone); let settings_scroller = ScrolledWindow::new(); settings_scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); settings_scroller.set_child(Some(settings.widget())); stack_clone.add_named(&settings_scroller, Some("settings")); + let conn_icon_clone = conn_icon.clone(); + let conn_name_clone = conn_name.clone(); + let scan_spinner_clone = scan_spinner.clone(); + let on_success: Rc = { let list_container = list_container_clone.clone(); let is_scanning = is_scanning_clone.clone(); let nm = nm.clone(); let status = status_clone.clone(); + let conn_icon = conn_icon_clone.clone(); + let conn_name = conn_name_clone.clone(); + let scan_spinner = scan_spinner_clone.clone(); let stack = stack_clone.clone(); let parent_window = win_clone.clone(); let details_page = details_page.clone(); let wired_details_page = wired_details_page.clone(); + let vpn_details_page = vpn_details_page.clone(); let on_success_cell: CallbackCell = Rc::new(std::cell::RefCell::new(None)); let on_success_cell_clone = on_success_cell.clone(); @@ -103,11 +140,15 @@ pub fn build_ui(app: &Application) { let is_scanning = is_scanning.clone(); let nm = nm.clone(); let status = status.clone(); + let conn_icon = conn_icon.clone(); + let conn_name = conn_name.clone(); + let scan_spinner = scan_spinner.clone(); let stack = stack.clone(); let parent_window = parent_window.clone(); let on_success_cell = on_success_cell.clone(); let details_page = details_page.clone(); let wired_details_page = wired_details_page.clone(); + let vpn_details_page = vpn_details_page.clone(); glib::MainContext::default().spawn_local(async move { let callback = on_success_cell.borrow().as_ref().map(|cb| cb.clone()); @@ -115,10 +156,14 @@ pub fn build_ui(app: &Application) { nm, on_success: callback.unwrap_or_else(|| Rc::new(|| {})), status, + conn_icon, + conn_name, + scan_spinner, stack, parent_window, details_page: details_page.clone(), wired_details_page: wired_details_page.clone(), + vpn_details_page: vpn_details_page.clone(), }); header::refresh_networks(refresh_ctx, &list_container, &is_scanning) .await; @@ -134,13 +179,19 @@ pub fn build_ui(app: &Application) { nm: nm.clone(), on_success: on_success.clone(), status: status_clone.clone(), + conn_icon: conn_icon_clone.clone(), + conn_name: conn_name_clone.clone(), + scan_spinner: scan_spinner_clone.clone(), stack: stack_clone.clone(), parent_window: win_clone.clone(), details_page: details_page.clone(), wired_details_page, + vpn_details_page: vpn_details_page.clone(), }); - details_page.set_on_success(on_success); + details_page.set_on_success(on_success.clone()); + vpn_details_page.set_on_success(on_success.clone()); + vpn_add.set_on_success(on_success); let header = header::build_header( ctx.clone(), diff --git a/src/ui/networks.rs b/src/ui/networks.rs index 7e29d9f..917481f 100644 --- a/src/ui/networks.rs +++ b/src/ui/networks.rs @@ -23,20 +23,28 @@ pub struct NetworksContext { pub nm: Rc, pub on_success: Rc, pub status: Label, + pub conn_icon: Image, + pub conn_name: Label, + pub scan_spinner: gtk::Spinner, pub stack: gtk::Stack, pub parent_window: gtk::ApplicationWindow, pub details_page: Rc, pub wired_details_page: Rc, + pub vpn_details_page: Rc, } impl NetworksContext { pub async fn new( on_success: Rc, status: &Label, + conn_icon: &Image, + conn_name: &Label, + scan_spinner: >k::Spinner, stack: >k::Stack, parent_window: >k::ApplicationWindow, details_page: Rc, wired_details_page: Rc, + vpn_details_page: Rc, ) -> Result { let nm = Rc::new(NetworkManager::new().await?); @@ -44,10 +52,14 @@ impl NetworksContext { nm, on_success, status: status.clone(), + conn_icon: conn_icon.clone(), + conn_name: conn_name.clone(), + scan_spinner: scan_spinner.clone(), stack: stack.clone(), parent_window: parent_window.clone(), details_page, wired_details_page, + vpn_details_page, }) } } diff --git a/src/ui/vpn_add_page.rs b/src/ui/vpn_add_page.rs new file mode 100644 index 0000000..881fcd9 --- /dev/null +++ b/src/ui/vpn_add_page.rs @@ -0,0 +1,394 @@ +use glib::clone; +use gtk::prelude::*; +use gtk::{ + Align, Box as GtkBox, Button, Entry, FileChooserAction, FileChooserDialog, Label, Orientation, + ResponseType, Stack, +}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; +use std::cell::RefCell; +use std::rc::Rc; + +type OnSuccessCallback = Rc>>>; + +pub struct VpnAddPage { + root: gtk::Box, + on_success: OnSuccessCallback, +} + +impl VpnAddPage { + pub fn new(stack: >k::Stack, parent_window: >k::ApplicationWindow) -> Self { + let root = GtkBox::new(Orientation::Vertical, 12); + root.add_css_class("network-page"); + + let back = Button::with_label("← Back"); + back.add_css_class("back-button"); + back.set_halign(Align::Start); + back.set_cursor_from_name(Some("pointer")); + back.connect_clicked(clone![ + #[weak] + stack, + move |_| { + stack.set_visible_child_name("networks"); + } + ]); + root.append(&back); + + let title = Label::new(Some("Add VPN")); + title.add_css_class("network-title"); + title.set_halign(Align::Start); + root.append(&title); + + let tab_stack = Stack::new(); + let on_success: OnSuccessCallback = Rc::new(RefCell::new(None)); + + let tab_bar = GtkBox::new(Orientation::Horizontal, 8); + tab_bar.set_margin_top(4); + tab_bar.set_margin_bottom(8); + + let wg_tab_btn = Button::with_label("WireGuard"); + wg_tab_btn.add_css_class("vpn-tab-btn"); + wg_tab_btn.add_css_class("vpn-tab-active"); + wg_tab_btn.set_cursor_from_name(Some("pointer")); + + let ovpn_tab_btn = Button::with_label("Import OpenVPN"); + ovpn_tab_btn.add_css_class("vpn-tab-btn"); + ovpn_tab_btn.set_cursor_from_name(Some("pointer")); + + tab_bar.append(&wg_tab_btn); + tab_bar.append(&ovpn_tab_btn); + root.append(&tab_bar); + + { + let tab_stack_c = tab_stack.clone(); + let ovpn_btn_c = ovpn_tab_btn.clone(); + wg_tab_btn.connect_clicked(move |btn| { + tab_stack_c.set_visible_child_name("wireguard"); + btn.add_css_class("vpn-tab-active"); + ovpn_btn_c.remove_css_class("vpn-tab-active"); + }); + } + { + let tab_stack_c = tab_stack.clone(); + let wg_btn_c = wg_tab_btn.clone(); + ovpn_tab_btn.connect_clicked(move |btn| { + tab_stack_c.set_visible_child_name("openvpn"); + btn.add_css_class("vpn-tab-active"); + wg_btn_c.remove_css_class("vpn-tab-active"); + }); + } + + let wg_page = Self::build_wireguard_tab(stack, &on_success); + tab_stack.add_named(&wg_page, Some("wireguard")); + + let ovpn_page = Self::build_openvpn_tab(stack, parent_window, &on_success); + tab_stack.add_named(&ovpn_page, Some("openvpn")); + + tab_stack.set_visible_child_name("wireguard"); + root.append(&tab_stack); + + Self { root, on_success } + } + + pub fn set_on_success(&self, callback: Rc) { + *self.on_success.borrow_mut() = Some(callback); + } + + fn labeled_entry(parent: >k::Box, label_text: &str, placeholder: &str) -> Entry { + let label = Label::new(Some(label_text)); + label.add_css_class("info-label"); + label.set_halign(Align::Start); + parent.append(&label); + + let entry = Entry::new(); + entry.add_css_class("vpn-entry"); + entry.set_placeholder_text(Some(placeholder)); + parent.append(&entry); + entry + } + + fn build_wireguard_tab(stack: >k::Stack, on_success: &OnSuccessCallback) -> gtk::Box { + let page = GtkBox::new(Orientation::Vertical, 8); + + let conn_header = Label::new(Some("Connection")); + conn_header.add_css_class("section-header"); + page.append(&conn_header); + + let name_entry = Self::labeled_entry(&page, "Name", "e.g. HomeVPN"); + let gateway_entry = Self::labeled_entry(&page, "Gateway", "vpn.example.com:51820"); + let privkey_entry = + Self::labeled_entry(&page, "Private Key", "Base64 WireGuard private key"); + privkey_entry.set_visibility(false); + let address_entry = Self::labeled_entry(&page, "Address", "10.0.0.2/24"); + + let peer_header = Label::new(Some("Peer")); + peer_header.add_css_class("section-header"); + peer_header.set_margin_top(12); + page.append(&peer_header); + + let peer_pubkey = Self::labeled_entry(&page, "Public Key", "Peer's public key"); + let peer_endpoint = Self::labeled_entry(&page, "Endpoint", "vpn.example.com:51820"); + let peer_allowed = Self::labeled_entry(&page, "Allowed IPs", "0.0.0.0/0"); + let peer_keepalive = Self::labeled_entry(&page, "Persistent Keepalive", "25 (optional)"); + + let opt_header = Label::new(Some("Optional")); + opt_header.add_css_class("section-header"); + opt_header.set_margin_top(12); + page.append(&opt_header); + + let dns_entry = Self::labeled_entry(&page, "DNS Servers", "1.1.1.1, 8.8.8.8 (optional)"); + let mtu_entry = Self::labeled_entry(&page, "MTU", "1420 (optional)"); + + let status_label = Label::new(None); + status_label.add_css_class("vpn-status-label"); + status_label.set_halign(Align::Start); + status_label.set_margin_top(8); + page.append(&status_label); + + let connect_btn = Button::with_label("Connect"); + connect_btn.add_css_class("vpn-connect-btn"); + connect_btn.set_halign(Align::Start); + connect_btn.set_margin_top(8); + connect_btn.set_cursor_from_name(Some("pointer")); + page.append(&connect_btn); + + { + let stack = stack.clone(); + let on_success = on_success.clone(); + let status = status_label.clone(); + + connect_btn.connect_clicked(move |btn| { + let name = name_entry.text().to_string(); + let gateway = gateway_entry.text().to_string(); + let privkey = privkey_entry.text().to_string(); + let address = address_entry.text().to_string(); + let pubkey = peer_pubkey.text().to_string(); + let endpoint = peer_endpoint.text().to_string(); + let allowed = peer_allowed.text().to_string(); + let keepalive = peer_keepalive.text().to_string(); + let dns_text = dns_entry.text().to_string(); + let mtu_text = mtu_entry.text().to_string(); + + if name.trim().is_empty() + || gateway.trim().is_empty() + || privkey.trim().is_empty() + || address.trim().is_empty() + || pubkey.trim().is_empty() + || endpoint.trim().is_empty() + || allowed.trim().is_empty() + { + status.set_text("Fill in all required fields"); + return; + } + + let stack = stack.clone(); + let on_success = on_success.clone(); + let status = status.clone(); + btn.set_sensitive(false); + let btn = btn.clone(); + + status.set_text("Connecting..."); + + glib::MainContext::default().spawn_local(async move { + let allowed_ips: Vec = allowed + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let mut peer = WireGuardPeer::new(pubkey.trim(), endpoint.trim(), allowed_ips); + + if let Ok(ka) = keepalive.trim().parse::() { + peer = peer.with_persistent_keepalive(ka); + } + + let mut config = WireGuardConfig::new( + name.trim(), + gateway.trim(), + privkey.trim(), + address.trim(), + vec![peer], + ); + + if !dns_text.trim().is_empty() { + let dns: Vec = dns_text + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + config = config.with_dns(dns); + } + + if let Ok(mtu) = mtu_text.trim().parse::() { + config = config.with_mtu(mtu); + } + + match NetworkManager::new().await { + Ok(nm) => match nm.connect_vpn(config).await { + Ok(_) => { + status.set_text("Connected!"); + stack.set_visible_child_name("networks"); + if let Some(callback) = on_success.borrow().as_ref() { + callback(); + } + } + Err(e) => { + status.set_text(&format!("Failed: {e}")); + } + }, + Err(e) => { + status.set_text(&format!("NM error: {e}")); + } + } + btn.set_sensitive(true); + }); + }); + } + + page + } + + fn build_openvpn_tab( + stack: >k::Stack, + parent_window: >k::ApplicationWindow, + on_success: &OnSuccessCallback, + ) -> gtk::Box { + let page = GtkBox::new(Orientation::Vertical, 8); + + let file_header = Label::new(Some("Configuration File")); + file_header.add_css_class("section-header"); + page.append(&file_header); + + let file_label = Label::new(Some("OpenVPN File (.ovpn)")); + file_label.add_css_class("info-label"); + file_label.set_halign(Align::Start); + page.append(&file_label); + + let file_hbox = GtkBox::new(Orientation::Horizontal, 8); + let file_entry = Entry::new(); + file_entry.add_css_class("vpn-entry"); + file_entry.set_placeholder_text(Some("/path/to/config.ovpn")); + file_entry.set_hexpand(true); + + let browse_btn = Button::with_label("Browse..."); + browse_btn.add_css_class("vpn-browse-btn"); + browse_btn.set_cursor_from_name(Some("pointer")); + file_hbox.append(&file_entry); + file_hbox.append(&browse_btn); + page.append(&file_hbox); + + { + let file_entry_c = file_entry.clone(); + let parent_weak = parent_window.downgrade(); + browse_btn.connect_clicked(move |_| { + let Some(parent) = parent_weak.upgrade() else { + return; + }; + let file_entry = file_entry_c.clone(); + let dialog = FileChooserDialog::new( + Some("Select OpenVPN Configuration"), + Some(&parent), + FileChooserAction::Open, + &[ + ("Cancel", ResponseType::Cancel), + ("Open", ResponseType::Accept), + ], + ); + dialog.connect_response(move |dialog, response| { + if response == ResponseType::Accept + && let Some(file) = dialog.file() + && let Some(path) = file.path() + { + file_entry.set_text(&path.to_string_lossy()); + } + dialog.close(); + }); + dialog.show(); + }); + } + + let cred_header = Label::new(Some("Credentials (optional)")); + cred_header.add_css_class("section-header"); + cred_header.set_margin_top(12); + page.append(&cred_header); + + let username_entry = Self::labeled_entry(&page, "Username", "VPN username (if required)"); + let password_entry = Self::labeled_entry(&page, "Password", "VPN password (if required)"); + password_entry.set_visibility(false); + + let status_label = Label::new(None); + status_label.add_css_class("vpn-status-label"); + status_label.set_halign(Align::Start); + status_label.set_margin_top(8); + page.append(&status_label); + + let import_btn = Button::with_label("Import & Connect"); + import_btn.add_css_class("vpn-connect-btn"); + import_btn.set_halign(Align::Start); + import_btn.set_margin_top(8); + import_btn.set_cursor_from_name(Some("pointer")); + page.append(&import_btn); + + { + let stack = stack.clone(); + let on_success = on_success.clone(); + let status = status_label.clone(); + + import_btn.connect_clicked(move |btn| { + let path = file_entry.text().to_string(); + let user = username_entry.text().to_string(); + let pass = password_entry.text().to_string(); + + if path.trim().is_empty() { + status.set_text("Select an .ovpn file"); + return; + } + + let stack = stack.clone(); + let on_success = on_success.clone(); + let status = status.clone(); + btn.set_sensitive(false); + let btn = btn.clone(); + + status.set_text("Importing..."); + + glib::MainContext::default().spawn_local(async move { + let user_opt = if user.trim().is_empty() { + None + } else { + Some(user.as_str()) + }; + let pass_opt = if pass.trim().is_empty() { + None + } else { + Some(pass.as_str()) + }; + + match NetworkManager::new().await { + Ok(nm) => match nm.import_ovpn(&path, user_opt, pass_opt).await { + Ok(_) => { + status.set_text("Imported & Connected!"); + stack.set_visible_child_name("networks"); + if let Some(callback) = on_success.borrow().as_ref() { + callback(); + } + } + Err(e) => { + status.set_text(&format!("Failed: {e}")); + } + }, + Err(e) => { + status.set_text(&format!("NM error: {e}")); + } + } + btn.set_sensitive(true); + }); + }); + } + + page + } + + pub fn widget(&self) -> >k::Box { + &self.root + } +} diff --git a/src/ui/vpn_details_page.rs b/src/ui/vpn_details_page.rs new file mode 100644 index 0000000..25e6e1b --- /dev/null +++ b/src/ui/vpn_details_page.rs @@ -0,0 +1,437 @@ +use glib::clone; +use gtk::prelude::*; +use gtk::{Align, Box, Button, Image, Label, Orientation}; +use nmrs::{NetworkManager, VpnConnection, VpnConnectionInfo, VpnDetails}; +use std::cell::RefCell; +use std::rc::Rc; + +type OnSuccessCallback = Rc>>>; + +pub struct VpnDetailsPage { + root: gtk::Box, + + title: gtk::Label, + status_val: gtk::Label, + vpn_type_val: gtk::Label, + interface_val: gtk::Label, + + network_section: gtk::Box, + ip4_val: gtk::Label, + ip6_val: gtk::Label, + gateway_val: gtk::Label, + dns_val: gtk::Label, + + protocol_section: gtk::Box, + protocol_header: gtk::Label, + protocol_box: gtk::Box, + + action_btn: gtk::Button, + + current_name: Rc>, + current_uuid: Rc>, + current_active: Rc>, + on_success: OnSuccessCallback, +} + +impl VpnDetailsPage { + pub fn new(stack: >k::Stack) -> Self { + let root = Box::new(Orientation::Vertical, 12); + root.add_css_class("network-page"); + + let back = Button::with_label("← Back"); + back.add_css_class("back-button"); + back.set_halign(Align::Start); + back.set_cursor_from_name(Some("pointer")); + back.connect_clicked(clone![ + #[weak] + stack, + move |_| { + stack.set_visible_child_name("networks"); + } + ]); + root.append(&back); + + let header = Box::new(Orientation::Horizontal, 6); + let icon = Image::from_icon_name("network-vpn-symbolic"); + icon.set_pixel_size(24); + + let title = Label::new(None); + title.add_css_class("network-title"); + + let spacer = Box::new(Orientation::Horizontal, 0); + spacer.set_hexpand(true); + + let forget_btn = Button::with_label("Forget"); + forget_btn.add_css_class("forget-button"); + forget_btn.set_halign(Align::End); + forget_btn.set_valign(Align::Center); + forget_btn.set_cursor_from_name(Some("pointer")); + + header.append(&icon); + header.append(&title); + header.append(&spacer); + header.append(&forget_btn); + root.append(&header); + + // Basic section + let basic_box = Box::new(Orientation::Vertical, 6); + basic_box.add_css_class("basic-section"); + let basic_header = Label::new(Some("Basic")); + basic_header.add_css_class("section-header"); + basic_box.append(&basic_header); + + let status_val = Label::new(None); + let vpn_type_val = Label::new(None); + let interface_val = Label::new(None); + + Self::add_row(&basic_box, "Connection Status", &status_val); + Self::add_row(&basic_box, "VPN Type", &vpn_type_val); + Self::add_row(&basic_box, "Interface", &interface_val); + + let action_btn = Button::with_label("Connect"); + action_btn.add_css_class("vpn-action-btn"); + action_btn.set_halign(Align::Start); + action_btn.set_margin_top(8); + action_btn.set_cursor_from_name(Some("pointer")); + basic_box.append(&action_btn); + + root.append(&basic_box); + + // Network section (visible only when active) + let network_section = Box::new(Orientation::Vertical, 6); + network_section.add_css_class("advanced-section"); + let net_header = Label::new(Some("Network")); + net_header.add_css_class("section-header"); + network_section.append(&net_header); + + let ip4_val = Label::new(None); + let ip6_val = Label::new(None); + let gateway_val = Label::new(None); + let dns_val = Label::new(None); + + Self::add_row(&network_section, "IPv4 Address", &ip4_val); + Self::add_row(&network_section, "IPv6 Address", &ip6_val); + Self::add_row(&network_section, "Gateway", &gateway_val); + Self::add_row(&network_section, "DNS Servers", &dns_val); + + network_section.set_visible(false); + root.append(&network_section); + + // Protocol section (dynamic content) + let protocol_section = Box::new(Orientation::Vertical, 6); + protocol_section.add_css_class("advanced-section"); + let protocol_header = Label::new(Some("Protocol")); + protocol_header.add_css_class("section-header"); + protocol_section.append(&protocol_header); + + let protocol_box = Box::new(Orientation::Vertical, 6); + protocol_section.append(&protocol_box); + + protocol_section.set_visible(false); + root.append(&protocol_section); + + let current_name = Rc::new(RefCell::new(String::new())); + let current_uuid = Rc::new(RefCell::new(String::new())); + let current_active = Rc::new(RefCell::new(false)); + let on_success_callback: OnSuccessCallback = Rc::new(RefCell::new(None)); + + // Forget handler + { + let stack_clone = stack.clone(); + let name_clone = current_name.clone(); + let on_success_clone = on_success_callback.clone(); + + forget_btn.connect_clicked(move |btn| { + let stack = stack_clone.clone(); + let name = name_clone.borrow().clone(); + let on_success = on_success_clone.clone(); + btn.set_sensitive(false); + let btn = btn.clone(); + + glib::MainContext::default().spawn_local(async move { + if let Ok(nm) = NetworkManager::new().await + && nm.forget_vpn(&name).await.is_ok() + { + stack.set_visible_child_name("networks"); + if let Some(callback) = on_success.borrow().as_ref() { + callback(); + } + } + btn.set_sensitive(true); + }); + }); + } + + // Connect/Disconnect handler + { + let uuid_clone = current_uuid.clone(); + let active_clone = current_active.clone(); + let name_clone = current_name.clone(); + let on_success_clone = on_success_callback.clone(); + let stack_clone = stack.clone(); + + action_btn.connect_clicked(move |btn| { + let uuid = uuid_clone.borrow().clone(); + let active = *active_clone.borrow(); + let name = name_clone.borrow().clone(); + let on_success = on_success_clone.clone(); + let stack = stack_clone.clone(); + btn.set_sensitive(false); + let btn = btn.clone(); + + glib::MainContext::default().spawn_local(async move { + if let Ok(nm) = NetworkManager::new().await { + let results = if active { + nm.disconnect_vpn_by_uuid(&uuid).await + } else { + nm.connect_vpn_by_uuid(&uuid).await + }; + + match results { + Ok(_) => { + stack.set_visible_child_name("networks"); + if let Some(callback) = on_success.borrow().as_ref() { + callback(); + } + } + Err(e) => { + eprintln!( + "VPN {} failed for '{}': {}", + if active { "disconnect" } else { "connect" }, + name, + e + ); + } + } + } + btn.set_sensitive(true); + }); + }); + } + + Self { + root, + title, + status_val, + vpn_type_val, + interface_val, + network_section, + ip4_val, + ip6_val, + gateway_val, + dns_val, + protocol_section, + protocol_header, + protocol_box, + action_btn, + current_name, + current_uuid, + current_active, + on_success: on_success_callback, + } + } + + pub fn set_on_success(&self, callback: Rc) { + *self.on_success.borrow_mut() = Some(callback); + } + + fn add_row(parent: >k::Box, key_text: &str, val_widget: >k::Label) { + let row = Box::new(Orientation::Vertical, 3); + row.set_halign(Align::Start); + + let key = Label::new(Some(key_text)); + key.add_css_class("info-label"); + key.set_halign(Align::Start); + + val_widget.add_css_class("info-value"); + val_widget.set_halign(Align::Start); + + row.append(&key); + row.append(val_widget); + parent.append(&row); + } + + fn clear_protocol_box(&self) { + let mut child = self.protocol_box.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + self.protocol_box.remove(&widget); + } + } + + fn add_protocol_row(&self, key_text: &str, value: &str) { + let row = Box::new(Orientation::Vertical, 3); + row.set_halign(Align::Start); + + let key = Label::new(Some(key_text)); + key.add_css_class("info-label"); + key.set_halign(Align::Start); + + let val = Label::new(Some(value)); + val.add_css_class("info-value"); + val.set_halign(Align::Start); + + row.append(&key); + row.append(&val); + self.protocol_box.append(&row); + } + + pub fn update(&self, vpn: &VpnConnection) { + self.current_name.replace(vpn.name.clone()); + self.current_uuid.replace(vpn.uuid.clone()); + self.current_active.replace(vpn.active); + + self.title.set_text(&vpn.name); + self.status_val.set_text(if vpn.active { + "Connected" + } else { + "Disconnected" + }); + + let type_label = match &vpn.vpn_type { + nmrs::VpnType::WireGuard { .. } => "WireGuard", + nmrs::VpnType::OpenVpn { .. } => "OpenVPN", + nmrs::VpnType::OpenConnect { .. } => "OpenConnect", + nmrs::VpnType::StrongSwan { .. } => "strongSwan", + nmrs::VpnType::Pptp { .. } => "PPTP", + nmrs::VpnType::L2tp { .. } => "L2TP", + _ => "VPN", + }; + self.vpn_type_val.set_text(type_label); + + self.interface_val + .set_text(vpn.interface.as_deref().unwrap_or("-")); + + if vpn.active { + self.action_btn.set_label("Disconnect"); + self.action_btn.remove_css_class("vpn-connect-btn"); + self.action_btn.add_css_class("vpn-disconnect-btn"); + } else { + self.action_btn.set_label("Connect"); + self.action_btn.remove_css_class("vpn-disconnect-btn"); + self.action_btn.add_css_class("vpn-connect-btn"); + } + + // Populate protocol section from VpnType + self.clear_protocol_box(); + match &vpn.vpn_type { + nmrs::VpnType::WireGuard { + peer_public_key, + endpoint, + allowed_ips, + persistent_keepalive, + .. + } => { + self.protocol_header.set_text("WireGuard"); + if let Some(pk) = peer_public_key { + self.add_protocol_row("Peer Public Key", pk); + } + if let Some(ep) = endpoint { + self.add_protocol_row("Endpoint", ep); + } + if !allowed_ips.is_empty() { + self.add_protocol_row("Allowed IPs", &allowed_ips.join(", ")); + } + if let Some(ka) = persistent_keepalive { + self.add_protocol_row("Keepalive", &format!("{ka}s")); + } + self.protocol_section.set_visible(true); + } + nmrs::VpnType::OpenVpn { + remote, + connection_type, + user_name, + ca, + .. + } => { + self.protocol_header.set_text("OpenVPN"); + if let Some(r) = remote { + self.add_protocol_row("Remote", r); + } + if let Some(ct) = connection_type { + self.add_protocol_row("Auth Type", &format!("{ct:?}")); + } + if let Some(u) = user_name { + self.add_protocol_row("Username", u); + } + if let Some(c) = ca { + self.add_protocol_row("CA Certificate", c); + } + self.protocol_section.set_visible(true); + } + _ => { + self.protocol_section.set_visible(false); + } + } + + self.network_section.set_visible(false); + } + + pub fn enrich_with_info(&self, info: &VpnConnectionInfo) { + self.ip4_val + .set_text(info.ip4_address.as_deref().unwrap_or("-")); + self.ip6_val + .set_text(info.ip6_address.as_deref().unwrap_or("-")); + self.gateway_val + .set_text(info.gateway.as_deref().unwrap_or("-")); + + if info.dns_servers.is_empty() { + self.dns_val.set_text("-"); + } else { + self.dns_val.set_text(&info.dns_servers.join(", ")); + } + + self.interface_val + .set_text(info.interface.as_deref().unwrap_or("-")); + + self.network_section.set_visible(true); + + // Enrich protocol details from active info + if let Some(details) = &info.details { + self.clear_protocol_box(); + match details { + VpnDetails::WireGuard { + public_key, + endpoint, + } => { + self.protocol_header.set_text("WireGuard"); + if let Some(pk) = public_key { + self.add_protocol_row("Public Key", pk); + } + if let Some(ep) = endpoint { + self.add_protocol_row("Endpoint", ep); + } + self.protocol_section.set_visible(true); + } + VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + } => { + self.protocol_header.set_text("OpenVPN"); + self.add_protocol_row("Remote", remote); + self.add_protocol_row("Port", &port.to_string()); + self.add_protocol_row("Protocol", protocol); + if let Some(c) = cipher { + self.add_protocol_row("Cipher", c); + } + if let Some(a) = auth { + self.add_protocol_row("Auth", a); + } + if let Some(comp) = compression { + self.add_protocol_row("Compression", comp); + } + self.protocol_section.set_visible(true); + } + _ => {} + } + } + } + + pub fn widget(&self) -> >k::Box { + &self.root + } +} diff --git a/src/ui/vpn_list.rs b/src/ui/vpn_list.rs new file mode 100644 index 0000000..f47600a --- /dev/null +++ b/src/ui/vpn_list.rs @@ -0,0 +1,204 @@ +use gtk::prelude::*; +use gtk::{Align, Box as GtkBox, GestureClick, Image, Label, ListBox, ListBoxRow, Orientation}; +use nmrs::VpnConnection; +use std::rc::Rc; + +use crate::ui::networks::NetworksContext; +use crate::ui::vpn_details_page::VpnDetailsPage; + +pub fn vpn_section( + ctx: Rc, + vpns: &[VpnConnection], + details_page: Rc, + list_container: &GtkBox, +) { + if vpns.is_empty() { + return; + } + + let separator = gtk::Separator::new(Orientation::Horizontal); + separator.add_css_class("device-separator"); + separator.set_margin_top(12); + separator.set_margin_bottom(12); + list_container.append(&separator); + + let header = Label::new(Some("VPN")); + header.add_css_class("section-header"); + header.add_css_class("vpn-section-header"); + header.set_halign(Align::Start); + header.set_margin_top(8); + header.set_margin_bottom(4); + header.set_margin_start(12); + list_container.append(&header); + + let list = vpn_list_view(ctx, vpns, details_page); + list.add_css_class("vpn-list"); + list_container.append(&list); +} + +pub fn vpn_add_button(ctx: &NetworksContext, list_container: &GtkBox) { + let row = ListBoxRow::new(); + row.add_css_class("network-selection"); + row.add_css_class("vpn-add-row"); + + let hbox = GtkBox::new(Orientation::Horizontal, 6); + + let icon = Image::from_icon_name("list-add-symbolic"); + icon.add_css_class("vpn-add-icon"); + hbox.append(&icon); + + let label = Label::new(Some("Add VPN")); + label.add_css_class("vpn-add-label"); + hbox.append(&label); + + row.set_child(Some(&hbox)); + + let click = GestureClick::new(); + let stack = ctx.stack.clone(); + click.connect_pressed(move |_, _, _, _| { + stack.set_visible_child_name("vpn-add"); + }); + row.add_controller(click); + + let add_list = ListBox::new(); + add_list.add_css_class("vpn-list"); + add_list.append(&row); + list_container.append(&add_list); +} + +fn vpn_list_view( + ctx: Rc, + vpns: &[VpnConnection], + details_page: Rc, +) -> ListBox { + let list = ListBox::new(); + + for vpn in vpns { + let row = ListBoxRow::new(); + let hbox = GtkBox::new(Orientation::Horizontal, 6); + + row.add_css_class("network-selection"); + + if vpn.active { + row.add_css_class("connected"); + } + + let name_label = Label::new(Some(&vpn.name)); + hbox.append(&name_label); + + let type_label = Label::new(Some(vpn_type_short(&vpn.vpn_type))); + type_label.add_css_class("vpn-type-label"); + hbox.append(&type_label); + + if vpn.active { + let connected_label = Label::new(Some("Connected")); + connected_label.add_css_class("connected-label"); + hbox.append(&connected_label); + } + + let spacer = GtkBox::new(Orientation::Horizontal, 0); + spacer.set_hexpand(true); + hbox.append(&spacer); + + let icon = Image::from_icon_name("network-vpn-symbolic"); + icon.add_css_class("vpn-icon"); + hbox.append(&icon); + + let arrow = Image::from_icon_name("go-next-symbolic"); + arrow.set_halign(Align::End); + arrow.add_css_class("network-arrow"); + arrow.set_cursor_from_name(Some("pointer")); + hbox.append(&arrow); + + row.set_child(Some(&hbox)); + + // Arrow click -> details page + { + let click = GestureClick::new(); + let vpn_clone = vpn.clone(); + let ctx_c = ctx.clone(); + let page = details_page.clone(); + + click.connect_pressed(move |_, _, _, _| { + let vpn = vpn_clone.clone(); + let ctx = ctx_c.clone(); + let page = page.clone(); + + glib::MainContext::default().spawn_local(async move { + page.update(&vpn); + + if vpn.active { + if let Ok(info) = ctx.nm.get_vpn_info(&vpn.name).await { + page.enrich_with_info(&info); + } + } + + ctx.stack.set_visible_child_name("vpn-details"); + }); + }); + + arrow.add_controller(click); + } + + // Double-click row -> connect/disconnect + { + let click = GestureClick::new(); + let vpn_clone = vpn.clone(); + let ctx_c = ctx.clone(); + + click.connect_pressed(move |_, n, _, _| { + if n != 2 { + return; + } + + let vpn = vpn_clone.clone(); + let ctx = ctx_c.clone(); + let status = ctx.status.clone(); + let window = ctx.parent_window.clone(); + let on_success = ctx.on_success.clone(); + + glib::MainContext::default().spawn_local(async move { + window.set_sensitive(false); + + let result = if vpn.active { + status.set_text(&format!("Disconnecting {}...", vpn.name)); + ctx.nm.disconnect_vpn_by_uuid(&vpn.uuid).await + } else { + status.set_text(&format!("Connecting to {}...", vpn.name)); + ctx.nm.connect_vpn_by_uuid(&vpn.uuid).await + }; + + match result { + Ok(_) => { + status.set_text(""); + on_success(); + } + Err(e) => { + status.set_text(&format!("VPN error: {e}")); + } + } + + window.set_sensitive(true); + }); + }); + + row.add_controller(click); + } + + list.append(&row); + } + + list +} + +fn vpn_type_short(vpn_type: &nmrs::VpnType) -> &'static str { + match vpn_type { + nmrs::VpnType::WireGuard { .. } => "WireGuard", + nmrs::VpnType::OpenVpn { .. } => "OpenVPN", + nmrs::VpnType::OpenConnect { .. } => "OpenConnect", + nmrs::VpnType::StrongSwan { .. } => "strongSwan", + nmrs::VpnType::Pptp { .. } => "PPTP", + nmrs::VpnType::L2tp { .. } => "L2TP", + _ => "VPN", + } +}