diff --git a/rust/mseries-tailscale/README.md b/rust/mseries-tailscale/README.md index 453345c..542242c 100644 --- a/rust/mseries-tailscale/README.md +++ b/rust/mseries-tailscale/README.md @@ -25,6 +25,13 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened **Send all traffic over VPN connection** switch (`ipv4.never-default` / `ipv6.never-default` = no). The card says which mode is active; a connected VPN shows **Reconnect** after the mode changes. **Add VPN…** opens Network settings, where new VPNs are created. +- **VPN settings (gear button):** each VPN row has a gear that opens an edit sheet: server, username, password, + IPsec pre-shared key, "Send all traffic over VPN connection" and **Remember passwords** (on by default; stored + system-wide, readable only by root; off means ask every time). Secret boxes start empty; empty keeps what is + stored. Saving goes through NetworkManager's D-Bus `Update2`, so secrets never appear on a command line or in a + log. A connected VPN offers Reconnect afterwards. + + ![VPN row](docs/vpn-row-light.png) ![VPN settings sheet](docs/vpn-edit-light.png) - **Devices:** every device on the tailnet, online first, with IP, OS and when it was last seen. Search with Ctrl+F. Click a row or the copy button to copy its IP; the terminal button copies `ssh ` (the MagicDNS name, e.g. `ssh sdolbec-ser8`). diff --git a/rust/mseries-tailscale/docs/vpn-edit-light.png b/rust/mseries-tailscale/docs/vpn-edit-light.png new file mode 100644 index 0000000..89f9644 Binary files /dev/null and b/rust/mseries-tailscale/docs/vpn-edit-light.png differ diff --git a/rust/mseries-tailscale/docs/vpn-row-light.png b/rust/mseries-tailscale/docs/vpn-row-light.png new file mode 100644 index 0000000..5d9632a Binary files /dev/null and b/rust/mseries-tailscale/docs/vpn-row-light.png differ diff --git a/rust/mseries-tailscale/src/cli.rs b/rust/mseries-tailscale/src/cli.rs index 43cde0e..3c2f9ac 100644 --- a/rust/mseries-tailscale/src/cli.rs +++ b/rust/mseries-tailscale/src/cli.rs @@ -108,7 +108,11 @@ pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String let unsaved = std::fs::read_to_string(dir.join("unsaved-passwords.txt")).unwrap_or_default(); let uuid = argv.last().copied().unwrap_or_default(); let flags = if unsaved.lines().any(|l| l == uuid) { 2 } else { 0 }; - Ok((true, format!("password-flags = {flags}, ipsec-psk-flags = {flags}"), String::new())) + let data = format!( + "gateway = vpn.example.com, ipsec-enabled = yes, ipsec-psk-flags = {flags}, \ + password-flags = {flags}, user = name@example.com" + ); + Ok((true, data, String::new())) } _ => Ok((false, String::new(), "Error: not allowed in fixture mode".into())), }; diff --git a/rust/mseries-tailscale/src/lib.rs b/rust/mseries-tailscale/src/lib.rs index 6b37bd7..de38713 100644 --- a/rust/mseries-tailscale/src/lib.rs +++ b/rust/mseries-tailscale/src/lib.rs @@ -3,5 +3,6 @@ pub mod cli; pub mod model; +pub mod nm; pub mod strings; pub mod ui; diff --git a/rust/mseries-tailscale/src/main.rs b/rust/mseries-tailscale/src/main.rs index 8444bd6..8c7ec85 100644 --- a/rust/mseries-tailscale/src/main.rs +++ b/rust/mseries-tailscale/src/main.rs @@ -52,6 +52,21 @@ fn main() -> glib::ExitCode { win.window.connect_destroy(move |_| { s.borrow_mut().take(); }); + // MSERIES_TAILSCALE_SNAPSHOT_EDIT=1 opens the first VPN's edit sheet and renders it to -sheet.png. + if std::env::var("MSERIES_TAILSCALE_SNAPSHOT_EDIT").as_deref() == Ok("1") { + let w = win.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(800), move || w.open_first_vpn_sheet()); + let w = win.clone(); + if let Ok(path) = std::env::var("MSERIES_TAILSCALE_SNAPSHOT") { + let delay: u64 = + std::env::var("MSERIES_TAILSCALE_SNAPSHOT_DELAY").ok().and_then(|d| d.parse().ok()).unwrap_or(2500); + glib::timeout_add_local_once(std::time::Duration::from_millis(delay.saturating_sub(300)), move || { + if let Some(sheet) = w.sheet() { + render(&sheet, &path.replace(".png", "-sheet.png")); + } + }); + } + } maybe_snapshot(app, &win.window); *slot.borrow_mut() = Some(win); }); @@ -73,16 +88,7 @@ fn maybe_snapshot(app: >k::Application, window: >k::ApplicationWindow) { let window = window.clone(); let app = app.clone(); glib::timeout_add_local_once(std::time::Duration::from_millis(delay), move || { - let paintable = gtk::WidgetPaintable::new(Some(&window)); - let snapshot = gtk::Snapshot::new(); - paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64); - match (snapshot.to_node(), window.native().and_then(|n| n.renderer())) { - (Some(node), Some(renderer)) => match renderer.render_texture(&node, None).save_to_png(&path) { - Ok(()) => eprintln!("snapshot written to {path}"), - Err(e) => eprintln!("snapshot failed: {e}"), - }, - _ => eprintln!("nothing to render for the snapshot"), - } + render(window.upcast_ref(), &path); app.quit(); }); } @@ -100,3 +106,16 @@ fn find_switch(widget: >k::Widget) -> Option { } None } + +fn render(window: >k::Window, path: &str) { + let paintable = gtk::WidgetPaintable::new(Some(window)); + let snapshot = gtk::Snapshot::new(); + paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64); + match (snapshot.to_node(), window.native().and_then(|n| n.renderer())) { + (Some(node), Some(renderer)) => match renderer.render_texture(&node, None).save_to_png(path) { + Ok(()) => eprintln!("snapshot written to {path}"), + Err(e) => eprintln!("snapshot failed: {e}"), + }, + _ => eprintln!("nothing to render for the snapshot"), + } +} diff --git a/rust/mseries-tailscale/src/model.rs b/rust/mseries-tailscale/src/model.rs index c0397a0..5f3cedb 100644 --- a/rust/mseries-tailscale/src/model.rs +++ b/rust/mseries-tailscale/src/model.rs @@ -369,6 +369,15 @@ pub fn parse_vpn_list(text: &str) -> Vec { .collect() } +/// `nmcli -g vpn.data` ("key = value, key = value") as a map. Values with ", " inside are not expected here. +pub fn parse_vpn_data(text: &str) -> std::collections::HashMap { + text.trim() + .split(", ") + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + .collect() +} + #[cfg(test)] mod vpn_tests { use super::*; @@ -385,6 +394,9 @@ mod vpn_tests { assert!(!secrets_saved("password-flags = 0, ipsec-psk-flags = 2")); assert!(secrets_saved("password-flags = 0, ipsec-psk-flags = 0, user = a")); assert!(secrets_saved("")); + let d = parse_vpn_data("gateway = vpn.example.com, ipsec-enabled = yes, password-flags = 0, user = a@b.org\n"); + assert_eq!(d.get("gateway").map(String::as_str), Some("vpn.example.com")); + assert_eq!(d.get("user").map(String::as_str), Some("a@b.org")); assert!(needs_secrets("Error: Connection activation failed: Secrets were required, but not provided")); } } diff --git a/rust/mseries-tailscale/src/nm.rs b/rust/mseries-tailscale/src/nm.rs new file mode 100644 index 0000000..9064057 --- /dev/null +++ b/rust/mseries-tailscale/src/nm.rs @@ -0,0 +1,116 @@ +//! Saves a VPN connection through NetworkManager's D-Bus API (`Settings.Connection.Update2`). +//! +//! Secrets travel only over the system bus, never on a command line (where `ps` shows them), and are never +//! printed or logged. A blank secret field keeps the stored value: it is read back with `GetSecrets` and +//! written again, because `Update2` replaces the whole connection. + +use gtk::gio; +use gtk::glib::{self, Variant, prelude::*}; +use std::collections::HashMap; + +const NM: &str = "org.freedesktop.NetworkManager"; +const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const CONNECTION_IFACE: &str = "org.freedesktop.NetworkManager.Settings.Connection"; + +type Settings = HashMap>; + +/// What the edit sheet changes. `None` secrets keep what is stored. +pub struct VpnEdit { + pub server_key: String, + pub server: String, + pub user_key: String, + pub user: String, + pub password: Option, + pub psk: Option, + pub all_traffic: bool, + /// Store secrets (flag 0) or ask every time (flag 2). + pub remember: bool, +} + +async fn call( + bus: &gio::DBusConnection, + path: &str, + iface: &str, + method: &str, + args: Option<&Variant>, +) -> Result { + bus.call_future(Some(NM), path, iface, method, args, None, gio::DBusCallFlags::NONE, 30_000).await +} + +fn friendly(e: &glib::Error) -> String { + let m = e.message(); + if m.contains("NotAuthorized") || m.contains("not authorized") || m.contains("Insufficient privileges") { + "You didn't approve the change.".into() + } else { + // D-Bus errors carry the remote error name first; keep the human part. + m.rsplit(": ").next().unwrap_or(m).to_string() + } +} + +pub async fn save_vpn(uuid: &str, edit: VpnEdit) -> Result<(), String> { + let bus = gio::bus_get_future(gio::BusType::System).await.map_err(|e| friendly(&e))?; + let found = call( + &bus, + SETTINGS_PATH, + "org.freedesktop.NetworkManager.Settings", + "GetConnectionByUuid", + Some(&(uuid,).to_variant()), + ) + .await + .map_err(|e| friendly(&e))?; + let path: glib::variant::ObjectPath = found.child_value(0).get().ok_or("NetworkManager gave no connection")?; + let path = path.as_str().to_string(); + + let current = call(&bus, &path, CONNECTION_IFACE, "GetSettings", None).await.map_err(|e| friendly(&e))?; + let mut settings: Settings = current.child_value(0).get().ok_or("Couldn't read the VPN's settings")?; + + let vpn = settings.entry("vpn".into()).or_default(); + let mut data: HashMap = vpn.get("data").and_then(|v| v.get()).unwrap_or_default(); + let flag = if edit.remember { "0" } else { "2" }; + data.insert(edit.server_key.clone(), edit.server.trim().to_string()); + data.insert(edit.user_key.clone(), edit.user.trim().to_string()); + data.insert("password-flags".into(), flag.into()); + let has_psk = data.contains_key("ipsec-psk-flags") || data.get("ipsec-enabled").is_some_and(|v| v == "yes"); + if has_psk { + data.insert("ipsec-psk-flags".into(), flag.into()); + } + + // Keep stored secrets for blank fields: read them back (they never leave this process). + let mut secrets: HashMap = HashMap::new(); + if edit.remember && (edit.password.is_none() || (has_psk && edit.psk.is_none())) { + // Never wipe a stored secret by accident: if it can't be read back, stop and ask for it. + let stored = + call(&bus, &path, CONNECTION_IFACE, "GetSecrets", Some(&("vpn",).to_variant())).await.map_err(|_| { + "The saved password couldn't be read back. Type the password and key, then save.".to_string() + })?; + let stored: Settings = stored.child_value(0).get().unwrap_or_default(); + if let Some(s) = + stored.get("vpn").and_then(|v| v.get("secrets")).and_then(|v| v.get::>()) + { + secrets = s; + } + } + if edit.remember { + if let Some(p) = edit.password { + secrets.insert("password".into(), p); + } + if has_psk && let Some(k) = edit.psk { + secrets.insert("ipsec-psk".into(), k); + } + } else { + secrets.clear(); + } + vpn.insert("data".into(), data.to_variant()); + vpn.insert("secrets".into(), secrets.to_variant()); + + // macOS's "Send all traffic over VPN connection" is never-default = false. + for family in ["ipv4", "ipv6"] { + settings.entry(family.into()).or_default().insert("never-default".into(), (!edit.all_traffic).to_variant()); + } + + // Update2(settings, flags = TO_DISK, args = {}). + let args = (settings.to_variant(), 1u32, HashMap::::new().to_variant()); + let packed = Variant::tuple_from_iter([args.0, args.1.to_variant(), args.2]); + call(&bus, &path, CONNECTION_IFACE, "Update2", Some(&packed)).await.map_err(|e| friendly(&e))?; + Ok(()) +} diff --git a/rust/mseries-tailscale/src/ui/mod.rs b/rust/mseries-tailscale/src/ui/mod.rs index 14bbd0b..2976d6e 100644 --- a/rust/mseries-tailscale/src/ui/mod.rs +++ b/rust/mseries-tailscale/src/ui/mod.rs @@ -1,3 +1,4 @@ //! The Tailscale window: a status card with the connect switch, exit node, the tailnet's devices and settings. +pub mod vpn_edit; pub mod window; diff --git a/rust/mseries-tailscale/src/ui/vpn_edit.rs b/rust/mseries-tailscale/src/ui/vpn_edit.rs new file mode 100644 index 0000000..c989e68 --- /dev/null +++ b/rust/mseries-tailscale/src/ui/vpn_edit.rs @@ -0,0 +1,215 @@ +//! Edit sheet for one NetworkManager VPN: server, username, password, IPsec pre-shared key, "Send all traffic +//! over VPN connection", and whether to remember the passwords. Secret fields start empty; empty keeps what is +//! stored. Saving goes through `nm::save_vpn` (D-Bus), so secrets are never on a command line or in a log. + +use crate::cli; +use crate::model; +use crate::nm::{self, VpnEdit}; +use crate::strings::tr; +use gtk::{glib, prelude::*}; +use mseries_pantheon_kit::{chrome, set_accessible_label}; +use std::rc::Rc; + +/// Opens the sheet over `parent`. `on_saved(active)` runs after a successful save. +pub fn open( + parent: >k::Window, + toast: &granite::Toast, + vpn: &model::Vpn, + on_saved: impl Fn(bool) + 'static, +) -> gtk::Window { + let sheet = gtk::Window::builder() + .transient_for(parent) + .modal(true) + .destroy_with_parent(true) + .title(format!("{} {}", vpn.name, tr("Settings"))) + .default_width(460) + .resizable(false) + .build(); + sheet.add_css_class("mseries-tailscale"); + + let header = gtk::HeaderBar::builder().show_title_buttons(false).build(); + let cancel = gtk::Button::with_label(&tr("Cancel")); + let save = gtk::Button::with_label(&tr("Save")); + save.add_css_class(granite::CssClassSUGGESTED); + save.set_sensitive(false); + header.pack_start(&cancel); + header.pack_end(&save); + let title = gtk::Label::new(Some(&vpn.name)); + title.add_css_class("title"); + header.set_title_widget(Some(&title)); + sheet.set_titlebar(Some(&header)); + + let server = gtk::Entry::builder().hexpand(true).build(); + let user = gtk::Entry::builder().hexpand(true).build(); + let password = gtk::PasswordEntry::builder().show_peek_icon(true).hexpand(true).build(); + let psk = gtk::PasswordEntry::builder().show_peek_icon(true).hexpand(true).build(); + set_accessible_label(&server, &tr("Server")); + set_accessible_label(&user, &tr("Username")); + set_accessible_label(&password, &tr("Password")); + set_accessible_label(&psk, &tr("Pre-shared key")); + let all = gtk::Switch::builder().valign(gtk::Align::Center).active(vpn.all_traffic).build(); + let remember = gtk::Switch::builder().valign(gtk::Align::Center).active(true).build(); + set_accessible_label(&all, &tr("Send all traffic over VPN connection")); + set_accessible_label(&remember, &tr("Remember passwords")); + + let grid = gtk::Grid::builder().row_spacing(9).column_spacing(12).build(); + let mut row = 0; + let mut add = |label: &str, widget: >k::Widget| { + let l = gtk::Label::builder().label(label).xalign(1.0).build(); + l.add_css_class(granite::CssClassDIM); + grid.attach(&l, 0, row, 1, 1); + grid.attach(widget, 1, row, 1, 1); + row += 1; + }; + add(&tr("Server"), server.upcast_ref()); + add(&tr("Username"), user.upcast_ref()); + add(&tr("Password"), password.upcast_ref()); + add(&tr("Pre-shared key"), psk.upcast_ref()); + let psk_row = row - 1; + + let note = gtk::Label::builder().xalign(0.0).wrap(true).build(); + note.add_css_class(granite::CssClassDIM); + note.add_css_class(granite::CssClassSMALL); + + let switches = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + switches.add_css_class(granite::CssClassCARD); + switches.add_css_class("mseries-tailscale-card"); + switches.append(&row_with( + &tr("Send all traffic over VPN connection"), + &tr("Off sends only the VPN's own networks through it"), + &all, + )); + switches.append(>k::Separator::new(gtk::Orientation::Horizontal)); + switches.append(&row_with( + &tr("Remember passwords"), + &tr("Saved on this computer, readable only by the system. Off asks every time."), + &remember, + )); + + let error = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); + error.add_css_class(granite::CssClassERROR); + + let body = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(15) + .margin_top(18) + .margin_bottom(18) + .margin_start(18) + .margin_end(18) + .build(); + body.append(&grid); + body.append(¬e); + body.append(&switches); + body.append(&error); + sheet.set_child(Some(&body)); + + // Fill in the non-secret fields; secrets stay empty (empty keeps the stored value). + let keys = Rc::new(std::cell::RefCell::new(("gateway".to_string(), "user".to_string(), false))); + { + let (server, user, password, psk, note, save, keys, grid) = ( + server.clone(), + user.clone(), + password.clone(), + psk.clone(), + note.clone(), + save.clone(), + keys.clone(), + grid.clone(), + ); + let uuid = vpn.uuid.clone(); + glib::spawn_future_local(async move { + let data = match cli::run_program(&["nmcli", "-g", "vpn.data", "connection", "show", &uuid], 8).await { + Ok((true, out, _)) => model::parse_vpn_data(&out), + _ => Default::default(), + }; + let server_key = if data.contains_key("remote") { "remote" } else { "gateway" }; + let user_key = if data.contains_key("username") { "username" } else { "user" }; + let has_psk = data.contains_key("ipsec-psk-flags") || data.get("ipsec-enabled").is_some_and(|v| v == "yes"); + server.set_text(data.get(server_key).map(String::as_str).unwrap_or_default()); + user.set_text(data.get(user_key).map(String::as_str).unwrap_or_default()); + let saved = + model::secrets_saved(&data.iter().map(|(k, v)| format!("{k} = {v}")).collect::>().join(", ")); + let hint = if saved { tr("Saved. Leave empty to keep.") } else { tr("Not saved yet") }; + password.set_property("placeholder-text", &hint); + psk.set_property("placeholder-text", &hint); + for col in 0..2 { + if let Some(w) = grid.child_at(col, psk_row) { + w.set_visible(has_psk); + } + } + note.set_label(&if saved { + tr("Type a new password or key only to change it.") + } else { + tr("Type the password and pre-shared key once. They will be remembered.") + }); + *keys.borrow_mut() = (server_key.into(), user_key.into(), has_psk); + save.set_sensitive(true); + }); + } + + let s = sheet.clone(); + cancel.connect_clicked(move |_| s.close()); + let keyc = gtk::EventControllerKey::new(); + let s = sheet.clone(); + keyc.connect_key_pressed(move |_, key, _, _| { + if key == gtk::gdk::Key::Escape { + s.close(); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + sheet.add_controller(keyc); + + let on_saved = Rc::new(on_saved); + let (uuid, active, toast) = (vpn.uuid.clone(), vpn.active, toast.clone()); + let s = sheet.clone(); + save.connect_clicked(move |button| { + let (server_key, user_key, has_psk) = keys.borrow().clone(); + let secret = |e: >k::PasswordEntry| Some(e.text().to_string()).filter(|t| !t.is_empty()); + let edit = VpnEdit { + server_key, + server: server.text().to_string(), + user_key, + user: user.text().to_string(), + password: secret(&password), + psk: if has_psk { secret(&psk) } else { None }, + all_traffic: all.is_active(), + remember: remember.is_active(), + }; + button.set_sensitive(false); + error.set_visible(false); + let (uuid, s, button, error, toast, on_saved) = + (uuid.clone(), s.clone(), button.clone(), error.clone(), toast.clone(), on_saved.clone()); + glib::spawn_future_local(async move { + match nm::save_vpn(&uuid, edit).await { + Ok(()) => { + chrome::show_toast(&toast, &tr("VPN settings saved")); + on_saved(active); + s.close(); + } + Err(message) => { + error.set_label(&message); + error.set_visible(true); + button.set_sensitive(true); + } + } + }); + }); + + sheet.present(); + sheet +} + +fn row_with(title: &str, description: &str, control: >k::Switch) -> gtk::Box { + let label = gtk::Label::builder().label(title).xalign(0.0).wrap(true).build(); + let sub = gtk::Label::builder().label(description).xalign(0.0).wrap(true).build(); + sub.add_css_class(granite::CssClassDIM); + sub.add_css_class(granite::CssClassSMALL); + let text = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(2).hexpand(true).build(); + text.append(&label); + text.append(&sub); + let row = gtk::Box::builder().spacing(12).build(); + row.append(&text); + row.append(control); + row +} diff --git a/rust/mseries-tailscale/src/ui/window.rs b/rust/mseries-tailscale/src/ui/window.rs index 8cd01cd..2ce6dda 100644 --- a/rust/mseries-tailscale/src/ui/window.rs +++ b/rust/mseries-tailscale/src/ui/window.rs @@ -65,6 +65,7 @@ pub struct AppWindow { busy: Cell, refreshing: Cell, network: RefCell>, + sheet: RefCell>, poll: RefCell>, } @@ -334,6 +335,7 @@ impl AppWindow { busy: Cell::new(false), refreshing: Cell::new(false), network: RefCell::new(None), + sheet: RefCell::new(None), poll: RefCell::new(None), }); @@ -883,11 +885,22 @@ impl AppWindow { fn vpn_card(self: &Rc, vpn: &model::Vpn, needs_reconnect: bool) -> gtk::Box { let card = card_box(); let on = gtk::Switch::builder().valign(gtk::Align::Center).active(vpn.active).state(vpn.active).build(); + let gear = icon_button("emblem-system-symbolic", &format!("{} {}", vpn.name, tr("Settings"))); + let controls = gtk::Box::builder().spacing(6).valign(gtk::Align::Center).build(); + controls.append(&gear); + controls.append(&on); + set_accessible_label(&on, &format!("{} {}", tr("Connect"), vpn.name)); card.append(&setting_row( &vpn.name, Some(&if vpn.active { tr("Connected") } else { tr("Not connected") }), - &on, + &controls, )); + let (weak, edit_vpn) = (Rc::downgrade(self), vpn.clone()); + gear.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.open_vpn_sheet(&edit_vpn); + } + }); card.append(>k::Separator::new(gtk::Orientation::Horizontal)); let all = gtk::Switch::builder().valign(gtk::Align::Center).active(vpn.all_traffic).state(vpn.all_traffic).build(); @@ -900,9 +913,7 @@ impl AppWindow { if !vpn.secrets_saved { let line = gtk::Box::builder().spacing(12).build(); let note = gtk::Label::builder() - .label(tr( - "Passwords aren't saved, so connecting from here can't work. Save them once in the VPN's settings.", - )) + .label(tr("Passwords aren't saved, so connecting from here can't work. Save them once.")) .xalign(0.0) .hexpand(true) .wrap(true) @@ -914,10 +925,10 @@ impl AppWindow { line.append(&save); card.append(>k::Separator::new(gtk::Orientation::Horizontal)); card.append(&line); - let (weak, uuid) = (Rc::downgrade(self), vpn.uuid.clone()); + let (weak, edit_vpn) = (Rc::downgrade(self), vpn.clone()); save.connect_clicked(move |_| { if let Some(this) = weak.upgrade() { - this.edit_vpn(&uuid); + this.open_vpn_sheet(&edit_vpn); } }); } @@ -996,6 +1007,39 @@ impl AppWindow { card } + pub fn open_vpn_sheet(self: &Rc, vpn: &model::Vpn) { + let weak = Rc::downgrade(self); + let uuid = vpn.uuid.clone(); + let sheet = super::vpn_edit::open(self.window.upcast_ref(), &self.toast, vpn, move |active| { + if let Some(this) = weak.upgrade() { + if active { + // New settings apply on the next connect; offer Reconnect on the card. + this.vpn_pending.borrow_mut().insert(uuid.clone()); + } + this.vpn_key.borrow_mut().clear(); + this.refresh(); + } + }); + *self.sheet.borrow_mut() = Some(sheet); + } + + /// The first VPN, for fixture screenshots of the edit sheet. + pub fn open_first_vpn_sheet(self: &Rc) { + let this = self.clone(); + glib::spawn_future_local(async move { + if let Ok((true, out, _)) = + cli::run_program(&["nmcli", "-t", "-f", "NAME,UUID,TYPE,ACTIVE", "connection", "show"], 8).await + && let Some(vpn) = model::parse_vpn_list(&out).into_iter().next() + { + this.open_vpn_sheet(&vpn); + } + }); + } + + pub fn sheet(&self) -> Option { + self.sheet.borrow().clone() + } + /// Opens NetworkManager's editor for one connection, where "Store the password for all users" lives. fn edit_vpn(&self, uuid: &str) { if let Err(e) = gio::Subprocess::newv(