diff --git a/rust/mseries-tailscale/README.md b/rust/mseries-tailscale/README.md index efd8018..453345c 100644 --- a/rust/mseries-tailscale/README.md +++ b/rust/mseries-tailscale/README.md @@ -8,6 +8,8 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened | ![Connected](docs/main-light.png) | ![Connected, dark](docs/main-dark.png) | | **Disconnected** | **A refused change shows the one-time setup** | | ![Disconnected](docs/stopped-light.png) | ![Setup banner](docs/setup-light.png) | +| **Whole window** | **Dark** | +| ![Whole window](docs/full-light.png) | ![Whole window, dark](docs/full-dark.png) | | **Logged out** | **Service not running** | | ![Log in](docs/login-light.png) | ![Not running](docs/notrunning-dark.png) | @@ -15,8 +17,14 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened - **Status card:** connected or disconnected, tailnet and account, and a switch that runs `tailscale up` / `down`. This device's name and IP, with a copy button. -- **Exit node:** pick any device that offers itself as an exit node (`tailscale set --exit-node=IP`), or None, and - allow local network access while using one. +- **Route All Traffic Through:** pick a tailnet exit node (`tailscale set --exit-node=IP`), or None, and allow + local network access while using one (`--exit-node-allow-lan-access`). The status card then says "All internet + traffic goes through …". With no exit node on the tailnet it explains how to add one + (`sudo tailscale set --advertise-exit-node` on an always-on device, then approve it in the Admin Console). +- **VPN Connections:** the computer's other VPNs from NetworkManager, each with a connect switch and macOS's + **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. - **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`). @@ -32,7 +40,10 @@ control springs back to the real state, and the window shows the one-time comman sudo tailscale set --operator=$USER ``` -The window refreshes every 3 s while it is open, after each change, and on Ctrl+R / F5. +The window refreshes every 3 s while it is open, after each change, when the network changes (Wi-Fi switches +make tailscaled reconnect), and on Ctrl+R / F5. Every command runs off the UI thread with a time limit (8 s for +reads, 15–25 s for Tailscale changes, 90 s for VPN changes, which may wait on a password prompt); a command that +runs out of time is stopped and the window says Tailscale isn't answering instead of freezing. ## Build, install, screenshots diff --git a/rust/mseries-tailscale/docs/full-dark.png b/rust/mseries-tailscale/docs/full-dark.png new file mode 100644 index 0000000..7bfb160 Binary files /dev/null and b/rust/mseries-tailscale/docs/full-dark.png differ diff --git a/rust/mseries-tailscale/docs/full-light.png b/rust/mseries-tailscale/docs/full-light.png new file mode 100644 index 0000000..2507f18 Binary files /dev/null and b/rust/mseries-tailscale/docs/full-light.png differ diff --git a/rust/mseries-tailscale/docs/main-dark.png b/rust/mseries-tailscale/docs/main-dark.png index a4aa62e..a06c6df 100644 Binary files a/rust/mseries-tailscale/docs/main-dark.png and b/rust/mseries-tailscale/docs/main-dark.png differ diff --git a/rust/mseries-tailscale/docs/main-light.png b/rust/mseries-tailscale/docs/main-light.png index 9a48c80..7462149 100644 Binary files a/rust/mseries-tailscale/docs/main-light.png and b/rust/mseries-tailscale/docs/main-light.png differ diff --git a/rust/mseries-tailscale/fixtures/nmcli.txt b/rust/mseries-tailscale/fixtures/nmcli.txt new file mode 100644 index 0000000..97adeb4 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/nmcli.txt @@ -0,0 +1,2 @@ +Home Wi-Fi:11111111-aaaa:802-11-wireless:yes +BFS:4a1d6d4d-c5e4:vpn:yes diff --git a/rust/mseries-tailscale/fixtures/split-tunnel.txt b/rust/mseries-tailscale/fixtures/split-tunnel.txt new file mode 100644 index 0000000..c051887 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/split-tunnel.txt @@ -0,0 +1 @@ +4a1d6d4d-c5e4 diff --git a/rust/mseries-tailscale/snapshot.sh b/rust/mseries-tailscale/snapshot.sh index 92b6134..fca5830 100755 --- a/rust/mseries-tailscale/snapshot.sh +++ b/rust/mseries-tailscale/snapshot.sh @@ -14,9 +14,9 @@ for f in "$here"/data/*.svg; do install -Dm644 "$f" "$data/icons/hicolor/$dir/apps/$(basename "$f")" done for scheme in light dark; do - xvfb-run -a -s "-screen 0 1000x1300x24" env GSK_RENDERER=cairo GDK_BACKEND=x11 \ + xvfb-run -a -s "-screen 0 1000x1700x24" env GSK_RENDERER=cairo GDK_BACKEND=x11 \ XDG_DATA_DIRS="$data:/usr/local/share:/usr/share" \ - MSERIES_TAILSCALE_FIXTURES="$fixtures" MSERIES_TAILSCALE_DARK=$([[ $scheme == dark ]] && echo 1 || echo 0) \ + MSERIES_TAILSCALE_FIXTURES="$fixtures" ${SIZE:+MSERIES_TAILSCALE_SNAPSHOT_SIZE=$SIZE} MSERIES_TAILSCALE_DARK=$([[ $scheme == dark ]] && echo 1 || echo 0) \ MSERIES_TAILSCALE_SNAPSHOT="$out/${PREFIX:-main}-$scheme.png" MSERIES_TAILSCALE_SNAPSHOT_DELAY=${DELAY:-2500} \ dbus-run-session -- "$bin" 2>&1 | grep -viE "egl|mesa|dri3|dbus" || true done diff --git a/rust/mseries-tailscale/src/cli.rs b/rust/mseries-tailscale/src/cli.rs index 0752162..40ae415 100644 --- a/rust/mseries-tailscale/src/cli.rs +++ b/rust/mseries-tailscale/src/cli.rs @@ -14,6 +14,8 @@ pub enum CliError { NotRunning, /// Changing settings needs root or the operator setting. AccessDenied, + /// The command did not finish in time and was stopped. + TimedOut(String), Failed(String), } @@ -23,6 +25,7 @@ impl CliError { Self::NotInstalled => "Tailscale isn't installed.".into(), Self::NotRunning => "The Tailscale service isn't running.".into(), Self::AccessDenied => "Changing Tailscale settings needs a one-time setup.".into(), + Self::TimedOut(what) => format!("{what} didn't answer in time. Check your network and try again."), Self::Failed(text) => text.clone(), } } @@ -69,9 +72,42 @@ pub async fn run(args: &[&str]) -> Result { }; } let bin = binary(); - let mut argv: Vec<&std::ffi::OsStr> = vec![bin.as_ref()]; - argv.extend(args.iter().map(|a| std::ffi::OsStr::new(*a))); - let proc = gio::Subprocess::newv(&argv, gio::SubprocessFlags::STDOUT_PIPE | gio::SubprocessFlags::STDERR_PIPE) + let mut argv: Vec<&str> = vec![&bin]; + argv.extend_from_slice(args); + // Changes can wait on the network (`up` after a link change); reads should be instant. + let limit = match args.first() { + Some(&"up") | Some(&"down") => 25, + Some(&"set") => 15, + _ => 8, + }; + match run_program(&argv, limit).await { + Ok((true, out, _)) => Ok(out), + // `status --json` exits non-zero when stopped or logged out but still prints the state. + Ok((false, out, _)) if args.first() == Some(&"status") && !out.trim().is_empty() => Ok(out), + Ok((false, _, err)) => Err(classify(&err)), + Err(CliError::TimedOut(_)) => Err(CliError::TimedOut("Tailscale".into())), + Err(e) => Err(e), + } +} + +/// Runs ARGV with a time limit in seconds; returns (succeeded, stdout, stderr). Stops the process on timeout. +pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String, String), CliError> { + if let (Some(dir), Some(&"nmcli")) = (fixtures(), argv.first()) { + // Fixture VPNs: `nmcli.txt` is the connection list; every VPN sends all traffic unless listed in + // `split-tunnel.txt`; changes are refused like Tailscale's. + let list = std::fs::read_to_string(dir.join("nmcli.txt")).unwrap_or_default(); + return match argv.get(1..4) { + Some(["-t", "-f", _]) => Ok((true, list, String::new())), + Some(["-g", "ipv4.never-default", _]) => { + let split = std::fs::read_to_string(dir.join("split-tunnel.txt")).unwrap_or_default(); + let uuid = argv.last().copied().unwrap_or_default(); + Ok((true, if split.lines().any(|l| l == uuid) { "yes" } else { "no" }.into(), String::new())) + } + _ => Ok((false, String::new(), "Error: not allowed in fixture mode".into())), + }; + } + let os: Vec<&std::ffi::OsStr> = argv.iter().map(std::ffi::OsStr::new).collect(); + let proc = gio::Subprocess::newv(&os, gio::SubprocessFlags::STDOUT_PIPE | gio::SubprocessFlags::STDERR_PIPE) .map_err(|e| { if e.matches(gio::IOErrorEnum::NotFound) || e.message().contains("No such file") { CliError::NotInstalled @@ -79,16 +115,18 @@ pub async fn run(args: &[&str]) -> Result { CliError::Failed(e.message().to_string()) } })?; - let (out, err) = proc.communicate_utf8_future(None).await.map_err(|e| CliError::Failed(e.message().to_string()))?; - let out = out.map(|s| s.to_string()).unwrap_or_default(); - let err = err.map(|s| s.to_string()).unwrap_or_default(); - if proc.is_successful() { - Ok(out) - } else if args.first() == Some(&"status") && !out.trim().is_empty() { - // `status --json` exits non-zero when stopped or logged out but still prints the state. - Ok(out) - } else { - Err(classify(&err)) + let running = proc.communicate_utf8_future(None); + match gtk::glib::future_with_timeout(std::time::Duration::from_secs(limit_secs), running).await { + Err(_) => { + proc.force_exit(); + Err(CliError::TimedOut(argv.first().copied().unwrap_or_default().to_string())) + } + Ok(Err(e)) => Err(CliError::Failed(e.message().to_string())), + Ok(Ok((out, err))) => Ok(( + proc.is_successful(), + out.map(|s| s.to_string()).unwrap_or_default(), + err.map(|s| s.to_string()).unwrap_or_default(), + )), } } diff --git a/rust/mseries-tailscale/src/main.rs b/rust/mseries-tailscale/src/main.rs index 911b4f5..8444bd6 100644 --- a/rust/mseries-tailscale/src/main.rs +++ b/rust/mseries-tailscale/src/main.rs @@ -40,6 +40,13 @@ fn main() -> glib::ExitCode { return; } let win = AppWindow::new(app); + // MSERIES_TAILSCALE_SNAPSHOT_SIZE=560x1500 renders the whole page for screenshots. + if let Some((w, h)) = std::env::var("MSERIES_TAILSCALE_SNAPSHOT_SIZE").ok().and_then(|v| { + let (w, h) = v.split_once('x')?; + Some((w.parse().ok()?, h.parse().ok()?)) + }) { + win.window.set_default_size(w, h); + } win.window.present(); let s = slot.clone(); win.window.connect_destroy(move |_| { diff --git a/rust/mseries-tailscale/src/model.rs b/rust/mseries-tailscale/src/model.rs index 3d5165c..3286caa 100644 --- a/rust/mseries-tailscale/src/model.rs +++ b/rust/mseries-tailscale/src/model.rs @@ -308,3 +308,55 @@ mod tests { assert_eq!(presence_text(false, Some(0), 86_400 * 90), "Last seen 3 months ago"); } } + +/// A NetworkManager VPN connection (`nmcli`), with macOS's "Send all traffic over VPN" setting +/// (NetworkManager's `never-default` set to no). +#[derive(Debug, Clone, PartialEq)] +pub struct Vpn { + pub name: String, + pub uuid: String, + pub active: bool, + pub all_traffic: bool, +} + +/// Splits one `nmcli -t` line on unescaped colons and unescapes `\:` and `\\`. +pub fn nmcli_fields(line: &str) -> Vec { + let mut fields = vec![String::new()]; + let mut chars = line.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => { + if let Some(n) = chars.next() { + fields.last_mut().unwrap().push(n); + } + } + ':' => fields.push(String::new()), + c => fields.last_mut().unwrap().push(c), + } + } + fields +} + +/// VPNs from `nmcli -t -f NAME,UUID,TYPE,ACTIVE connection show` (WireGuard counts; Tailscale's own does not). +pub fn parse_vpn_list(text: &str) -> Vec { + text.lines() + .map(nmcli_fields) + .filter(|f| f.len() >= 4 && (f[2] == "vpn" || f[2] == "wireguard") && f[0] != "tailscale0") + .map(|f| Vpn { name: f[0].clone(), uuid: f[1].clone(), active: f[3] == "yes", all_traffic: false }) + .collect() +} + +#[cfg(test)] +mod vpn_tests { + use super::*; + + #[test] + fn parses_nmcli() { + let text = "Home Wi\\:Fi:aaa:802-11-wireless:yes\nBFS:4a1d:vpn:no\nWork\\\\VPN:bbb:wireguard:yes\nlo:ccc:loopback:yes\n"; + let vpns = parse_vpn_list(text); + assert_eq!(vpns.len(), 2); + assert_eq!((vpns[0].name.as_str(), vpns[0].uuid.as_str(), vpns[0].active), ("BFS", "4a1d", false)); + assert_eq!((vpns[1].name.as_str(), vpns[1].active), ("Work\\VPN", true)); + assert_eq!(nmcli_fields("a\\:b:c"), vec!["a:b", "c"]); + } +} diff --git a/rust/mseries-tailscale/src/ui/window.rs b/rust/mseries-tailscale/src/ui/window.rs index 8782000..ac8c93f 100644 --- a/rust/mseries-tailscale/src/ui/window.rs +++ b/rust/mseries-tailscale/src/ui/window.rs @@ -29,6 +29,7 @@ pub struct AppWindow { status_icon: gtk::Image, status_title: gtk::Label, status_detail: gtk::Label, + route_line: gtk::Label, connect: gtk::Switch, this_name: gtk::Label, this_ip: gtk::Label, @@ -42,6 +43,10 @@ pub struct AppWindow { exit_ids: RefCell>, exit_note: gtk::Label, lan_access: gtk::Switch, + // NetworkManager VPNs + vpn_list: gtk::Box, + vpn_key: RefCell, + vpn_pending: RefCell>, // Devices devices_header: granite::HeaderLabel, search: gtk::SearchEntry, @@ -58,6 +63,8 @@ pub struct AppWindow { prefs: RefCell, updating: Cell, busy: Cell, + refreshing: Cell, + network: RefCell>, poll: RefCell>, } @@ -90,6 +97,9 @@ impl AppWindow { titles.set_valign(gtk::Align::Center); titles.append(&status_title); titles.append(&status_detail); + let route_line = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); + route_line.add_css_class(granite::CssClassACCENT); + titles.append(&route_line); let connect = gtk::Switch::builder().valign(gtk::Align::Center).build(); set_accessible_label(&connect, &tr("Connected")); let top = gtk::Box::builder().spacing(12).build(); @@ -155,8 +165,8 @@ impl AppWindow { health.add_css_class(granite::CssClassWARNING); // ---- Exit node -------------------------------------------------------------------------------------- - let exit_header = granite::HeaderLabel::new(&tr("Exit Node")); - exit_header.set_secondary_text(Some(&tr("Send all internet traffic through another device"))); + let exit_header = granite::HeaderLabel::new(&tr("Route All Traffic Through")); + exit_header.set_secondary_text(Some(&tr("Send all internet traffic through another device on your tailnet"))); let exit_choice = gtk::DropDown::from_strings(&[&tr("None")]); exit_choice.set_valign(gtk::Align::Center); set_accessible_label(&exit_choice, &tr("Exit node")); @@ -164,7 +174,7 @@ impl AppWindow { exit_note.add_css_class(granite::CssClassDIM); let lan_access = gtk::Switch::builder().valign(gtk::Align::Center).build(); let exit_card = card_box(); - exit_card.append(&setting_row(&tr("Use exit node"), None, &exit_choice)); + exit_card.append(&setting_row(&tr("Exit node"), None, &exit_choice)); exit_card.append(&exit_note); exit_card.append(>k::Separator::new(gtk::Orientation::Horizontal)); exit_card.append(&setting_row( @@ -186,6 +196,22 @@ impl AppWindow { devices_box.append(&device_list); let devices_section = section(&devices_header, &devices_box); + // ---- Other VPNs (NetworkManager) --------------------------------------------------------------------- + let vpn_header = granite::HeaderLabel::new(&tr("VPN Connections")); + vpn_header.set_secondary_text(Some(&tr("Other VPNs on this computer"))); + let vpn_list = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + let add_vpn = gtk::Button::with_label(&tr("Add VPN…")); + add_vpn.set_halign(gtk::Align::Start); + add_vpn.set_tooltip_text(Some(&tr("Opens Network settings"))); + add_vpn.connect_clicked(|b| { + let window = b.root().and_downcast::(); + gtk::UriLauncher::new("settings://network").launch(window.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + let vpn_body = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + vpn_body.append(&vpn_list); + vpn_body.append(&add_vpn); + let vpn_section = section(&vpn_header, &vpn_body); + // ---- Settings --------------------------------------------------------------------------------------- let accept_dns = gtk::Switch::builder().valign(gtk::Align::Center).build(); let accept_routes = gtk::Switch::builder().valign(gtk::Align::Center).build(); @@ -239,6 +265,7 @@ impl AppWindow { column.append(&health); column.append(&exit_section); column.append(&devices_section); + column.append(&vpn_section); column.append(&settings_section); column.append(&footer); let scroller = gtk::ScrolledWindow::builder().hscrollbar_policy(gtk::PolicyType::Never).child(&column).build(); @@ -277,6 +304,7 @@ impl AppWindow { status_icon, status_title, status_detail, + route_line, connect, this_name, this_ip, @@ -288,6 +316,9 @@ impl AppWindow { exit_ids: RefCell::new(Vec::new()), exit_note, lan_access, + vpn_list, + vpn_key: RefCell::new(String::new()), + vpn_pending: RefCell::new(Default::default()), devices_header, search, device_list, @@ -301,6 +332,8 @@ impl AppWindow { prefs: RefCell::new(Prefs::default()), updating: Cell::new(false), busy: Cell::new(false), + refreshing: Cell::new(false), + network: RefCell::new(None), poll: RefCell::new(None), }); @@ -315,6 +348,14 @@ impl AppWindow { }); poll.bind_to_widget_mapped(&this.window); *this.poll.borrow_mut() = Some(poll); + // Wi-Fi changes make tailscaled reconnect; show it at once instead of waiting for the next poll. + let weak = Rc::downgrade(&this); + let id = gio::NetworkMonitor::default().connect_network_changed(move |_, _| { + if let Some(this) = weak.upgrade() { + this.refresh(); + } + }); + *this.network.borrow_mut() = Some(id); this.refresh(); this } @@ -433,6 +474,11 @@ impl AppWindow { return; } let args: Vec = args.into_iter().map(String::from).collect(); + if args.first().map(String::as_str) == Some("up") { + self.status_title.set_label(&tr("Connecting…")); + } else if args.first().map(String::as_str) == Some("down") { + self.status_title.set_label(&tr("Disconnecting…")); + } let this = self.clone(); glib::spawn_future_local(async move { let argv: Vec<&str> = args.iter().map(String::as_str).collect(); @@ -455,7 +501,8 @@ impl AppWindow { } pub fn refresh(self: &Rc) { - if self.busy.get() { + // A change in flight refreshes when it ends; never stack refreshes behind a slow command. + if self.busy.get() || self.refreshing.get() { return; } let this = self.clone(); @@ -463,7 +510,10 @@ impl AppWindow { } async fn refresh_now(self: &Rc) { + self.refreshing.set(true); + self.refresh_vpns().await; let status = cli::run(&["status", "--json"]).await; + self.refreshing.set(false); let prefs = cli::run(&["debug", "prefs"]).await.ok().and_then(|p| model::parse_prefs(&p).ok()); match status.map(|json| model::parse_status(&json)) { Ok(Ok(status)) => { @@ -475,6 +525,18 @@ impl AppWindow { Ok(Err(_)) => { self.show_problem(&tr("Couldn't read Tailscale's status"), &tr("Try again in a moment."), None) } + Err(CliError::TimedOut(_)) => { + // Keep the last good view; say why it is not updating. + if self.status.borrow().is_some() { + self.status_detail.set_label(&tr("Tailscale isn't answering. Retrying…")); + } else { + self.show_problem( + &tr("Tailscale isn't answering"), + &tr("The Tailscale service is slow to respond. This window keeps trying."), + None, + ); + } + } Err(CliError::NotInstalled) => self.show_problem( &tr("Tailscale isn't installed"), &tr("Install Tailscale, then open this window again."), @@ -574,6 +636,11 @@ impl AppWindow { detail.insert(0, tr("Turn on to reach your devices")); } self.status_detail.set_label(&detail.join(" · ")); + let route = status.current_exit_node().filter(|_| connected).map(|d| d.name.clone()); + self.route_line.set_visible(route.is_some()); + if let Some(name) = &route { + self.route_line.set_label(&format!("{} {name}", tr("All internet traffic goes through"))); + } self.connect.set_active(connected || status.backend == Backend::Starting); self.connect.set_state(connected); @@ -609,7 +676,8 @@ impl AppWindow { self.exit_choice.set_sensitive(connected && !options.is_empty()); self.exit_note.set_visible(options.is_empty()); self.exit_note.set_label(&tr( - "No device on your tailnet offers itself as an exit node yet. Turn it on in that device's Tailscale settings.", + "No device on your tailnet offers to route traffic yet. On an always-on device, like sdolbec-ser8, run \ + “sudo tailscale set --advertise-exit-node”, then approve it under Edit route settings in the Admin Console.", )); let prefs = self.prefs.borrow().clone(); self.lan_access.set_active(prefs.allow_lan_access); @@ -774,6 +842,149 @@ impl AppWindow { row } + async fn refresh_vpns(self: &Rc) { + let listed = cli::run_program(&["nmcli", "-t", "-f", "NAME,UUID,TYPE,ACTIVE", "connection", "show"], 8).await; + let mut vpns = match listed { + Ok((true, out, _)) => model::parse_vpn_list(&out), + _ => Vec::new(), + }; + for vpn in &mut vpns { + if let Ok((true, out, _)) = + cli::run_program(&["nmcli", "-g", "ipv4.never-default", "connection", "show", &vpn.uuid], 8).await + { + vpn.all_traffic = out.trim() == "no"; + } + } + let pending = self.vpn_pending.borrow().clone(); + let key = format!("{vpns:?}|{pending:?}"); + if *self.vpn_key.borrow() == key { + return; + } + *self.vpn_key.borrow_mut() = key; + while let Some(child) = self.vpn_list.first_child() { + self.vpn_list.remove(&child); + } + if vpns.is_empty() { + let none = gtk::Label::builder().label(tr("No other VPNs set up")).xalign(0.0).build(); + none.add_css_class(granite::CssClassDIM); + self.vpn_list.append(&none); + } + for vpn in vpns { + let needs_reconnect = pending.contains(&vpn.uuid) && vpn.active; + self.vpn_list.append(&self.vpn_card(&vpn, needs_reconnect)); + } + } + + 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(); + card.append(&setting_row( + &vpn.name, + Some(&if vpn.active { tr("Connected") } else { tr("Not connected") }), + &on, + )); + 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(); + let mode = if vpn.all_traffic { + format!("{} {}", tr("All internet traffic goes through"), vpn.name) + } else { + format!("{} {}", tr("Off: only its own networks go through"), vpn.name) + }; + card.append(&setting_row(&tr("Send all traffic over VPN connection"), Some(&mode), &all)); + if needs_reconnect { + let line = gtk::Box::builder().spacing(12).build(); + let note = gtk::Label::builder() + .label(tr("Reconnect to use the new setting.")) + .xalign(0.0) + .hexpand(true) + .wrap(true) + .build(); + note.add_css_class(granite::CssClassWARNING); + let reconnect = gtk::Button::with_label(&tr("Reconnect")); + reconnect.add_css_class(granite::CssClassSUGGESTED); + line.append(¬e); + line.append(&reconnect); + card.append(&line); + let (weak, uuid, name) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.name.clone()); + reconnect.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.vpn_command( + vec!["sh", "-c", "nmcli connection down uuid \"$0\" && nmcli connection up uuid \"$0\"", &uuid], + format!("{} {name}", tr("Reconnected")), + Some(uuid.clone()), + ); + } + }); + } + + let (weak, uuid, name) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.name.clone()); + on.connect_state_set(move |_, active| { + if let Some(this) = weak.upgrade() { + let verb = if active { "up" } else { "down" }; + let done = if active { tr("Connected to") } else { tr("Disconnected from") }; + this.vpn_command(vec!["nmcli", "connection", verb, "uuid", &uuid], format!("{done} {name}"), None); + } + glib::Propagation::Stop + }); + let (weak, uuid, active) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.active); + all.connect_state_set(move |_, everything| { + if let Some(this) = weak.upgrade() { + let never_default = if everything { "no" } else { "yes" }; + let done = if everything { + tr("All traffic will go over the VPN") + } else { + tr("Only the VPN's own networks will use it") + }; + if active { + this.vpn_pending.borrow_mut().insert(uuid.clone()); + } + this.vpn_command( + vec![ + "nmcli", + "connection", + "modify", + "uuid", + &uuid, + "ipv4.never-default", + never_default, + "ipv6.never-default", + never_default, + ], + done, + None, + ); + } + glib::Propagation::Stop + }); + card + } + + /// Runs an `nmcli` change (which may ask for a password through polkit), then refreshes. + fn vpn_command(self: &Rc, argv: Vec<&str>, done: String, clears_pending: Option) { + let argv: Vec = argv.into_iter().map(String::from).collect(); + let this = self.clone(); + glib::spawn_future_local(async move { + let refs: Vec<&str> = argv.iter().map(String::as_str).collect(); + // Connecting an L2TP/IPsec VPN takes a while; a polkit prompt waits for the user. + match cli::run_program(&refs, 90).await { + Ok((true, _, _)) => { + if let Some(uuid) = clears_pending { + this.vpn_pending.borrow_mut().remove(&uuid); + } + chrome::show_toast(&this.toast, &done); + } + Ok((false, _, err)) => { + let first = err.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("The VPN change failed."); + chrome::show_toast(&this.toast, first.trim_start_matches("Error: ")); + } + Err(e) => chrome::show_toast(&this.toast, &e.message()), + } + this.vpn_key.borrow_mut().clear(); + this.refresh_vpns().await; + }); + } + fn copy(&self, text: &str, confirmation: &str) { self.window.clipboard().set_text(text); chrome::show_toast(&self.toast, confirmation);