diff --git a/.github/workflows/mseries-tailscale.yml b/.github/workflows/mseries-tailscale.yml new file mode 100644 index 0000000..d1cc0ea --- /dev/null +++ b/.github/workflows/mseries-tailscale.yml @@ -0,0 +1,74 @@ +name: Tailscale app + +on: + workflow_dispatch: + push: + branches: [dev, main] + paths: + - "rust/mseries-pantheon-kit/**" + - "rust/mseries-tailscale/**" + - "rust/Cargo.toml" + - "rust/Cargo.lock" + - ".github/workflows/mseries-tailscale.yml" + pull_request: + paths: + - "rust/mseries-pantheon-kit/**" + - "rust/mseries-tailscale/**" + - "rust/Cargo.toml" + - "rust/Cargo.lock" + - ".github/workflows/mseries-tailscale.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Tailscale test/build/render (arm64) + runs-on: ubuntu-26.04-arm + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/fast-apt + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config cargo rustc rustfmt rust-clippy \ + libgtk-4-dev libgranite-7-dev desktop-file-utils xvfb xauth dbus-x11 python3 + + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: mseries-tailscale-${{ runner.arch }}-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: mseries-tailscale-${{ runner.arch }}- + + - name: Formatting and strict lint + run: | + cargo fmt --manifest-path rust/Cargo.toml -p mseries-tailscale --check + cargo clippy --manifest-path rust/Cargo.toml -p mseries-tailscale --locked -- -D warnings + + - name: Unit tests and app conventions + run: | + cargo test --manifest-path rust/Cargo.toml -p mseries-tailscale --locked + cargo test --manifest-path rust/Cargo.toml -p mseries-pantheon-kit --test conventions --locked + + - name: Validate desktop metadata + run: desktop-file-validate rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.desktop + + - name: Render the window from fixture status, light and dark + run: bash rust/mseries-tailscale/snapshot.sh "$RUNNER_TEMP/tailscale-snapshots" + + - uses: actions/upload-artifact@v7 + with: + name: tailscale-snapshots-${{ github.run_id }} + path: ${{ runner.temp }}/tailscale-snapshots/ + if-no-files-found: error diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d4515bd..d0c0c12 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2438,6 +2438,17 @@ dependencies = [ "vte4", ] +[[package]] +name = "mseries-tailscale" +version = "0.1.0" +dependencies = [ + "granite-rs", + "gtk4", + "mseries-pantheon-kit", + "serde", + "serde_json", +] + [[package]] name = "mseries-update-core" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3d5de93..117d7f7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["mseries-power", "granite-smoke", "mseries-update-core", "mseries-updates", "mseries-diskspace", "mseries-pantheon-kit", "mseries-image-studio", "mseries-ai", "mseries-reply", "mseries-messages", "mseries-system-monitor", "claude-pantheon-rs", "msdk", "mseries-broker", "msdk-gtk", "mseries-dock", "mseries-greeter", "mseries-apple-dev", "torrential", "mseries-signal-history"] +members = ["mseries-power", "granite-smoke", "mseries-update-core", "mseries-updates", "mseries-diskspace", "mseries-pantheon-kit", "mseries-image-studio", "mseries-ai", "mseries-reply", "mseries-messages", "mseries-system-monitor", "claude-pantheon-rs", "msdk", "mseries-broker", "msdk-gtk", "mseries-dock", "mseries-greeter", "mseries-apple-dev", "mseries-tailscale", "torrential", "mseries-signal-history"] resolver = "2" # Smaller, faster binaries for the session components (the compositor runs on diff --git a/rust/mseries-pantheon-kit/data/conventions.json b/rust/mseries-pantheon-kit/data/conventions.json index 45fee60..76a574c 100644 --- a/rust/mseries-pantheon-kit/data/conventions.json +++ b/rust/mseries-pantheon-kit/data/conventions.json @@ -92,6 +92,14 @@ "exempt": { "gettext": "uses Fluent catalogs (i18n-embed; de, en-GB) rather than gettext" } + }, + { + "name": "Tailscale", + "dir": "mseries-tailscale", + "desktop": [ + "org.techmore.mseries.Tailscale.desktop" + ], + "exempt": {} } ] } diff --git a/rust/mseries-tailscale/Cargo.toml b/rust/mseries-tailscale/Cargo.toml new file mode 100644 index 0000000..b3940f9 --- /dev/null +++ b/rust/mseries-tailscale/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mseries-tailscale" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-only" +publish = false +description = "Tailscale: a native Pantheon window for your tailnet: connect, exit nodes, devices, copy IP or ssh" + +[dependencies] +mseries-pantheon-kit = { path = "../mseries-pantheon-kit" } +# Same GTK/Granite pins as mseries-apple-dev (see mseries/RUST-UPSTREAM-MAINTENANCE.md). +gtk = { package = "gtk4", version = "0.11", features = ["v4_20"] } +granite = { package = "granite-rs", version = "3.3", features = ["v7_8", "gtk_v4_20"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/rust/mseries-tailscale/README.md b/rust/mseries-tailscale/README.md new file mode 100644 index 0000000..efd8018 --- /dev/null +++ b/rust/mseries-tailscale/README.md @@ -0,0 +1,56 @@ +# Tailscale + +A native Pantheon window for Tailscale. It replaces the old launcher that opened Tailscale's read-only web page +(`http://100.100.100.100/`) in a browser tab. + +| Connected | Dark | +| --- | --- | +| ![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) | +| **Logged out** | **Service not running** | +| ![Log in](docs/login-light.png) | ![Not running](docs/notrunning-dark.png) | + +## What it does + +- **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. +- **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`). +- **Settings:** Tailscale DNS, subnet routes, incoming connections (shields up), and the Tailscale SSH server, + each a `tailscale set` flag. +- **Logged out:** "Log In…" starts `tailscale login` and opens the sign-in page. +- **Service not running:** says so and copies `sudo systemctl enable --now tailscaled`. + +Reading needs no root. Changes need the operator setting; without it Tailscale refuses ("access denied"), every +control springs back to the real state, and the window shows the one-time command with a Copy button: + +```sh +sudo tailscale set --operator=$USER +``` + +The window refreshes every 3 s while it is open, after each change, and on Ctrl+R / F5. + +## Build, install, screenshots + +```sh +rust/mseries-tailscale/install-local.sh # ~/.local, no sudo +rust/mseries-tailscale/snapshot.sh OUT_DIR # light/dark PNGs from fixtures/ under Xvfb +cargo test -p mseries-tailscale # parsing and error classification +``` + +`MSERIES_TAILSCALE_FIXTURES=DIR` makes the app read `status.json` / `prefs.json` from DIR (every change is then +refused, which is how the setup-banner screenshot is made with `MSERIES_TAILSCALE_SNAPSHOT_TOGGLE=1`). + +## Icon + +The app icon and the status icons redraw the Tailscale logo from the dot layout in Tailscale's own tray code, +[`client/systray/logo.go`](https://github.com/tailscale/tailscale/blob/v1.104.4/client/systray/logo.go) +(BSD-3-Clause, © Tailscale Inc & contributors): lit dots when connected, all grey when not. The Tailscale name +and logo are trademarks of Tailscale Inc., used here only to label Tailscale itself. + +The top-panel menu and Work Mode taskbar button live in the OS repo +(techmore/ElementaryOS_MSeries `mseries/tailscale-wingpanel`). diff --git a/rust/mseries-tailscale/data/mseries-tailscale-offline-symbolic.svg b/rust/mseries-tailscale/data/mseries-tailscale-offline-symbolic.svg new file mode 100644 index 0000000..61aab6d --- /dev/null +++ b/rust/mseries-tailscale/data/mseries-tailscale-offline-symbolic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/rust/mseries-tailscale/data/mseries-tailscale-symbolic.svg b/rust/mseries-tailscale/data/mseries-tailscale-symbolic.svg new file mode 100644 index 0000000..dca170c --- /dev/null +++ b/rust/mseries-tailscale/data/mseries-tailscale-symbolic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale-symbolic.svg b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale-symbolic.svg new file mode 100644 index 0000000..dca170c --- /dev/null +++ b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale-symbolic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.desktop b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.desktop new file mode 100644 index 0000000..b6cfe0b --- /dev/null +++ b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Tailscale +GenericName=VPN +Comment=Connect to your tailnet, pick an exit node, and copy any device's IP or ssh command +Exec=mseries-tailscale +Icon=org.techmore.mseries.Tailscale +Terminal=false +Categories=GTK;Network; +Keywords=tailscale;vpn;tailnet;ssh;exit node;network; +StartupNotify=true +StartupWMClass=org.techmore.mseries.Tailscale diff --git a/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.svg b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.svg new file mode 100644 index 0000000..55ac078 --- /dev/null +++ b/rust/mseries-tailscale/data/org.techmore.mseries.Tailscale.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/rust/mseries-tailscale/data/style.css b/rust/mseries-tailscale/data/style.css new file mode 100644 index 0000000..443b0f0 --- /dev/null +++ b/rust/mseries-tailscale/data/style.css @@ -0,0 +1,26 @@ +/* Tailscale window. Theme colours only (APP-CONVENTIONS: no hex literals). */ +.mseries-tailscale .mseries-tailscale-card { + padding: 12px 15px; +} + +.mseries-tailscale .mseries-tailscale-setup { + background-color: alpha(@warning_color, 0.12); + box-shadow: inset 0 0 0 1px alpha(@warning_color, 0.35); +} + +.mseries-tailscale .mseries-tailscale-badge { + border-radius: 999px; + padding: 1px 8px; + font-size: 0.8em; + font-weight: 700; + color: @accent_color; + background-color: alpha(@accent_color, 0.18); +} + +.mseries-tailscale .mseries-tailscale-devices > row { + min-height: 48px; +} + +.mseries-tailscale .mseries-tailscale-dot { + font-size: 0.7em; +} diff --git a/rust/mseries-tailscale/docs/login-light.png b/rust/mseries-tailscale/docs/login-light.png new file mode 100644 index 0000000..5810c2e Binary files /dev/null and b/rust/mseries-tailscale/docs/login-light.png differ diff --git a/rust/mseries-tailscale/docs/main-dark.png b/rust/mseries-tailscale/docs/main-dark.png new file mode 100644 index 0000000..a4aa62e Binary files /dev/null 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 new file mode 100644 index 0000000..9a48c80 Binary files /dev/null and b/rust/mseries-tailscale/docs/main-light.png differ diff --git a/rust/mseries-tailscale/docs/notrunning-dark.png b/rust/mseries-tailscale/docs/notrunning-dark.png new file mode 100644 index 0000000..81e2626 Binary files /dev/null and b/rust/mseries-tailscale/docs/notrunning-dark.png differ diff --git a/rust/mseries-tailscale/docs/setup-light.png b/rust/mseries-tailscale/docs/setup-light.png new file mode 100644 index 0000000..25a9033 Binary files /dev/null and b/rust/mseries-tailscale/docs/setup-light.png differ diff --git a/rust/mseries-tailscale/docs/stopped-light.png b/rust/mseries-tailscale/docs/stopped-light.png new file mode 100644 index 0000000..ba2169e Binary files /dev/null and b/rust/mseries-tailscale/docs/stopped-light.png differ diff --git a/rust/mseries-tailscale/fixtures/prefs.json b/rust/mseries-tailscale/fixtures/prefs.json new file mode 100644 index 0000000..a349f26 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/prefs.json @@ -0,0 +1,11 @@ +{ + "ControlURL": "https://controlplane.tailscale.com", + "RouteAll": false, + "ExitNodeID": "", + "ExitNodeIP": "", + "ExitNodeAllowLANAccess": false, + "CorpDNS": true, + "RunSSH": false, + "WantRunning": true, + "ShieldsUp": false +} diff --git a/rust/mseries-tailscale/fixtures/status.json b/rust/mseries-tailscale/fixtures/status.json new file mode 100644 index 0000000..9b222df --- /dev/null +++ b/rust/mseries-tailscale/fixtures/status.json @@ -0,0 +1,200 @@ +{ + "Version": "1.104.4-t938213198-gd38c96d6a", + "BackendState": "Running", + "AuthURL": "", + "Self": { + "ID": "self", + "HostName": "eos", + "DNSName": "eos.tail1234.ts.net.", + "OS": "linux", + "TailscaleIPs": [ + "100.115.140.33", + "fd7a:115c:a1e0::7c32:8c22" + ], + "Online": true, + "LastSeen": "0001-01-01T00:00:00Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "Peer": { + "nodekey:00": { + "ID": "n0", + "HostName": "CSP-BFS-mini", + "DNSName": "csp-bfs-mini.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.82.173.72", + "fd7a:115c:a1e0::fc01:ad48" + ], + "Online": true, + "LastSeen": "0001-01-01T00:00:00Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:01": { + "ID": "n1", + "HostName": "CSP-Mini", + "DNSName": "csp-mini.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.117.181.76", + "fd7a:115c:a1e0::a001:b553" + ], + "Online": false, + "LastSeen": "2025-09-06T05:26:42.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:02": { + "ID": "n2", + "HostName": "Techmore-10g", + "DNSName": "techmore-10g.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.92.228.81", + "fd7a:115c:a1e0::1932:e451" + ], + "Online": false, + "LastSeen": "2026-08-24T21:09:21.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:03": { + "ID": "n3", + "HostName": "admin’s Mac mini", + "DNSName": "admins-mac-mini.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.116.199.21", + "fd7a:115c:a1e0::5a32:c715" + ], + "Online": false, + "LastSeen": "2025-09-29T14:53:09.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:04": { + "ID": "n4", + "HostName": "elementary-m1", + "DNSName": "elementary-m1.tail1234.ts.net.", + "OS": "linux", + "TailscaleIPs": [ + "100.110.219.14", + "fd7a:115c:a1e0::2432:db0f" + ], + "Online": false, + "LastSeen": "2026-10-14T16:26:08.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:05": { + "ID": "n5", + "HostName": "localhost", + "DNSName": "iphone-14-pro.tail1234.ts.net.", + "OS": "iOS", + "TailscaleIPs": [ + "100.96.99.44", + "fd7a:115c:a1e0::7d32:632c" + ], + "Online": true, + "LastSeen": "0001-01-01T00:00:00Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:06": { + "ID": "n6", + "HostName": "sdolbec-SER8", + "DNSName": "sdolbec-ser8.tail1234.ts.net.", + "OS": "linux", + "TailscaleIPs": [ + "100.127.99.74", + "fd7a:115c:a1e0::a32:634b" + ], + "Online": true, + "LastSeen": "0001-01-01T00:00:00Z", + "ExitNode": true, + "ExitNodeOption": true, + "UserID": 8343998775294178 + }, + "nodekey:07": { + "ID": "n7", + "HostName": "sdolbec’s Mac mini", + "DNSName": "sdolbecs-mac-mini.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.76.227.61", + "fd7a:115c:a1e0::3b32:e33d" + ], + "Online": false, + "LastSeen": "2026-01-31T00:30:30.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:08": { + "ID": "n8", + "HostName": "techmore (2)", + "DNSName": "techmore-2.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.69.38.89", + "fd7a:115c:a1e0::de01:265a" + ], + "Online": false, + "LastSeen": "2026-10-27T17:21:38.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:09": { + "ID": "n9", + "HostName": "techmore’s Mac mini", + "DNSName": "techmores-mac-mini.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.126.148.10", + "fd7a:115c:a1e0::6201:940c" + ], + "Online": false, + "LastSeen": "2026-06-06T17:44:47.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + }, + "nodekey:10": { + "ID": "n10", + "HostName": "techmore’s Mac mini", + "DNSName": "techmores-mac-mini-1.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.103.145.120", + "fd7a:115c:a1e0::6832:9179" + ], + "Online": false, + "LastSeen": "2026-07-04T00:35:09.1Z", + "ExitNode": false, + "ExitNodeOption": false, + "UserID": 8343998775294178 + } + }, + "CurrentTailnet": { + "Name": "example@gmail.com", + "MagicDNSSuffix": "tail1234.ts.net", + "MagicDNSEnabled": true + }, + "User": { + "8343998775294178": { + "ID": 8343998775294178, + "LoginName": "example@gmail.com", + "DisplayName": "Example" + } + }, + "Health": [] +} diff --git a/rust/mseries-tailscale/install-local.sh b/rust/mseries-tailscale/install-local.sh new file mode 100755 index 0000000..e85e9de --- /dev/null +++ b/rust/mseries-tailscale/install-local.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Install Tailscale for the current user (~/.local), no sudo. PROFILE=debug installs the debug build. +# rust/mseries-tailscale/install-local.sh +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +id=org.techmore.mseries.Tailscale +profile="${PROFILE:-release}" +flag=(); [[ $profile == release ]] && flag=(--release) +(cd "$here/.." && cargo build "${flag[@]}" -p mseries-tailscale) +target_dir="$(cd "$here/.." && cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')" +data="${XDG_DATA_HOME:-$HOME/.local/share}" +install -Dm755 "$target_dir/$profile/mseries-tailscale" "$HOME/.local/bin/mseries-tailscale" +mkdir -p "$data/applications" +sed "s|^Exec=mseries-tailscale|Exec=$HOME/.local/bin/mseries-tailscale|" "$here/data/$id.desktop" >"$data/applications/$id.desktop" +install -Dm644 "$here/data/$id.svg" "$data/icons/hicolor/scalable/apps/$id.svg" +for icon in "$id-symbolic" mseries-tailscale-symbolic mseries-tailscale-offline-symbolic; do + install -Dm644 "$here/data/$icon.svg" "$data/icons/hicolor/symbolic/apps/$icon.svg" +done +# The old launcher opened Tailscale's web page in a browser; this app replaces it. +old="$data/applications/tailscale-control.desktop" +if [[ -f $old ]]; then + mkdir -p "$data/applications/.replaced" && mv -f "$old" "$data/applications/.replaced/" +fi +gtk-update-icon-cache -q -t -f "$data/icons/hicolor" 2>/dev/null || true +update-desktop-database -q "$data/applications" 2>/dev/null || true +echo "Installed Tailscale ($profile) to ~/.local. Open it from the applications menu." diff --git a/rust/mseries-tailscale/rustfmt.toml b/rust/mseries-tailscale/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rust/mseries-tailscale/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/rust/mseries-tailscale/snapshot.sh b/rust/mseries-tailscale/snapshot.sh new file mode 100755 index 0000000..92b6134 --- /dev/null +++ b/rust/mseries-tailscale/snapshot.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Render the Tailscale window to PNGs (light and dark) under Xvfb from the fixture status, for review and PRs. +# rust/mseries-tailscale/snapshot.sh OUT_DIR [FIXTURE_DIR] +set -euo pipefail +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +out=$(mkdir -p "$1" && cd "$1" && pwd) +fixtures=${2:-$here/fixtures} +(cd "$here/.." && cargo build -q -p mseries-tailscale) +bin="$(cd "$here/.." && cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')/debug/mseries-tailscale" +# Icons come from a private data dir so the snapshot does not depend on what is installed. +data=$(mktemp -d "${XDG_CACHE_HOME:-$HOME/.cache}/tailscale-snapshot.XXXXXX"); trap 'rm -rf "$data"' EXIT +for f in "$here"/data/*.svg; do + case $f in *-symbolic.svg) dir=symbolic ;; *) dir=scalable ;; esac + 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 \ + XDG_DATA_DIRS="$data:/usr/local/share:/usr/share" \ + MSERIES_TAILSCALE_FIXTURES="$fixtures" 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 +ls -la "$out" diff --git a/rust/mseries-tailscale/src/cli.rs b/rust/mseries-tailscale/src/cli.rs new file mode 100644 index 0000000..0752162 --- /dev/null +++ b/rust/mseries-tailscale/src/cli.rs @@ -0,0 +1,136 @@ +//! Runs the `tailscale` command line without blocking the UI. +//! +//! `MSERIES_TAILSCALE_BIN` overrides the binary. `MSERIES_TAILSCALE_FIXTURES=DIR` reads `status.json` and +//! `prefs.json` from DIR instead, and every change then fails with "access denied", for screenshots and tests. + +use gtk::gio; +use gtk::prelude::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum CliError { + /// No `tailscale` binary. + NotInstalled, + /// `tailscaled` is not running (or its socket is missing). + NotRunning, + /// Changing settings needs root or the operator setting. + AccessDenied, + Failed(String), +} + +impl CliError { + pub fn message(&self) -> String { + match self { + 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::Failed(text) => text.clone(), + } + } +} + +/// Command that lets the current user change Tailscale settings without sudo. +pub fn operator_command() -> String { + format!("sudo tailscale set --operator={}", gtk::glib::user_name().to_string_lossy()) +} + +pub const START_SERVICE_COMMAND: &str = "sudo systemctl enable --now tailscaled"; + +fn binary() -> String { + std::env::var("MSERIES_TAILSCALE_BIN").unwrap_or_else(|_| "tailscale".into()) +} + +fn fixtures() -> Option { + std::env::var_os("MSERIES_TAILSCALE_FIXTURES").map(Into::into) +} + +/// Sorts a failed command's output into a state the window can explain. +pub fn classify(stderr: &str) -> CliError { + let lower = stderr.to_lowercase(); + if lower.contains("access denied") || lower.contains("permission denied") || lower.contains("prefs write access") { + CliError::AccessDenied + } else if lower.contains("failed to connect to local tailscaled") + || lower.contains("tailscaled.sock") + || lower.contains("is tailscaled running") + { + CliError::NotRunning + } else { + let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("Tailscale didn't respond."); + CliError::Failed(first.to_string()) + } +} + +/// Runs `tailscale ARGS…` and returns its standard output. +pub async fn run(args: &[&str]) -> Result { + if let Some(dir) = fixtures() { + return match args { + ["status", "--json"] => std::fs::read_to_string(dir.join("status.json")).map_err(|_| CliError::NotRunning), + ["debug", "prefs"] => std::fs::read_to_string(dir.join("prefs.json")).map_err(|_| CliError::NotRunning), + _ => Err(CliError::AccessDenied), + }; + } + 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) + .map_err(|e| { + if e.matches(gio::IOErrorEnum::NotFound) || e.message().contains("No such file") { + CliError::NotInstalled + } else { + 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)) + } +} + +/// Starts `tailscale login` and returns the sign-in URL it prints, without waiting for the sign-in to finish. +pub async fn login_url() -> Result { + if fixtures().is_some() { + return Err(CliError::AccessDenied); + } + let proc = gio::Subprocess::newv( + &[binary().as_ref(), std::ffi::OsStr::new("login")], + gio::SubprocessFlags::STDOUT_PIPE | gio::SubprocessFlags::STDERR_MERGE, + ) + .map_err(|e| CliError::Failed(e.message().to_string()))?; + let stream = gio::DataInputStream::new(&proc.stdout_pipe().expect("stdout pipe")); + let mut seen = String::new(); + loop { + match stream.read_line_utf8_future(gtk::glib::Priority::DEFAULT).await { + Ok(Some(line)) => { + if let Some(url) = line.split_whitespace().find(|w| w.starts_with("https://")) { + return Ok(url.to_string()); + } + seen.push_str(&line); + seen.push('\n'); + } + Ok(None) => break, + Err(e) => return Err(CliError::Failed(e.message().to_string())), + } + } + // Exited without a URL: already logged in, or an error. + if seen.trim().is_empty() { Ok(String::new()) } else { Err(classify(&seen)) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_errors() { + let denied = "Access denied: checkprefs access denied\n\nUse 'sudo tailscale set --exit-node='."; + assert_eq!(classify(denied), CliError::AccessDenied); + let down = "failed to connect to local tailscaled; it doesn't appear to be running (sudo systemctl start tailscaled ?)"; + assert_eq!(classify(down), CliError::NotRunning); + assert_eq!(classify("\nboom\nmore"), CliError::Failed("boom".into())); + } +} diff --git a/rust/mseries-tailscale/src/lib.rs b/rust/mseries-tailscale/src/lib.rs new file mode 100644 index 0000000..6b37bd7 --- /dev/null +++ b/rust/mseries-tailscale/src/lib.rs @@ -0,0 +1,7 @@ +//! Tailscale for Pantheon: status, connect, exit nodes, settings and the tailnet's devices, read from and +//! written through the `tailscale` command line (no root for reading; changes need the operator setting). + +pub mod cli; +pub mod model; +pub mod strings; +pub mod ui; diff --git a/rust/mseries-tailscale/src/main.rs b/rust/mseries-tailscale/src/main.rs new file mode 100644 index 0000000..911b4f5 --- /dev/null +++ b/rust/mseries-tailscale/src/main.rs @@ -0,0 +1,95 @@ +//! `mseries-tailscale`: the Tailscale window. +//! +//! Debug aids: `MSERIES_TAILSCALE_SNAPSHOT=/path.png` renders the window to a PNG after +//! `MSERIES_TAILSCALE_SNAPSHOT_DELAY` ms (default 2500) and quits; `MSERIES_TAILSCALE_DARK=1|0` forces the colour +//! scheme; `MSERIES_TAILSCALE_FIXTURES=DIR` reads status and prefs from DIR (see `cli.rs`). + +use gtk::{glib, prelude::*}; +use mseries_pantheon_kit::chrome; +use mseries_tailscale::strings::tr; +use mseries_tailscale::ui::window::{APP_ID, AppWindow}; +use std::cell::RefCell; +use std::rc::Rc; + +fn main() -> glib::ExitCode { + let app = gtk::Application::builder().application_id(APP_ID).build(); + let window: Rc>>> = Rc::new(RefCell::new(None)); + + app.connect_startup(|app| { + granite::init(); + if let Some(display) = gtk::gdk::Display::default() { + mseries_pantheon_kit::install_css(&display); + } + mseries_pantheon_kit::appearance::follow_color_scheme(Some("MSERIES_TAILSCALE_DARK")); + chrome::install_app_actions( + app, + chrome::About { + program_name: tr("Tailscale"), + icon: APP_ID.into(), + comments: tr("Connect to your tailnet, pick an exit node, and copy any device's IP or ssh command."), + menu_label: tr("About Tailscale"), + website: Some("https://github.com/techmore/mseries-sdk-apps".into()), + }, + ); + }); + + let slot = window.clone(); + app.connect_activate(move |app| { + if let Some(existing) = slot.borrow().as_ref() { + existing.window.present(); + return; + } + let win = AppWindow::new(app); + win.window.present(); + let s = slot.clone(); + win.window.connect_destroy(move |_| { + s.borrow_mut().take(); + }); + maybe_snapshot(app, &win.window); + *slot.borrow_mut() = Some(win); + }); + app.run() +} + +fn maybe_snapshot(app: >k::Application, window: >k::ApplicationWindow) { + let Ok(path) = std::env::var("MSERIES_TAILSCALE_SNAPSHOT") else { return }; + let delay = std::env::var("MSERIES_TAILSCALE_SNAPSHOT_DELAY").ok().and_then(|d| d.parse().ok()).unwrap_or(2500); + // MSERIES_TAILSCALE_SNAPSHOT_TOGGLE=1 flips the connect switch first (a refused change in fixture mode). + if std::env::var("MSERIES_TAILSCALE_SNAPSHOT_TOGGLE").as_deref() == Ok("1") { + let window = window.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(delay / 3), move || { + if let Some(switch) = find_switch(window.upcast_ref()) { + switch.set_active(!switch.is_active()); + } + }); + } + 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"), + } + app.quit(); + }); +} + +fn find_switch(widget: >k::Widget) -> Option { + if let Ok(switch) = widget.clone().downcast::() { + return Some(switch); + } + let mut child = widget.first_child(); + while let Some(c) = child { + if let Some(found) = find_switch(&c) { + return Some(found); + } + child = c.next_sibling(); + } + None +} diff --git a/rust/mseries-tailscale/src/model.rs b/rust/mseries-tailscale/src/model.rs new file mode 100644 index 0000000..3d5165c --- /dev/null +++ b/rust/mseries-tailscale/src/model.rs @@ -0,0 +1,310 @@ +//! `tailscale status --json` and `tailscale debug prefs`, reduced to what the window shows. + +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Backend { + Running, + Starting, + Stopped, + NeedsLogin, + NeedsMachineAuth, + Other(String), +} + +impl Backend { + fn from_str(s: &str) -> Self { + match s { + "Running" => Self::Running, + "Starting" => Self::Starting, + "Stopped" => Self::Stopped, + "NeedsLogin" | "NoState" => Self::NeedsLogin, + "NeedsMachineAuth" => Self::NeedsMachineAuth, + other => Self::Other(other.to_string()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Device { + pub id: String, + pub name: String, + /// First MagicDNS label, what `ssh` takes ("sdolbec-ser8"). + pub ssh_host: String, + pub ipv4: String, + pub ipv6: String, + pub os: String, + pub online: bool, + /// RFC 3339; `None` when Tailscale has no time (online devices report year 1). + pub last_seen: Option, + pub exit_node: bool, + pub exit_node_option: bool, +} + +impl Device { + /// The address people type: IPv4 when there is one. + pub fn ip(&self) -> &str { + if self.ipv4.is_empty() { &self.ipv6 } else { &self.ipv4 } + } + + pub fn ssh_command(&self) -> String { + format!("ssh {}", if self.ssh_host.is_empty() { self.ip() } else { &self.ssh_host }) + } + + pub fn os_name(&self) -> &str { + match self.os.as_str() { + "linux" => "Linux", + "windows" => "Windows", + "android" => "Android", + "" => "Unknown", + other => other, // macOS, iOS, tvOS already read well + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Status { + pub backend: Backend, + pub auth_url: String, + pub tailnet: String, + pub account: String, + pub version: String, + pub this_device: Option, + /// Online first, then by name. + pub peers: Vec, + pub health: Vec, +} + +impl Status { + pub fn exit_node_options(&self) -> Vec<&Device> { + self.peers.iter().filter(|p| p.exit_node_option).collect() + } + + pub fn current_exit_node(&self) -> Option<&Device> { + self.peers.iter().find(|p| p.exit_node) + } + + pub fn online_count(&self) -> usize { + self.peers.iter().filter(|p| p.online).count() + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Prefs { + pub allow_lan_access: bool, + pub accept_dns: bool, + pub accept_routes: bool, + pub shields_up: bool, + pub run_ssh: bool, + pub operator: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawStatus { + #[serde(default)] + backend_state: String, + #[serde(default, rename = "AuthURL")] + auth_url: String, + #[serde(default)] + version: String, + #[serde(rename = "Self")] + this: Option, + #[serde(default)] + peer: Option>, + #[serde(default)] + current_tailnet: Option, + #[serde(default)] + health: Option>, + #[serde(default)] + user: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawTailnet { + #[serde(default)] + name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawUser { + #[serde(default)] + login_name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawPeer { + #[serde(default, rename = "ID")] + id: String, + #[serde(default)] + host_name: String, + #[serde(default, rename = "DNSName")] + dns_name: String, + #[serde(default, rename = "OS")] + os: String, + #[serde(default, rename = "TailscaleIPs")] + tailscale_ips: Option>, + #[serde(default)] + online: bool, + #[serde(default)] + last_seen: String, + #[serde(default)] + exit_node: bool, + #[serde(default)] + exit_node_option: bool, + #[serde(default, rename = "UserID")] + user_id: i64, +} + +impl RawPeer { + fn into_device(self) -> Device { + let ips = self.tailscale_ips.unwrap_or_default(); + let ipv4 = ips.iter().find(|ip| !ip.contains(':')).cloned().unwrap_or_default(); + let ipv6 = ips.iter().find(|ip| ip.contains(':')).cloned().unwrap_or_default(); + let ssh_host = self.dns_name.split('.').next().unwrap_or_default().to_string(); + // Phones often call themselves "localhost"; the MagicDNS name is more useful. + let name = + if self.host_name.is_empty() || self.host_name == "localhost" { ssh_host.clone() } else { self.host_name }; + let last_seen = (!self.last_seen.is_empty() && !self.last_seen.starts_with("0001-")).then_some(self.last_seen); + Device { + id: self.id, + name, + ssh_host, + ipv4, + ipv6, + os: self.os, + online: self.online, + last_seen, + exit_node: self.exit_node, + exit_node_option: self.exit_node_option, + } + } +} + +pub fn parse_status(json: &str) -> Result { + let raw: RawStatus = serde_json::from_str(json)?; + let account = raw + .this + .as_ref() + .and_then(|me| raw.user.as_ref()?.get(&me.user_id.to_string())) + .map(|u| u.login_name.clone()) + .unwrap_or_default(); + let mut peers: Vec = + raw.peer.unwrap_or_default().into_values().map(RawPeer::into_device).filter(|d| !d.ip().is_empty()).collect(); + peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(Status { + backend: Backend::from_str(&raw.backend_state), + auth_url: raw.auth_url, + tailnet: raw.current_tailnet.map(|t| t.name).unwrap_or_default(), + account, + version: raw.version.split('-').next().unwrap_or_default().to_string(), + this_device: raw.this.map(RawPeer::into_device), + peers, + health: raw.health.unwrap_or_default(), + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawPrefs { + #[serde(default)] + exit_node_allow_lan_access: bool, + #[serde(default, rename = "CorpDNS")] + corp_dns: bool, + #[serde(default)] + route_all: bool, + #[serde(default)] + shields_up: bool, + #[serde(default, rename = "RunSSH")] + run_ssh: bool, + #[serde(default)] + operator_user: Option, +} + +pub fn parse_prefs(json: &str) -> Result { + let raw: RawPrefs = serde_json::from_str(json)?; + Ok(Prefs { + allow_lan_access: raw.exit_node_allow_lan_access, + accept_dns: raw.corp_dns, + accept_routes: raw.route_all, + shields_up: raw.shields_up, + run_ssh: raw.run_ssh, + operator: raw.operator_user.unwrap_or_default(), + }) +} + +/// "Online", "Last seen 5 minutes ago", "Last seen 3 days ago"; `now` and `seen` in Unix seconds. +pub fn presence_text(online: bool, seen_unix: Option, now_unix: i64) -> String { + if online { + return "Online".into(); + } + let Some(seen) = seen_unix else { return "Offline".into() }; + let secs = (now_unix - seen).max(0); + let (n, unit) = match secs { + s if s < 60 => return "Last seen just now".into(), + s if s < 3600 => (s / 60, "minute"), + s if s < 86_400 => (s / 3600, "hour"), + s if s < 86_400 * 60 => (s / 86_400, "day"), + s if s < 86_400 * 730 => (s / (86_400 * 30), "month"), + s => (s / (86_400 * 365), "year"), + }; + format!("Last seen {n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const STATUS: &str = include_str!("../fixtures/status.json"); + + #[test] + fn parses_status_and_orders_devices() { + let s = parse_status(STATUS).unwrap(); + assert_eq!(s.backend, Backend::Running); + assert_eq!(s.tailnet, "example@gmail.com"); + assert_eq!(s.account, "example@gmail.com"); + assert_eq!(s.version, "1.104.4"); + let me = s.this_device.as_ref().unwrap(); + assert_eq!((me.name.as_str(), me.ip()), ("eos", "100.115.140.33")); + assert!(s.peers.iter().take(s.online_count()).all(|p| p.online)); + let phone = s.peers.iter().find(|p| p.ssh_host == "iphone-14-pro").unwrap(); + assert_eq!(phone.name, "iphone-14-pro"); + assert_eq!(phone.ip(), "100.96.99.44"); + assert_eq!(phone.last_seen, None); + let ser8 = s.peers.iter().find(|p| p.name == "sdolbec-SER8").unwrap(); + assert_eq!(ser8.ssh_command(), "ssh sdolbec-ser8"); + assert_eq!(s.exit_node_options().len(), 1); + assert_eq!(s.current_exit_node().map(|d| d.name.as_str()), Some("sdolbec-SER8")); + } + + #[test] + fn parses_stopped_and_logged_out() { + let s = parse_status(r#"{"BackendState":"Stopped","Self":null,"Peer":null}"#).unwrap(); + assert_eq!(s.backend, Backend::Stopped); + assert!(s.peers.is_empty()); + let s = parse_status(r#"{"BackendState":"NeedsLogin","AuthURL":"https://login.tailscale.com/a/x"}"#).unwrap(); + assert_eq!(s.backend, Backend::NeedsLogin); + assert_eq!(s.auth_url, "https://login.tailscale.com/a/x"); + } + + #[test] + fn parses_prefs() { + let p = parse_prefs(include_str!("../fixtures/prefs.json")).unwrap(); + assert!(p.accept_dns && !p.accept_routes && !p.shields_up && !p.run_ssh); + assert_eq!(p.operator, ""); + } + + #[test] + fn presence() { + assert_eq!(presence_text(true, None, 0), "Online"); + assert_eq!(presence_text(false, None, 0), "Offline"); + assert_eq!(presence_text(false, Some(0), 30), "Last seen just now"); + assert_eq!(presence_text(false, Some(0), 3600), "Last seen 1 hour ago"); + assert_eq!(presence_text(false, Some(0), 86_400 * 3), "Last seen 3 days ago"); + assert_eq!(presence_text(false, Some(0), 86_400 * 90), "Last seen 3 months ago"); + } +} diff --git a/rust/mseries-tailscale/src/strings.rs b/rust/mseries-tailscale/src/strings.rs new file mode 100644 index 0000000..1c28a62 --- /dev/null +++ b/rust/mseries-tailscale/src/strings.rs @@ -0,0 +1,7 @@ +//! App-owned gettext domain, named after the application id. A missing catalog falls back to the source text. + +pub const DOMAIN: &str = "org.techmore.mseries.Tailscale"; + +pub fn tr(text: &str) -> String { + mseries_pantheon_kit::i18n::tr(DOMAIN, text) +} diff --git a/rust/mseries-tailscale/src/ui/mod.rs b/rust/mseries-tailscale/src/ui/mod.rs new file mode 100644 index 0000000..14bbd0b --- /dev/null +++ b/rust/mseries-tailscale/src/ui/mod.rs @@ -0,0 +1,3 @@ +//! The Tailscale window: a status card with the connect switch, exit node, the tailnet's devices and settings. + +pub mod window; diff --git a/rust/mseries-tailscale/src/ui/window.rs b/rust/mseries-tailscale/src/ui/window.rs new file mode 100644 index 0000000..8782000 --- /dev/null +++ b/rust/mseries-tailscale/src/ui/window.rs @@ -0,0 +1,843 @@ +//! Main window. One column (utility window): status card, exit node, devices, settings. +//! +//! State comes from `tailscale status --json` and `tailscale debug prefs`, every 3 s while the window is mapped +//! and right after every change. Controls never hold their own state: each refresh writes the real state back, +//! so a refused change springs back and the reason shows in a banner or toast. + +use crate::cli::{self, CliError}; +use crate::model::{self, Backend, Device, Prefs, Status}; +use crate::strings::tr; +use granite::prelude::{HeaderLabelExt, PlaceholderExt}; +use gtk::{gio, glib, prelude::*}; +use mseries_pantheon_kit::{appearance, chrome, set_accessible_label}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +pub const APP_ID: &str = "org.techmore.mseries.Tailscale"; +const ADMIN_URL: &str = "https://login.tailscale.com/admin/machines"; + +pub struct AppWindow { + pub window: gtk::ApplicationWindow, + toast: granite::Toast, + pages: gtk::Stack, + placeholder: granite::Placeholder, + placeholder_command: RefCell, + copy_button: gtk::Button, + login_button: gtk::Button, + // Status card + status_icon: gtk::Image, + status_title: gtk::Label, + status_detail: gtk::Label, + connect: gtk::Switch, + this_name: gtk::Label, + this_ip: gtk::Label, + this_row: gtk::Box, + // One-time setup banner + setup: gtk::Revealer, + health: gtk::Label, + // Exit node + exit_section: gtk::Box, + exit_choice: gtk::DropDown, + exit_ids: RefCell>, + exit_note: gtk::Label, + lan_access: gtk::Switch, + // Devices + devices_header: granite::HeaderLabel, + search: gtk::SearchEntry, + device_list: gtk::ListBox, + devices_key: RefCell, + // Settings + accept_dns: gtk::Switch, + accept_routes: gtk::Switch, + allow_incoming: gtk::Switch, + run_ssh: gtk::Switch, + version: gtk::Label, + // State + status: RefCell>, + prefs: RefCell, + updating: Cell, + busy: Cell, + poll: RefCell>, +} + +impl AppWindow { + pub fn new(app: >k::Application) -> Rc { + let window = gtk::ApplicationWindow::builder().application(app).title(tr("Tailscale")).build(); + appearance::size_window(&window, appearance::UTILITY_DEFAULT, appearance::UTILITY_MIN); + window.add_css_class("mseries-tailscale"); + install_style(&window); + + // Header: refresh and the primary menu. + let header = gtk::HeaderBar::new(); + let refresh = gtk::Button::from_icon_name("view-refresh-symbolic"); + refresh.set_action_name(Some("win.refresh")); + refresh.set_tooltip_text(Some(&tr("Refresh (Ctrl+R)"))); + set_accessible_label(&refresh, &tr("Refresh")); + header.pack_start(&refresh); + header.pack_end(&chrome::primary_menu(&tr("About Tailscale"), &tr("Quit"))); + window.set_titlebar(Some(&header)); + + // ---- Status card ------------------------------------------------------------------------------------ + let status_icon = gtk::Image::from_icon_name("mseries-tailscale-offline-symbolic"); + status_icon.set_pixel_size(48); + status_icon.set_valign(gtk::Align::Center); + let status_title = gtk::Label::builder().xalign(0.0).label(tr("Checking…")).build(); + status_title.add_css_class("title-2"); + let status_detail = gtk::Label::builder().xalign(0.0).wrap(true).build(); + status_detail.add_css_class(granite::CssClassDIM); + let titles = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(2).hexpand(true).build(); + titles.set_valign(gtk::Align::Center); + titles.append(&status_title); + titles.append(&status_detail); + let connect = gtk::Switch::builder().valign(gtk::Align::Center).build(); + set_accessible_label(&connect, &tr("Connected")); + let top = gtk::Box::builder().spacing(12).build(); + top.append(&status_icon); + top.append(&titles); + top.append(&connect); + + let this_name = gtk::Label::builder().xalign(0.0).ellipsize(gtk::pango::EllipsizeMode::End).build(); + let this_ip = gtk::Label::builder().xalign(0.0).selectable(true).build(); + this_ip.add_css_class(granite::CssClassDIM); + this_ip.add_css_class(granite::CssClassNUMERIC); + let this_text = gtk::Box::builder().orientation(gtk::Orientation::Vertical).hexpand(true).build(); + let this_caption = gtk::Label::builder().xalign(0.0).label(tr("This device")).build(); + this_caption.add_css_class(granite::CssClassSMALL); + this_caption.add_css_class(granite::CssClassDIM); + this_text.append(&this_caption); + this_text.append(&this_name); + this_text.append(&this_ip); + let this_copy = icon_button("edit-copy-symbolic", &tr("Copy IP Address")); + let this_row = gtk::Box::builder().spacing(6).build(); + this_row.append(&this_text); + this_row.append(&this_copy); + + let card = card_box(); + card.set_spacing(12); + card.append(&top); + card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + card.append(&this_row); + + // One-time operator setup, shown after a change is refused. + let setup_text = gtk::Label::builder() + .xalign(0.0) + .wrap(true) + .label(tr( + "To connect, disconnect or change settings without a password prompt, run this once in Terminal:", + )) + .build(); + let setup_cmd = gtk::Label::builder() + .xalign(0.0) + .selectable(true) + .wrap(true) + .wrap_mode(gtk::pango::WrapMode::Char) + .label(cli::operator_command()) + .hexpand(true) + .build(); + setup_cmd.add_css_class(granite::CssClassMONOSPACE); + let setup_copy = gtk::Button::with_label(&tr("Copy")); + setup_copy.set_valign(gtk::Align::Center); + let setup_line = gtk::Box::builder().spacing(12).build(); + setup_line.append(&setup_cmd); + setup_line.append(&setup_copy); + let setup_box = card_box(); + setup_box.add_css_class("mseries-tailscale-setup"); + let setup_title = gtk::Label::builder().xalign(0.0).label(tr("One-time setup needed")).build(); + setup_title.add_css_class("heading"); + setup_box.append(&setup_title); + setup_box.append(&setup_text); + setup_box.append(&setup_line); + let setup = gtk::Revealer::builder().child(&setup_box).reveal_child(false).visible(false).build(); + setup.connect_child_revealed_notify(|r| r.set_visible(r.reveals_child() || r.is_child_revealed())); + + let health = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); + 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_choice = gtk::DropDown::from_strings(&[&tr("None")]); + exit_choice.set_valign(gtk::Align::Center); + set_accessible_label(&exit_choice, &tr("Exit node")); + let exit_note = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); + 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(&exit_note); + exit_card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + exit_card.append(&setting_row( + &tr("Allow local network access"), + Some(&tr("Reach printers and other devices at home while using an exit node")), + &lan_access, + )); + let exit_section = section(&exit_header, &exit_card); + + // ---- Devices ---------------------------------------------------------------------------------------- + let devices_header = granite::HeaderLabel::new(&tr("Devices")); + let search = gtk::SearchEntry::builder().placeholder_text(tr("Search devices")).build(); + let device_list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); + device_list.add_css_class("boxed-list"); + device_list.add_css_class(granite::CssClassCARD); + device_list.add_css_class("mseries-tailscale-devices"); + let devices_box = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + devices_box.append(&search); + devices_box.append(&device_list); + let devices_section = section(&devices_header, &devices_box); + + // ---- Settings --------------------------------------------------------------------------------------- + let accept_dns = gtk::Switch::builder().valign(gtk::Align::Center).build(); + let accept_routes = gtk::Switch::builder().valign(gtk::Align::Center).build(); + let allow_incoming = gtk::Switch::builder().valign(gtk::Align::Center).build(); + let run_ssh = gtk::Switch::builder().valign(gtk::Align::Center).build(); + let settings_card = card_box(); + settings_card.append(&setting_row( + &tr("Use Tailscale DNS"), + Some(&tr("Reach devices by name, like sdolbec-ser8")), + &accept_dns, + )); + settings_card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + settings_card.append(&setting_row( + &tr("Use subnet routes"), + Some(&tr("Reach networks that other devices share")), + &accept_routes, + )); + settings_card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + settings_card.append(&setting_row( + &tr("Allow incoming connections"), + Some(&tr("Other devices can connect to this one")), + &allow_incoming, + )); + settings_card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + settings_card.append(&setting_row( + &tr("Tailscale SSH server"), + Some(&tr("Let your tailnet SSH into this device")), + &run_ssh, + )); + let settings_section = section(&granite::HeaderLabel::new(&tr("Settings")), &settings_card); + + let admin = gtk::LinkButton::with_label(ADMIN_URL, &tr("Admin Console…")); + admin.set_halign(gtk::Align::Start); + let version = gtk::Label::builder().xalign(1.0).hexpand(true).build(); + version.add_css_class(granite::CssClassDIM); + version.add_css_class(granite::CssClassSMALL); + let footer = gtk::Box::builder().spacing(12).build(); + footer.append(&admin); + footer.append(&version); + + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + column.append(&card); + column.append(&setup); + column.append(&health); + column.append(&exit_section); + column.append(&devices_section); + column.append(&settings_section); + column.append(&footer); + let scroller = gtk::ScrolledWindow::builder().hscrollbar_policy(gtk::PolicyType::Never).child(&column).build(); + + // ---- Whole-window states: checking, not running, logged out ------------------------------------------ + let placeholder = granite::Placeholder::new(&tr("Checking Tailscale…")); + placeholder.set_icon(&gio::ThemedIcon::new("mseries-tailscale-offline-symbolic")); + let copy_button = placeholder.append_button( + &gio::ThemedIcon::new("edit-copy"), + &tr("Copy Command"), + &tr("Paste it in Terminal"), + ); + let login_button = placeholder.append_button( + &gio::ThemedIcon::new("system-users"), + &tr("Log In…"), + &tr("Opens the Tailscale sign-in page in your browser"), + ); + copy_button.set_visible(false); + login_button.set_visible(false); + let pages = gtk::Stack::builder().transition_type(gtk::StackTransitionType::Crossfade).build(); + pages.add_named(&placeholder, Some("placeholder")); + pages.add_named(&scroller, Some("main")); + pages.set_visible_child_name("placeholder"); + + let (overlay, toast) = chrome::toast_overlay(&pages); + window.set_child(Some(&overlay)); + + let this = Rc::new(Self { + window, + toast, + pages, + placeholder, + placeholder_command: RefCell::new(String::new()), + copy_button, + login_button, + status_icon, + status_title, + status_detail, + connect, + this_name, + this_ip, + this_row, + setup, + health, + exit_section, + exit_choice, + exit_ids: RefCell::new(Vec::new()), + exit_note, + lan_access, + devices_header, + search, + device_list, + devices_key: RefCell::new(String::new()), + accept_dns, + accept_routes, + allow_incoming, + run_ssh, + version, + status: RefCell::new(None), + prefs: RefCell::new(Prefs::default()), + updating: Cell::new(false), + busy: Cell::new(false), + poll: RefCell::new(None), + }); + + this.install_actions(); + this.connect_controls(&this_copy, &setup_copy); + + let weak = Rc::downgrade(&this); + let poll = mseries_pantheon_kit::Polling::new(Duration::from_secs(3), move || { + if let Some(this) = weak.upgrade() { + this.refresh(); + } + }); + poll.bind_to_widget_mapped(&this.window); + *this.poll.borrow_mut() = Some(poll); + this.refresh(); + this + } + + fn install_actions(self: &Rc) { + let refresh = gio::SimpleAction::new("refresh", None); + let weak = Rc::downgrade(self); + refresh.connect_activate(move |_, _| { + if let Some(this) = weak.upgrade() { + this.refresh(); + } + }); + self.window.add_action(&refresh); + let find = gio::SimpleAction::new("find", None); + let weak = Rc::downgrade(self); + find.connect_activate(move |_, _| { + if let Some(this) = weak.upgrade() { + this.search.grab_focus(); + } + }); + self.window.add_action(&find); + if let Some(app) = self.window.application() { + app.set_accels_for_action("win.refresh", &["r", "F5"]); + app.set_accels_for_action("win.find", &["f"]); + } + } + + fn connect_controls(self: &Rc, this_copy: >k::Button, setup_copy: >k::Button) { + let weak = Rc::downgrade(self); + this_copy.connect_clicked(move |_| { + let Some(this) = weak.upgrade() else { return }; + let ip = this.status.borrow().as_ref().and_then(|s| s.this_device.as_ref().map(|d| d.ip().to_string())); + if let Some(ip) = ip { + this.copy(&ip, &format!("{} {ip}", tr("Copied"))); + } + }); + let weak = Rc::downgrade(self); + setup_copy.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.copy(&cli::operator_command(), &tr("Copied. Paste it in Terminal, then try again.")); + } + }); + + let weak = Rc::downgrade(self); + self.copy_button.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + let command = this.placeholder_command.borrow().clone(); + this.copy(&command, &tr("Copied. Paste it in Terminal.")); + } + }); + let weak = Rc::downgrade(self); + self.login_button.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.log_in(); + } + }); + + let weak = Rc::downgrade(self); + self.connect.connect_state_set(move |_, on| { + let Some(this) = weak.upgrade() else { return glib::Propagation::Stop }; + if this.updating.get() { + return glib::Propagation::Proceed; + } + this.change( + if on { vec!["up"] } else { vec!["down"] }, + if on { tr("Connected") } else { tr("Disconnected") }, + ); + glib::Propagation::Stop + }); + + let weak = Rc::downgrade(self); + self.exit_choice.connect_selected_notify(move |choice| { + let Some(this) = weak.upgrade() else { return }; + if this.updating.get() { + return; + } + let index = choice.selected() as usize; + let ids = this.exit_ids.borrow().clone(); + let target = if index == 0 { String::new() } else { ids.get(index - 1).cloned().unwrap_or_default() }; + let done = if target.is_empty() { tr("Exit node turned off") } else { tr("Using exit node") }; + this.change(vec!["set", &format!("--exit-node={target}")], done); + }); + + self.bind_switch(&self.lan_access, "--exit-node-allow-lan-access", false); + self.bind_switch(&self.accept_dns, "--accept-dns", false); + self.bind_switch(&self.accept_routes, "--accept-routes", false); + self.bind_switch(&self.allow_incoming, "--shields-up", true); + self.bind_switch(&self.run_ssh, "--ssh", false); + + let weak = Rc::downgrade(self); + self.search.connect_search_changed(move |_| { + if let Some(this) = weak.upgrade() { + this.devices_key.borrow_mut().clear(); + this.show_devices(); + } + }); + } + + /// `tailscale set FLAG=BOOL` from a switch; `inverted` for flags whose meaning is the switch's opposite. + fn bind_switch(self: &Rc, switch: >k::Switch, flag: &'static str, inverted: bool) { + let weak = Rc::downgrade(self); + switch.connect_state_set(move |_, on| { + let Some(this) = weak.upgrade() else { return glib::Propagation::Stop }; + if this.updating.get() { + return glib::Propagation::Proceed; + } + let value = on != inverted; + this.change(vec!["set", &format!("{flag}={value}")], tr("Setting saved")); + glib::Propagation::Stop + }); + } + + /// Runs a changing command, then refreshes so every control shows the real state. + fn change(self: &Rc, args: Vec<&str>, done: String) { + if self.busy.replace(true) { + return; + } + let args: Vec = args.into_iter().map(String::from).collect(); + let this = self.clone(); + glib::spawn_future_local(async move { + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); + let result = cli::run(&argv).await; + this.busy.set(false); + match result { + Ok(_) => { + this.setup.set_reveal_child(false); + chrome::show_toast(&this.toast, &done); + } + Err(CliError::AccessDenied) => { + this.setup.set_visible(true); + this.setup.set_reveal_child(true); + chrome::show_toast(&this.toast, &CliError::AccessDenied.message()); + } + Err(e) => chrome::show_toast(&this.toast, &e.message()), + } + this.refresh_now().await; + }); + } + + pub fn refresh(self: &Rc) { + if self.busy.get() { + return; + } + let this = self.clone(); + glib::spawn_future_local(async move { this.refresh_now().await }); + } + + async fn refresh_now(self: &Rc) { + let status = cli::run(&["status", "--json"]).await; + 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)) => { + if let Some(prefs) = prefs { + *self.prefs.borrow_mut() = prefs; + } + self.apply(status); + } + Ok(Err(_)) => { + self.show_problem(&tr("Couldn't read Tailscale's status"), &tr("Try again in a moment."), None) + } + Err(CliError::NotInstalled) => self.show_problem( + &tr("Tailscale isn't installed"), + &tr("Install Tailscale, then open this window again."), + None, + ), + Err(_) => self.show_problem( + &tr("Tailscale isn't running"), + &tr("Start the Tailscale service, then this window connects by itself:"), + Some(cli::START_SERVICE_COMMAND), + ), + } + } + + fn show_problem(self: &Rc, title: &str, description: &str, command: Option<&str>) { + self.placeholder.set_title(title); + let text = match command { + Some(c) => format!("{description}\n\n{c}"), + None => description.to_string(), + }; + self.placeholder.set_description(&text); + self.placeholder.set_icon(&gio::ThemedIcon::new("mseries-tailscale-offline-symbolic")); + self.set_placeholder_button(command.map(|c| PlaceholderAction::Copy(c.to_string()))); + self.pages.set_visible_child_name("placeholder"); + } + + fn set_placeholder_button(&self, action: Option) { + self.copy_button.set_visible(matches!(action, Some(PlaceholderAction::Copy(_)))); + self.login_button.set_visible(matches!(action, Some(PlaceholderAction::LogIn))); + if let Some(PlaceholderAction::Copy(command)) = action { + *self.placeholder_command.borrow_mut() = command; + } + } + + fn log_in(self: &Rc) { + let auth = self.status.borrow().as_ref().map(|s| s.auth_url.clone()).unwrap_or_default(); + if !auth.is_empty() { + self.open(&auth); + return; + } + let this = self.clone(); + glib::spawn_future_local(async move { + match cli::login_url().await { + Ok(url) if !url.is_empty() => this.open(&url), + Ok(_) => this.refresh_now().await, + Err(CliError::AccessDenied) => { + this.copy( + &cli::operator_command(), + &tr("Copied the one-time setup command. Run it, then try again."), + ); + } + Err(e) => chrome::show_toast(&this.toast, &e.message()), + } + }); + } + + fn apply(self: &Rc, status: Status) { + match status.backend { + Backend::NeedsLogin | Backend::NeedsMachineAuth => { + let (title, text) = if status.backend == Backend::NeedsLogin { + (tr("Log in to Tailscale"), tr("Sign in with your Tailscale account to join your tailnet.")) + } else { + (tr("Waiting for approval"), tr("An admin of your tailnet needs to approve this device.")) + }; + self.placeholder.set_title(&title); + self.placeholder.set_description(&text); + self.placeholder.set_icon(&gio::ThemedIcon::new("mseries-tailscale-offline-symbolic")); + self.set_placeholder_button( + (status.backend == Backend::NeedsLogin).then_some(PlaceholderAction::LogIn), + ); + *self.status.borrow_mut() = Some(status); + self.pages.set_visible_child_name("placeholder"); + return; + } + _ => {} + } + self.pages.set_visible_child_name("main"); + self.updating.set(true); + let connected = status.backend == Backend::Running; + self.status_icon.set_icon_name(Some(if connected { + "mseries-tailscale-symbolic" + } else { + "mseries-tailscale-offline-symbolic" + })); + self.status_title.set_label(&match status.backend { + Backend::Running => tr("Connected"), + Backend::Starting => tr("Connecting…"), + _ => tr("Disconnected"), + }); + let mut detail = Vec::new(); + if !status.tailnet.is_empty() { + detail.push(status.tailnet.clone()); + } + if !status.account.is_empty() && status.account != status.tailnet { + detail.push(status.account.clone()); + } + if !connected { + detail.insert(0, tr("Turn on to reach your devices")); + } + self.status_detail.set_label(&detail.join(" · ")); + self.connect.set_active(connected || status.backend == Backend::Starting); + self.connect.set_state(connected); + + if let Some(me) = &status.this_device { + self.this_name.set_label(&me.name); + let mut ip = me.ip().to_string(); + if !me.ssh_host.is_empty() && !me.ssh_host.eq_ignore_ascii_case(&me.name) { + ip = format!("{ip} · {}", me.ssh_host); + } + self.this_ip.set_label(&ip); + } + self.this_row.set_visible(status.this_device.is_some()); + + self.health.set_visible(!status.health.is_empty()); + self.health.set_label(&status.health.join("\n")); + + // Exit node + let options = status.exit_node_options(); + let mut labels = vec![tr("None")]; + let mut ids = Vec::new(); + for d in &options { + labels.push(if d.online { d.name.clone() } else { format!("{} ({})", d.name, tr("offline")) }); + ids.push(d.ip().to_string()); + } + let current = status.current_exit_node().map(|d| d.ip().to_string()); + let selected = current.as_ref().and_then(|ip| ids.iter().position(|i| i == ip)).map(|i| i + 1).unwrap_or(0); + if *self.exit_ids.borrow() != ids { + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + self.exit_choice.set_model(Some(>k::StringList::new(&refs))); + *self.exit_ids.borrow_mut() = ids; + } + self.exit_choice.set_selected(selected as u32); + 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.", + )); + let prefs = self.prefs.borrow().clone(); + self.lan_access.set_active(prefs.allow_lan_access); + self.lan_access.set_state(prefs.allow_lan_access); + self.exit_section.set_visible(true); + + self.accept_dns.set_active(prefs.accept_dns); + self.accept_dns.set_state(prefs.accept_dns); + self.accept_routes.set_active(prefs.accept_routes); + self.accept_routes.set_state(prefs.accept_routes); + self.allow_incoming.set_active(!prefs.shields_up); + self.allow_incoming.set_state(!prefs.shields_up); + self.run_ssh.set_active(prefs.run_ssh); + self.run_ssh.set_state(prefs.run_ssh); + self.version.set_label(&if status.version.is_empty() { + String::new() + } else { + format!("Tailscale {}", status.version) + }); + self.updating.set(false); + + self.devices_header.set_secondary_text(Some(&format!( + "{} {} · {} {}", + status.online_count(), + tr("online"), + status.peers.len(), + tr("total") + ))); + *self.status.borrow_mut() = Some(status); + self.show_devices(); + } + + fn show_devices(self: &Rc) { + let status = self.status.borrow(); + let Some(status) = status.as_ref() else { return }; + let query = self.search.text().to_lowercase(); + let now = glib::DateTime::now_utc().map(|t| t.to_unix()).unwrap_or(0); + let shown: Vec<&Device> = status + .peers + .iter() + .filter(|d| { + query.is_empty() + || d.name.to_lowercase().contains(&query) + || d.ip().contains(&query) + || d.os_name().to_lowercase().contains(&query) + }) + .collect(); + // Rebuild only when something visible changed, so hover and focus survive the 3 s refresh. + let connected = status.backend == Backend::Running; + let key = format!( + "{connected}|{}", + shown + .iter() + .map(|d| format!( + "{}:{}:{}:{:?}:{}", + d.id, + d.online, + d.name, + d.last_seen.as_ref().map(|_| now / 60), + d.exit_node + )) + .collect::>() + .join(",") + ); + if *self.devices_key.borrow() == key { + return; + } + *self.devices_key.borrow_mut() = key; + while let Some(row) = self.device_list.first_child() { + self.device_list.remove(&row); + } + if shown.is_empty() { + let empty = gtk::Label::builder() + .label(if query.is_empty() { tr("No other devices yet") } else { tr("No matching devices") }) + .margin_top(18) + .margin_bottom(18) + .build(); + empty.add_css_class(granite::CssClassDIM); + let row = gtk::ListBoxRow::builder().child(&empty).activatable(false).build(); + self.device_list.append(&row); + return; + } + for device in shown { + self.device_list.append(&self.device_row(device, now, connected)); + } + } + + fn device_row(self: &Rc, device: &Device, now: i64, connected: bool) -> gtk::ListBoxRow { + let dot = gtk::Label::new(Some("●")); + dot.add_css_class(if device.online { granite::CssClassSUCCESS } else { granite::CssClassDIM }); + dot.set_tooltip_text(Some(&if device.online { tr("Online") } else { tr("Offline") })); + dot.set_valign(gtk::Align::Center); + dot.add_css_class("mseries-tailscale-dot"); + + let name = + gtk::Label::builder().label(&device.name).xalign(0.0).ellipsize(gtk::pango::EllipsizeMode::End).build(); + name.add_css_class("heading"); + let seen = + device.last_seen.as_deref().and_then(|s| glib::DateTime::from_iso8601(s, None).ok()).map(|t| t.to_unix()); + let detail = gtk::Label::builder() + .label(format!( + "{} · {} · {}", + device.ip(), + device.os_name(), + tr(&model::presence_text(device.online, seen, now)) + )) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + detail.add_css_class(granite::CssClassDIM); + detail.add_css_class(granite::CssClassSMALL); + let text = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(2).hexpand(true).build(); + let title_line = gtk::Box::builder().spacing(6).build(); + title_line.append(&name); + if device.exit_node { + let badge = gtk::Label::new(Some(&tr("Exit node"))); + badge.add_css_class("mseries-tailscale-badge"); + badge.set_valign(gtk::Align::Center); + title_line.append(&badge); + } + text.append(&title_line); + text.append(&detail); + + let copy_ip = icon_button("edit-copy-symbolic", &format!("{} {}", tr("Copy IP Address"), device.ip())); + let ssh = device.ssh_command(); + let copy_ssh = icon_button("utilities-terminal-symbolic", &format!("{} “{ssh}”", tr("Copy"))); + let (ip, ip_done) = (device.ip().to_string(), format!("{} {}", tr("Copied"), device.ip())); + let weak = Rc::downgrade(self); + copy_ip.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.copy(&ip, &ip_done); + } + }); + let ssh_done = format!("{} “{ssh}”", tr("Copied")); + let weak = Rc::downgrade(self); + copy_ssh.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.copy(&ssh, &ssh_done); + } + }); + + let line = + gtk::Box::builder().spacing(12).margin_top(9).margin_bottom(9).margin_start(12).margin_end(6).build(); + line.append(&dot); + line.append(&text); + line.append(©_ip); + line.append(©_ssh); + if !device.online || !connected { + name.add_css_class(granite::CssClassDIM); + } + + // Clicking the row copies the IP; the context menu has both. + let row = gtk::ListBoxRow::builder().child(&line).activatable(true).build(); + row.set_tooltip_text(Some(&format!("{} {}", tr("Click to copy"), device.ip()))); + let (ip, ip_done) = (device.ip().to_string(), format!("{} {}", tr("Copied"), device.ip())); + let weak = Rc::downgrade(self); + row.connect_activate(move |_| { + if let Some(this) = weak.upgrade() { + this.copy(&ip, &ip_done); + } + }); + row + } + + fn copy(&self, text: &str, confirmation: &str) { + self.window.clipboard().set_text(text); + chrome::show_toast(&self.toast, confirmation); + } + + fn open(&self, uri: &str) { + gtk::UriLauncher::new(uri).launch(Some(&self.window), gio::Cancellable::NONE, |_| {}); + } +} + +enum PlaceholderAction { + Copy(String), + LogIn, +} + +fn install_style(window: >k::ApplicationWindow) { + let provider = gtk::CssProvider::new(); + provider.load_from_string(include_str!("../../data/style.css")); + gtk::style_context_add_provider_for_display( + >k::prelude::WidgetExt::display(window), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn card_box() -> gtk::Box { + let b = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + b.add_css_class(granite::CssClassCARD); + b.add_css_class("mseries-tailscale-card"); + b +} + +fn section(header: &impl IsA, body: &impl IsA) -> gtk::Box { + let b = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(6).build(); + b.append(header); + b.append(body); + b +} + +fn setting_row(title: &str, description: Option<&str>, control: &impl IsA) -> gtk::Box { + let label = gtk::Label::builder().label(title).xalign(0.0).wrap(true).build(); + let text = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(2).hexpand(true).build(); + text.set_valign(gtk::Align::Center); + text.append(&label); + if let Some(d) = description { + let sub = gtk::Label::builder().label(d).xalign(0.0).wrap(true).build(); + sub.add_css_class(granite::CssClassDIM); + sub.add_css_class(granite::CssClassSMALL); + text.append(&sub); + } + let row = gtk::Box::builder().spacing(12).build(); + row.append(&text); + row.append(control); + if let Some(sw) = control.dynamic_cast_ref::() { + set_accessible_label(sw, title); + } + row +} + +fn icon_button(icon: &str, label: &str) -> gtk::Button { + let b = gtk::Button::from_icon_name(icon); + b.add_css_class("flat"); + b.set_valign(gtk::Align::Center); + b.set_tooltip_text(Some(label)); + set_accessible_label(&b, label); + b +}