Skip to content
This repository was archived by the owner on Nov 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions rust/mseries-tailscale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened
Connecting…, "Dropped: on, but not carrying any traffic", or "Failed: <reason>" (from NetworkManager, pppd and
IPsec logs), with Reconnect. "Internet traffic now goes through …" names the interface the kernel really uses
(`ip route get`, policy routing included).
- **Connecting and timer in the header:** turning a VPN on shows a spinner and "Connecting…" in its row at once,
with the switch locked until there is an answer; the header bar's subtitle reads "BFS · Connecting…", then
"BFS · 00:12:34" (the same timer is in the row). A drop or failure stops the timer and the row says why.

![Header states](docs/vpn-header-states.png)
- **Timer and traffic:** each connected VPN, and Tailscale, shows how long it has been up (kept across app
restarts, reset on reconnect) and bytes down/up.
- **Two VPNs at once:** a banner when another tunnel (WiFiman's `utun`, any WireGuard/tun/PPP device) is up next
Expand Down
Binary file added rust/mseries-tailscale/docs/vpn-header-states.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions rust/mseries-tailscale/src/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,10 @@ pub fn bytes_text(n: u64) -> String {
}
}

/// "0:04:09", "12:30:00".
/// "00:04:09", "12:30:00".
pub fn duration_text(secs: i64) -> String {
let s = secs.max(0);
format!("{}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)
format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)
}

/// A friendly name for a tunnel interface that is not one of the listed VPNs.
Expand All @@ -160,7 +160,7 @@ mod tests {
assert_eq!(bytes_text(999), "999 B");
assert_eq!(bytes_text(4_307_842), "4.3 MB");
assert_eq!(bytes_text(48_300_000), "48 MB");
assert_eq!(duration_text(249), "0:04:09");
assert_eq!(duration_text(249), "00:04:09");
assert_eq!(duration_text(45_000), "12:30:00");
assert_eq!(tunnel_owner("utun"), "WiFiman (Ubiquiti Teleport)");
}
Expand Down
53 changes: 52 additions & 1 deletion rust/mseries-tailscale/src/ui/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub struct AppWindow {
route_now: gtk::Label,
sessions: Sessions,
ts_timer: gtk::Label,
header_sub: gtk::Label,
vpn_names: RefCell<HashMap<String, String>>,
remote_list: gtk::Box,
remote_key: RefCell<String>,
ticker: RefCell<Option<mseries_pantheon_kit::Polling>>,
Expand Down Expand Up @@ -99,6 +101,17 @@ impl AppWindow {
set_accessible_label(&refresh, &tr("Refresh"));
header.pack_start(&refresh);
header.pack_end(&chrome::primary_menu(&tr("About Tailscale"), &tr("Quit")));
// Title with a live subtitle: "BFS · 00:12:34" while a VPN is up, "BFS · Connecting…" while it starts.
let header_title = gtk::Label::new(Some(&tr("Tailscale")));
header_title.add_css_class("title");
let header_sub = gtk::Label::builder().visible(false).build();
header_sub.add_css_class(granite::CssClassSMALL);
header_sub.add_css_class(granite::CssClassNUMERIC);
header_sub.add_css_class(granite::CssClassDIM);
let titles_box = gtk::Box::builder().orientation(gtk::Orientation::Vertical).valign(gtk::Align::Center).build();
titles_box.append(&header_title);
titles_box.append(&header_sub);
header.set_title_widget(Some(&titles_box));
window.set_titlebar(Some(&header));

// ---- Status card ------------------------------------------------------------------------------------
Expand Down Expand Up @@ -366,6 +379,8 @@ impl AppWindow {
route_now,
sessions: Sessions::load(),
ts_timer,
header_sub,
vpn_names: RefCell::new(HashMap::new()),
remote_list,
remote_key: RefCell::new(String::new()),
ticker: RefCell::new(None),
Expand Down Expand Up @@ -991,6 +1006,7 @@ impl AppWindow {
self.sessions.stop(&vpn.uuid);
}
self.vpn_live.borrow_mut().insert(vpn.uuid.clone(), live.clone());
self.vpn_names.borrow_mut().insert(vpn.uuid.clone(), model::display_name(&vpn.name));
lives.push(live);
}
self.sessions.save();
Expand Down Expand Up @@ -1090,6 +1106,8 @@ impl AppWindow {
controls.append(&gear);
controls.append(&on);
set_accessible_label(&on, &format!("{} {name}", tr("Connect")));
// No second click until the first one has an answer.
on.set_sensitive(!matches!(live, Live::Connecting));
let (status_text, status_class) = match live {
Live::Connected(iface) => (format!("{} · {iface}", tr("Connected")), Some(granite::CssClassSUCCESS)),
Live::Connecting => (tr("Connecting…"), None),
Expand Down Expand Up @@ -1218,8 +1236,18 @@ impl AppWindow {
}

let (weak, uuid) = (Rc::downgrade(self), vpn.uuid.clone());
on.connect_state_set(move |_, active| {
let desc = row.first_child().and_then(|t| t.last_child()).and_downcast::<gtk::Label>();
let controls_now = controls.clone();
on.connect_state_set(move |switch, active| {
if let Some(this) = weak.upgrade() {
// Instant feedback: spinner, "Connecting…", switch locked until nmcli answers.
switch.set_sensitive(false);
controls_now.prepend(&gtk::Spinner::builder().spinning(true).valign(gtk::Align::Center).build());
if let Some(desc) = &desc {
desc.set_label(&if active { tr("Connecting…") } else { tr("Disconnecting…") });
desc.remove_css_class(granite::CssClassERROR);
desc.remove_css_class(granite::CssClassSUCCESS);
}
let verb = if active { "up" } else { "down" };
let done = if active { tr("Connected to") } else { tr("Disconnected from") };
this.vpn_failures.borrow_mut().remove(&uuid);
Expand Down Expand Up @@ -1255,6 +1283,29 @@ impl AppWindow {
t.bytes.set_label(&format!("↓ {} ↑ {}", net::bytes_text(rx), net::bytes_text(tx)));
}
}
let names = self.vpn_names.borrow();
let mut parts: Vec<String> = Vec::new();
for t in self.vpn_ticks.borrow().iter() {
if let (Some(start), Some(name)) = (self.sessions.started(&t.key), names.get(&t.key)) {
parts.push(format!("{name} · {}", net::duration_text(now - start)));
}
}
let connecting: HashSet<String> = self
.vpn_live
.borrow()
.iter()
.filter(|(_, l)| matches!(l, Live::Connecting))
.map(|(u, _)| u.clone())
.chain(self.vpn_connecting.borrow().iter().cloned())
.collect();
for uuid in &connecting {
if let Some(name) = names.get(uuid) {
parts.push(format!("{name} · {}", tr("Connecting…")));
}
}
drop(names);
self.header_sub.set_visible(!parts.is_empty());
self.header_sub.set_label(&parts.join(" "));
match self.sessions.started("tailscale") {
Some(start) if self.status_is_running() => {
self.ts_timer.set_label(&format!("{} {}", tr("Connected for"), net::duration_text(now - start)));
Expand Down