diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a75ef3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main, production] + pull_request: + branches: [main, production] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Check JavaScript syntax + run: | + node --check extension.js + node --check prefs.js + + - name: Validate metadata.json + run: python3 -c "import json; json.load(open('metadata.json'))" + + - name: Compile GSettings schema + run: | + sudo apt-get update -y + sudo apt-get install -y libglib2.0-bin + glib-compile-schemas schemas/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24552bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +TODO.md +WYJASNIENIE.md diff --git a/README.md b/README.md index 5d4e1db..d5a4b33 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ![GJS](https://img.shields.io/badge/GJS-ESM-f6d32d) ![Status](https://img.shields.io/badge/status-unofficial%20%2F%20community-orange) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue) +[![CI](https://github.com/urhend/twingate-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/urhend/twingate-panel/actions/workflows/ci.yml) [Repository](https://github.com/urhend/twingate-panel) @@ -52,9 +53,12 @@ dropdown menu that does it all. | | | |---|---| -| 🟒 **Live status dot** | Grey (disconnected), yellow (connecting), green (connected) β€” always visible in the dropdown header. | +| 🟒 **Live status dot** | Grey (disconnected), yellow (connecting), green (connected) β€” always visible in the dropdown header, next to the network name. | +| 🌐 **Network name** | Shows your Twingate network name (e.g. `network-name`), bold when connected, read from `twingate account list`. | | πŸ”Œ **One-click connect / disconnect** | A toggle switch drives `twingate start` / `twingate stop` for you. | | πŸ“‘ **Resource list with reachability** | Every authorized resource is listed as `name Β· address`, each with its own dot that turns green or red based on a live ping. | +| πŸ” **Resource search** | A magnifier button next to "Resources" reveals a compact filter box (name + address); auto-collapses when the dropdown closes. | +| πŸ”” **Connection notifications** | Native GNOME notifications on real connect/disconnect/error transitions, not on every poll β€” toggleable, and automatically silenced by GNOME's own Do Not Disturb setting. | | πŸ–ΌοΈ **Static, theme-independent icon** | The panel icon doesn't change color or shift with light/dark shell themes β€” it's always your Twingate mark. | | βš™οΈ **In-menu preferences** | A settings icon in the dropdown header opens a native GTK4/libadwaita preferences window β€” no more editing source files. | | ⚑ **Fully asynchronous** | Every external command (`twingate`, `pkexec`, `ping`) runs via non-blocking `Gio.Subprocess` β€” the shell never freezes while it works. | @@ -67,22 +71,29 @@ dropdown menu that does it all. β”‚ [Twingate icon] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ click β†’ dropdown: - β”‚ [Twingate wordmark] ● status dot βš™ (opens Preferences) + β”‚ network-name ● βš™ (opens Preferences) β”‚ ──────────────────────── β”‚ Connected [ toggle ] β”‚ ──────────────────────── - β”‚ Resources + β”‚ Resources πŸ” (toggles search) β”‚ server-a Β· 192.168.0.10 ● β”‚ server-b Β· 192.168.0.20 ● β–Ό - Poll loop (every 5s while enabled) - β”‚ - β–Ό - twingate -d status β†’ updates status dot - twingate -d resources β†’ updates resource list (only while connected) - ping -c 1 -W 1 β†’ updates each resource's reachability dot + twingate account list β†’ network name (fetched once at startup) + twingate -d status β†’ status dot + bold network name when connected; + sends a notification on real connect/disconnect/ + error transitions (never on repeated polls) + twingate -d resources β†’ resource list (only while connected) + ping -c 1 -W 1 β†’ reachability dot per resource, on its own + independent interval β€” can be disabled entirely ``` +Status polling uses the configured interval while the dropdown is open, and +backs off to 6Γ— slower while it's closed β€” reopening the menu triggers an +immediate refresh. Typing in the resource search box filters by name and +address without re-fetching or re-pinging anything; closing the dropdown +clears the search automatically. + Toggling the switch runs `pkexec twingate start` or `pkexec twingate stop`, which shows GNOME's native graphical polkit prompt for your password β€” the extension itself never sees or handles your credentials. @@ -121,8 +132,8 @@ Then log out and back in (or reload GNOME Shell on X11 with Alt+ ## Configuration -Click the βš™ icon in the top-right of the dropdown (opposite the Twingate -logo) to open the preferences window β€” or run: +Click the βš™ icon in the top-right of the dropdown header (next to the +network name) to open the preferences window β€” or run: ```bash gnome-extensions prefs twingate-panel@urhend @@ -131,8 +142,9 @@ gnome-extensions prefs twingate-panel@urhend | Setting | Default | Description | |---|---|---| | Poll interval | `5` seconds | How often status and resources are refreshed. Takes effect immediately, no reload needed. | -| Use pkexec | `on` | Show a graphical polkit password prompt for connect/disconnect. Turn off only if you've configured a scoped `NOPASSWD` sudoers rule for the exact `start`/`stop` commands. | -| Twingate binary path | `/usr/bin/twingate` | Path to the Twingate CLI binary. | +| Enable notifications | `on` | Send a native notification on real connect/disconnect/error transitions. Already respects GNOME's own Do Not Disturb setting regardless of this toggle. | +| Enable pings | `on` | Ping each resource periodically to show a reachability dot. Turn off to skip pinging entirely. | +| Ping interval | `5` seconds | How often resource reachability is re-checked, independent of the status poll interval. | Settings are stored via GSettings (schema `org.gnome.shell.extensions.twingate-panel`), so they persist across @@ -157,6 +169,7 @@ journalctl -f -o cat /usr/bin/gnome-shell ``` twingate-panel/ +β”œβ”€β”€ .github/workflows/ci.yml # CI: JS syntax, metadata.json, schema compile β”œβ”€β”€ metadata.json # Extension manifest (uuid, name, shell-version…) β”œβ”€β”€ extension.js # All logic: indicator, menu, polling, subprocesses β”œβ”€β”€ prefs.js # GTK4/libadwaita preferences window @@ -166,7 +179,8 @@ twingate-panel/ β”‚ └── gschemas.compiled # Compiled schema (required at runtime) β”œβ”€β”€ icons/ β”‚ β”œβ”€β”€ twingate-panel.png # Static top-bar icon -β”‚ └── twingate-wordmark.png# Logo shown in the dropdown header +β”‚ └── twingate-wordmark.png# Unused for now (dropped in favor of the +β”‚ # network name); kept for a possible future revision β”œβ”€β”€ screenshots/ β”‚ └── menu.png # Dropdown menu preview (used in this README) β”œβ”€β”€ LICENSE # GPL-3.0-or-later @@ -175,8 +189,13 @@ twingate-panel/ ## Known limitations -- `start`/`stop` always prompt for a password via `pkexec` unless you set up - a passwordless `sudoers` rule yourself (see [Configuration](#configuration)). +- `start`/`stop` always prompt for a password via `pkexec` β€” there is no + passwordless option. This is intentional: `sudo` is disallowed entirely + for privileged subprocesses under + [GNOME's extension review guidelines](https://gjs.guide/extensions/review-guidelines/review-guidelines.html#privileged-subprocess-must-not-be-user-writable). +- The Twingate binary path (`/usr/bin/twingate`) is hardcoded, not + configurable β€” for the same reason as above, and because every official + Twingate installer puts it there anyway. - Resource reachability is a simple one-shot ICMP ping; it doesn't reflect Twingate's own internal routing/health checks. diff --git a/extension.js b/extension.js index 4b6bdb5..69612a5 100644 --- a/extension.js +++ b/extension.js @@ -5,391 +5,690 @@ // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. See the LICENSE file for details. +// any later version. See the LICENSE file for details. -import GObject from 'gi://GObject'; -import St from 'gi://St'; -import Gio from 'gi://Gio'; -import GLib from 'gi://GLib'; -import Clutter from 'gi://Clutter'; +import GObject from "gi://GObject"; +import St from "gi://St"; +import Gio from "gi://Gio"; +import GLib from "gi://GLib"; +import Clutter from "gi://Clutter"; -import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; -import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; +import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; -Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async'); +Gio._promisify(Gio.Subprocess.prototype, "communicate_utf8_async"); -// ---- Configuration --------------------------------------------------------- -// Runtime values (poll interval, pkexec vs sudo, binary path) live in -// GSettings β€” see schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml -// and prefs.js. There are no hardcoded defaults here anymore. - -// ---- Status β†’ UI mapping -------------------------------------------------- -// The panel icon is static; only the status dot in the popup header changes -// color, via one of three style classes (see stylesheet.css). +const TWINGATE_BIN = "/usr/bin/twingate"; const STATUS_INFO = { - 'not-running': {label: 'Not running', connected: false, dotClass: 'twingate-dot-offline'}, - offline: {label: 'Offline', connected: false, dotClass: 'twingate-dot-offline'}, - online: {label: 'Online', connected: true, dotClass: 'twingate-dot-online'}, - connecting: {label: 'Connecting…', connected: false, dotClass: 'twingate-dot-connecting'}, - authenticating: {label: 'Authenticating…', connected: false, dotClass: 'twingate-dot-connecting'}, - unknown: {label: 'Unknown', connected: false, dotClass: 'twingate-dot-offline'}, + "not-running": { label: "Not running", connected: false, dotColor: "#9a9996" }, + offline: { label: "Offline", connected: false, dotColor: "#9a9996" }, + online: { label: "Online", connected: true, dotColor: "#33d17a" }, + connecting: { label: "Connecting…", connected: false, dotColor: "#f5c211" }, + authenticating: { + label: "Authenticating…", + connected: false, + dotColor: "#f5c211", + }, + unknown: { label: "Unknown", connected: false, dotColor: "#9a9996" }, +}; + +const NOTIFY_CATEGORY = { + online: "connected", + "not-running": "disconnected", + offline: "disconnected", + unknown: "error", }; -const DOT_CLASSES = ['twingate-dot-offline', 'twingate-dot-connecting', 'twingate-dot-online']; -const PING_CLASSES = ['twingate-dot-pending', 'twingate-dot-reachable', 'twingate-dot-unreachable']; -const DOT_SIZE = 10; - -/** - * A plain St.Widget with no content has a natural size of 0x0, so CSS - * width/height alone can be unreliable inside a BoxLayout (it may stretch - * to fill leftover space instead of staying a fixed-size circle). Forcing - * the actor size in JS, and disabling expand, guarantees a true circle - * regardless of theme/layout quirks. - */ +const PING_CLASSES = [ + "twingate-dot-pending", + "twingate-dot-reachable", + "twingate-dot-unreachable", +]; +const DOT_SIZE = 7; +const BACKGROUND_POLL_MULTIPLIER = 6; + function createDot(extraClass) { - const dot = new St.Widget({ - style_class: `twingate-status-dot ${extraClass}`, - x_expand: false, - y_expand: false, - y_align: Clutter.ActorAlign.CENTER, - }); - dot.set_size(DOT_SIZE, DOT_SIZE); - return dot; + const dot = new St.Widget({ + style_class: `twingate-status-dot ${extraClass}`, + x_expand: false, + y_expand: false, + y_align: Clutter.ActorAlign.CENTER, + }); + dot.set_size(DOT_SIZE, DOT_SIZE); + return dot; } function statusInfoFor(token) { - return STATUS_INFO[token] ?? STATUS_INFO.unknown; + return STATUS_INFO[token] ?? STATUS_INFO.unknown; } -/** - * Run an external command asynchronously (never blocks the shell). - * Returns {success, stdout, stderr}. Rethrows on cancellation so callers - * can stop scheduling further work. - */ async function runCommand(argv, cancellable) { - let proc; - try { - proc = new Gio.Subprocess({ - argv, - flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, - }); - proc.init(cancellable); - } catch (e) { - return {success: false, stdout: '', stderr: e.message ?? String(e)}; - } - - try { - const [stdout, stderr] = await proc.communicate_utf8_async(null, cancellable); - return { - success: proc.get_successful(), - stdout: stdout ?? '', - stderr: stderr ?? '', - }; - } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) - throw e; - return {success: false, stdout: '', stderr: e.message ?? String(e)}; - } + let proc; + try { + proc = new Gio.Subprocess({ + argv, + flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + }); + proc.init(cancellable); + } catch (e) { + if ( + e instanceof GLib.Error && + e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) + ) + throw e; + return { success: false, stdout: "", stderr: e.message ?? String(e) }; + } + + try { + const [stdout, stderr] = await proc.communicate_utf8_async( + null, + cancellable, + ); + return { + success: proc.get_successful(), + stdout: stdout ?? "", + stderr: stderr ?? "", + }; + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; + return { success: false, stdout: "", stderr: e.message ?? String(e) }; + } } function parseStatusToken(stdout) { - const firstLine = (stdout ?? '').split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''; - // Be defensive: lowercase, take the first "word-ish" token in case the - // CLI prepends extra text in some version. - const token = firstLine.toLowerCase().split(/\s+/)[0] ?? ''; - return token in STATUS_INFO ? token : 'unknown'; + const firstLine = + (stdout ?? "") + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? ""; + const token = firstLine.toLowerCase().split(/\s+/)[0] ?? ""; + return token in STATUS_INFO ? token : "unknown"; } -/** Returns true if a single ICMP echo request got a reply within 1s. */ async function pingAddress(address, cancellable) { - try { - const result = await runCommand(['ping', '-c', '1', '-W', '1', address], cancellable); - return result.success; - } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) - throw e; - return false; - } + try { + const result = await runCommand( + ["ping", "-c", "1", "-W", "1", address], + cancellable, + ); + return result.success; + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; + return false; + } } -/** - * `twingate resources` prints a tab-separated table: - * RESOURCE NAME ADDRESS ALIAS AUTH STATUS - * Home Assistant192.168.0.211- - * We only want name + address; the header row and other columns are dropped. - * When disconnected, the CLI prints an explanatory sentence instead of a - * table β€” that yields no rows here, which is the desired behavior. - */ +function parseNetworkName(stdout) { + const lines = (stdout ?? "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + for (const line of lines) { + const fields = line.split("\t").map((f) => f.trim()); + if (fields.length < 2 || /^email$/i.test(fields[0])) continue; + return fields[1]; + } + return null; +} + +const VALID_ADDRESS_RE = /^[A-Za-z0-9][A-Za-z0-9.:_-]*$/; + function parseResources(stdout) { - const lines = (stdout ?? '').split('\n').map(l => l.trim()).filter(l => l.length > 0); - const rows = []; - for (const line of lines) { - const fields = line.split('\t').map(f => f.trim()); - if (fields.length < 2 || /^resource name$/i.test(fields[0])) - continue; - rows.push({name: fields[0], address: fields[1]}); - } - return rows; + const lines = (stdout ?? "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const rows = []; + for (const line of lines) { + const fields = line.split("\t").map((f) => f.trim()); + if (fields.length < 2 || /^resource name$/i.test(fields[0])) continue; + const address = fields[1]; + if (!VALID_ADDRESS_RE.test(address)) continue; + rows.push({ name: fields[0], address }); + } + return rows; } const Indicator = GObject.registerClass( -class Indicator extends PanelMenu.Button { + class Indicator extends PanelMenu.Button { _init(extensionPath, settings, onOpenPreferences) { - super._init(0.0, 'Twingate Panel', false); - - this._extensionPath = extensionPath; - this._settings = settings; - this._onOpenPreferences = onOpenPreferences; - this._cancellable = new Gio.Cancellable(); - this._timeoutId = null; - this._pendingRefreshIds = new Set(); - this._suppressToggle = false; - this._busy = false; - this._resourceGeneration = 0; + super._init(0.0, "Twingate Panel", false); + + this._extensionPath = extensionPath; + this._settings = settings; + this._onOpenPreferences = onOpenPreferences; + this._cancellable = new Gio.Cancellable(); + this._timeoutId = null; + this._pingTimeoutId = null; + this._pendingRefreshIds = new Set(); + this._suppressToggle = false; + this._busy = false; + this._resourceGeneration = 0; + this._resourceDots = new Map(); + this._allResources = []; + this._resourceFilter = ""; + this._searchToggledOn = false; + this._pingStatus = new Map(); + this._previousCategory = null; + this._connected = false; + this._destroyed = false; + + const iconPath = GLib.build_filenamev([ + extensionPath, + "icons", + "twingate-panel.png", + ]); + this._icon = new St.Icon({ + gicon: Gio.icon_new_for_string(iconPath), + icon_size: 16, + }); + this.add_child(this._icon); + + this._buildMenu(); + this._setState("unknown"); + + this._refresh(); + this._startPolling(); + this._startPingPolling(); + this._loadNetworkName(); + + this._menuOpenStateId = this.menu.connect( + "open-state-changed", + (_menu, isOpen) => { + if (isOpen) this._refresh(); + else this._setSearchVisible(false); + this._startPolling(); + }, + ); + this._settingsChangedId = this._settings.connect( + "changed::poll-interval-seconds", + () => { + this._startPolling(); + }, + ); + this._pingSettingsChangedId = this._settings.connect( + "changed::ping-enabled", + () => { + this._startPingPolling(); + this._refreshResources(); + }, + ); + this._pingIntervalChangedId = this._settings.connect( + "changed::ping-interval-seconds", + () => { + this._startPingPolling(); + }, + ); + } - const iconPath = GLib.build_filenamev([extensionPath, 'icons', 'twingate-panel.png']); - this._icon = new St.Icon({ - gicon: Gio.icon_new_for_string(iconPath), - icon_size: 16, - }); - this.add_child(this._icon); + async _loadNetworkName() { + let result; + try { + result = await runCommand( + [TWINGATE_BIN, "-d", "account", "list"], + this._cancellable, + ); + } catch (e) { + return; + } + + if (this._destroyed) return; + + const name = parseNetworkName(result.stdout); + if (name) this._networkNameLabel.text = name; + } - this._buildMenu(); - this._setState('unknown'); + _startPolling() { + if (this._timeoutId) { + GLib.Source.remove(this._timeoutId); + this._timeoutId = null; + } + const base = this._settings.get_int("poll-interval-seconds"); + const seconds = this.menu.isOpen ? base : base * BACKGROUND_POLL_MULTIPLIER; + this._timeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + seconds, + () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }, + ); + } - this._refresh(); - this._startPolling(); - this._settingsChangedId = this._settings.connect('changed::poll-interval-seconds', () => { - this._startPolling(); - }); + _startPingPolling() { + if (this._pingTimeoutId) { + GLib.Source.remove(this._pingTimeoutId); + this._pingTimeoutId = null; + } + if (!this._settings.get_boolean("ping-enabled")) return; + + const seconds = this._settings.get_int("ping-interval-seconds"); + this._pingTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + seconds, + () => { + this._pingAllResources(this._resourceGeneration); + return GLib.SOURCE_CONTINUE; + }, + ); } - _startPolling() { - if (this._timeoutId) { - GLib.Source.remove(this._timeoutId); - this._timeoutId = null; - } - const seconds = this._settings.get_int('poll-interval-seconds'); - this._timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => { - this._refresh(); - return GLib.SOURCE_CONTINUE; - }); + _pingAllResources(generation) { + for (const { address } of this._allResources) { + if (generation !== this._resourceGeneration) return; + pingAddress(address, this._cancellable) + .then((reachable) => { + if (this._destroyed) return; + if (generation !== this._resourceGeneration) return; + this._pingStatus.set(address, reachable); + const dot = this._resourceDots.get(address); + if (dot) { + for (const cls of PING_CLASSES) dot.remove_style_class_name(cls); + dot.add_style_class_name( + reachable + ? "twingate-dot-reachable" + : "twingate-dot-unreachable", + ); + } + }) + .catch(() => {}); + } } _buildMenu() { - const headerItem = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false}); - const headerBox = new St.BoxLayout({style_class: 'twingate-header-box', x_expand: true}); + const headerItem = new PopupMenu.PopupBaseMenuItem({ + reactive: false, + can_focus: false, + }); + const headerBox = new St.BoxLayout({ + style_class: "twingate-header-box", + x_expand: true, + }); + + this._networkNameLabel = new St.Label({ + style_class: "twingate-network-title", + text: "Twingate", + y_align: Clutter.ActorAlign.CENTER, + }); + headerBox.add_child(this._networkNameLabel); + + this._statusDot = createDot(""); + headerBox.add_child(this._statusDot); + + const spacer = new St.Widget({ x_expand: true }); + headerBox.add_child(spacer); + + const settingsButton = new St.Button({ + style_class: "twingate-settings-button", + y_align: Clutter.ActorAlign.START, + child: new St.Icon({ + icon_name: "preferences-system-symbolic", + style_class: "popup-menu-icon twingate-header-icon", + }), + }); + settingsButton.connect("clicked", () => { + this.menu.close(); + this._onOpenPreferences(); + }); + headerBox.add_child(settingsButton); + + headerItem.add_child(headerBox); + this.menu.addMenuItem(headerItem); + + this._switchItem = new PopupMenu.PopupSwitchMenuItem("Connected", false); + this._switchItem.connect("toggled", (_item, state) => { + if (this._suppressToggle) return; + this._onToggle(state); + }); + this.menu.addMenuItem(this._switchItem); + + this._resourcesSeparator = new PopupMenu.PopupSeparatorMenuItem(); + this.menu.addMenuItem(this._resourcesSeparator); + + this._resourcesHeader = new PopupMenu.PopupBaseMenuItem({ + reactive: true, + can_focus: false, + activate: false, + }); + const resourcesHeaderBox = new St.BoxLayout({ x_expand: true }); + + const resourcesLabel = new St.Label({ + style_class: "twingate-resources-header", + text: "Resources", + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + resourcesHeaderBox.add_child(resourcesLabel); + + const searchToggleButton = new St.Button({ + style_class: "twingate-settings-button", + child: new St.Icon({ + icon_name: "edit-find-symbolic", + style_class: "popup-menu-icon twingate-header-icon", + }), + }); + searchToggleButton.connect("clicked", () => { + this._setSearchVisible(!this._resourceSearchItem.visible); + }); + resourcesHeaderBox.add_child(searchToggleButton); + + this._resourcesHeader.add_child(resourcesHeaderBox); + this._resourcesHeader.visible = false; + this.menu.addMenuItem(this._resourcesHeader); + + const searchItem = new PopupMenu.PopupBaseMenuItem({ + reactive: true, + can_focus: false, + activate: false, + }); + this._searchEntry = new St.Entry({ + style_class: "twingate-search-entry", + hint_text: "Search resources…", + x_expand: true, + can_focus: true, + }); + this._searchEntry.clutter_text.connect("text-changed", () => { + this._resourceFilter = this._searchEntry + .get_text() + .trim() + .toLowerCase(); + this._renderResourceItems(); + }); + searchItem.add_child(this._searchEntry); + searchItem.visible = false; + this.menu.addMenuItem(searchItem); + this._resourceSearchItem = searchItem; + + this._resourcesSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._resourcesSection); + } - const wordmarkPath = GLib.build_filenamev([this._extensionPath, 'icons', 'twingate-wordmark.png']); - const logo = new St.Widget({ - style_class: 'twingate-header-logo', - style: `background-image: url("file://${wordmarkPath}");`, - }); - headerBox.add_child(logo); + _setState(token) { + const info = statusInfoFor(token); + this._connected = info.connected; + + this._statusDot.set_style(`background-color: ${info.dotColor};`); + + if (info.connected) + this._networkNameLabel.add_style_class_name( + "twingate-network-title-connected", + ); + else + this._networkNameLabel.remove_style_class_name( + "twingate-network-title-connected", + ); + + this._suppressToggle = true; + this._switchItem.setToggleState(info.connected); + this._suppressToggle = false; + } - this._statusDot = createDot('twingate-dot-offline'); - headerBox.add_child(this._statusDot); + _setResources(resources) { + this._allResources = resources; + if (resources.length === 0) this._pingStatus.clear(); + this._renderResourceItems(); + + const pingEnabled = this._settings.get_boolean("ping-enabled"); + if (pingEnabled && resources.length > 0) + this._pingAllResources(this._resourceGeneration); + } - const spacer = new St.Widget({x_expand: true}); - headerBox.add_child(spacer); + _setSearchVisible(visible) { + this._searchToggledOn = visible; + if (!visible) this._searchEntry.set_text(""); + this._resourceSearchItem.visible = + visible && this._allResources.length > 0; + if (visible) this._searchEntry.grab_key_focus(); + } - const settingsButton = new St.Button({ - style_class: 'twingate-settings-button', - child: new St.Icon({ - icon_name: 'preferences-system-symbolic', - style_class: 'popup-menu-icon', - }), + _renderResourceItems() { + this._resourcesSection.removeAll(); + + const hasResources = this._allResources.length > 0; + const showEmptyPlaceholder = this._connected && !hasResources; + this._resourcesHeader.visible = hasResources || showEmptyPlaceholder; + this._resourceSearchItem.visible = hasResources && this._searchToggledOn; + + this._resourceGeneration++; + this._resourceDots.clear(); + + const pingEnabled = this._settings.get_boolean("ping-enabled"); + const filter = this._resourceFilter; + const filtered = filter + ? this._allResources.filter(({ name, address }) => + `${name} ${address}`.toLowerCase().includes(filter), + ) + : this._allResources; + + if (showEmptyPlaceholder) { + const emptyItem = new PopupMenu.PopupBaseMenuItem({ + reactive: false, + can_focus: false, }); - settingsButton.connect('clicked', () => { - this.menu.close(); - this._onOpenPreferences?.(); + emptyItem.add_child( + new St.Label({ + style_class: "twingate-resource-empty", + text: "No resources available", + x_expand: true, + }), + ); + this._resourcesSection.addMenuItem(emptyItem); + } + + for (const { name, address } of filtered) { + const item = new PopupMenu.PopupBaseMenuItem({ + reactive: false, + can_focus: false, }); - headerBox.add_child(settingsButton); - - headerItem.add_child(headerBox); - this.menu.addMenuItem(headerItem); - - this._switchItem = new PopupMenu.PopupSwitchMenuItem('Connected', false); - this._switchItem.connect('toggled', (_item, state) => { - if (this._suppressToggle) - return; - this._onToggle(state); + const box = new St.BoxLayout({ + style_class: "twingate-resource-box", + x_expand: true, }); - this.menu.addMenuItem(this._switchItem); - this._resourcesSeparator = new PopupMenu.PopupSeparatorMenuItem(); - this.menu.addMenuItem(this._resourcesSeparator); - - this._resourcesHeader = new PopupMenu.PopupMenuItem('Resources', { - reactive: false, - can_focus: false, + const label = new St.Label({ + style_class: "twingate-resource-label", + text: `${name} Β· ${address}`, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, }); - this._resourcesHeader.label.add_style_class_name('twingate-resources-header'); - this._resourcesHeader.visible = false; - this.menu.addMenuItem(this._resourcesHeader); - - this._resourcesSection = new PopupMenu.PopupMenuSection(); - this.menu.addMenuItem(this._resourcesSection); - } - - _setState(token) { - const info = statusInfoFor(token); - - for (const cls of DOT_CLASSES) - this._statusDot.remove_style_class_name(cls); - this._statusDot.add_style_class_name(info.dotClass); - - this._suppressToggle = true; - this._switchItem.setToggleState(info.connected); - this._suppressToggle = false; + box.add_child(label); + + const status = this._pingStatus.get(address); + const pingClass = + status === true + ? "twingate-dot-reachable" + : status === false + ? "twingate-dot-unreachable" + : "twingate-dot-pending"; + const pingDot = createDot(pingClass); + pingDot.visible = pingEnabled; + box.add_child(pingDot); + + item.add_child(box); + this._resourcesSection.addMenuItem(item); + + if (pingEnabled) this._resourceDots.set(address, pingDot); + } } - _setResources(resources) { - this._resourcesSection.removeAll(); - this._resourcesHeader.visible = resources.length > 0; - - this._resourceGeneration++; - const generation = this._resourceGeneration; - - for (const {name, address} of resources) { - const item = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false}); - const box = new St.BoxLayout({style_class: 'twingate-resource-box', x_expand: true}); - - const label = new St.Label({ - style_class: 'twingate-resource-label', - text: `${name} Β· ${address}`, - y_align: Clutter.ActorAlign.CENTER, - x_expand: true, - }); - box.add_child(label); - - const pingDot = createDot('twingate-dot-pending'); - box.add_child(pingDot); - - item.add_child(box); - this._resourcesSection.addMenuItem(item); - - pingAddress(address, this._cancellable).then(reachable => { - if (this._resourceGeneration !== generation) - return; - for (const cls of PING_CLASSES) - pingDot.remove_style_class_name(cls); - pingDot.add_style_class_name(reachable ? 'twingate-dot-reachable' : 'twingate-dot-unreachable'); - }).catch(() => {}); - } + _onToggle(wantConnected) { + if (wantConnected) this._runStart(); + else this._runStop(); } - _onToggle(wantConnected) { - this._runPrivileged(wantConnected ? 'start' : 'stop'); + async _runStart() { + if (this._busy) return; + this._busy = true; + try { + await runCommand( + ["pkexec", TWINGATE_BIN, "start"], + this._cancellable, + ); + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + } finally { + this._busy = false; + } + if (this._destroyed) return; + this._scheduleQuickRefreshes(); } - async _runPrivileged(action) { - if (this._busy) - return; - this._busy = true; - - const binary = this._settings.get_string('twingate-binary'); - const argv = this._settings.get_boolean('use-pkexec') - ? ['pkexec', binary, action] - : ['sudo', '-n', binary, action]; - - try { - await runCommand(argv, this._cancellable); - } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) - return; - } finally { - this._busy = false; - } - - // The daemon may take a moment to reflect the new state; poll a - // couple more times shortly after the action. - this._scheduleQuickRefreshes(); + async _runStop() { + if (this._busy) return; + this._busy = true; + try { + await runCommand( + ["pkexec", TWINGATE_BIN, "stop"], + this._cancellable, + ); + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + } finally { + this._busy = false; + } + if (this._destroyed) return; + this._scheduleQuickRefreshes(); } _scheduleQuickRefreshes() { - for (const delay of [2, 5]) { - const id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, delay, () => { - this._pendingRefreshIds.delete(id); - this._refresh(); - return GLib.SOURCE_REMOVE; - }); - this._pendingRefreshIds.add(id); - } + for (const delay of [2, 5]) { + const id = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + delay, + () => { + this._pendingRefreshIds.delete(id); + this._refresh(); + return GLib.SOURCE_REMOVE; + }, + ); + this._pendingRefreshIds.add(id); + } } async _refresh() { - const binary = this._settings.get_string('twingate-binary'); - let result; - try { - result = await runCommand([binary, '-d', 'status'], this._cancellable); - } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) - return; - this._setState('unknown'); - return; - } - - if (!result.success && result.stdout.length === 0) { - this._setState('unknown'); - return; - } - - const token = parseStatusToken(result.stdout); - this._setState(token); - - if (statusInfoFor(token).connected) - this._refreshResources(); - else - this._setResources([]); + let result; + try { + result = await runCommand( + [TWINGATE_BIN, "-d", "status"], + this._cancellable, + ); + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (this._destroyed) return; + this._setState("unknown"); + this._maybeNotifyStateChange("unknown"); + return; + } + + if (this._destroyed) return; + + if (!result.success && result.stdout.length === 0) { + this._setState("unknown"); + this._maybeNotifyStateChange("unknown"); + return; + } + + const token = parseStatusToken(result.stdout); + this._setState(token); + this._maybeNotifyStateChange(token); + + if (statusInfoFor(token).connected) this._refreshResources(); + else this._setResources([]); + } + + _maybeNotifyStateChange(token) { + const category = NOTIFY_CATEGORY[token] ?? null; + if ( + category && + this._previousCategory !== null && + category !== this._previousCategory && + this._settings.get_boolean("notifications-enabled") + ) { + const network = this._networkNameLabel?.text || "Twingate"; + if (category === "connected") + Main.notify("Twingate", `Connected to ${network}`); + else if (category === "disconnected") + Main.notify("Twingate", "Disconnected"); + else if (category === "error") + Main.notifyError( + "Twingate", + "Something went wrong β€” check the connection status.", + ); + } + if (category) this._previousCategory = category; } async _refreshResources() { - const binary = this._settings.get_string('twingate-binary'); - let result; - try { - result = await runCommand([binary, '-d', 'resources'], this._cancellable); - } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) - return; - this._setResources([]); - return; - } - - this._setResources(parseResources(result.stdout)); + let result; + try { + result = await runCommand( + [TWINGATE_BIN, "-d", "resources"], + this._cancellable, + ); + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (this._destroyed) return; + this._setResources([]); + return; + } + + if (this._destroyed) return; + + this._setResources(parseResources(result.stdout)); } destroy() { - if (this._timeoutId) { - GLib.Source.remove(this._timeoutId); - this._timeoutId = null; - } - for (const id of this._pendingRefreshIds) - GLib.Source.remove(id); - this._pendingRefreshIds.clear(); - - if (this._settingsChangedId) { - this._settings.disconnect(this._settingsChangedId); - this._settingsChangedId = null; - } - - this._cancellable.cancel(); - - super.destroy(); - } -}); + this._destroyed = true; -export default class TwingatePanelExtension extends Extension { - enable() { - this._indicator = new Indicator(this.path, this.getSettings(), () => this.openPreferences()); - Main.panel.addToStatusArea(this.uuid, this._indicator); + if (this._timeoutId) { + GLib.Source.remove(this._timeoutId); + this._timeoutId = null; + } + if (this._pingTimeoutId) { + GLib.Source.remove(this._pingTimeoutId); + this._pingTimeoutId = null; + } + for (const id of this._pendingRefreshIds) GLib.Source.remove(id); + this._pendingRefreshIds.clear(); + this._resourceDots.clear(); + + if (this._menuOpenStateId) { + this.menu.disconnect(this._menuOpenStateId); + this._menuOpenStateId = null; + } + if (this._settingsChangedId) { + this._settings.disconnect(this._settingsChangedId); + this._settingsChangedId = null; + } + if (this._pingSettingsChangedId) { + this._settings.disconnect(this._pingSettingsChangedId); + this._pingSettingsChangedId = null; + } + if (this._pingIntervalChangedId) { + this._settings.disconnect(this._pingIntervalChangedId); + this._pingIntervalChangedId = null; + } + + this._cancellable.cancel(); + + super.destroy(); } + }, +); - disable() { - this._indicator?.destroy(); - this._indicator = null; - } +export default class TwingatePanelExtension extends Extension { + enable() { + this._indicator = new Indicator(this.path, this.getSettings(), () => + this.openPreferences(), + ); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } } diff --git a/prefs.js b/prefs.js index 5bcdf03..3440e00 100644 --- a/prefs.js +++ b/prefs.js @@ -27,17 +27,33 @@ export default class TwingatePanelPreferences extends ExtensionPreferences { settings.bind('poll-interval-seconds', pollRow, 'value', Gio.SettingsBindFlags.DEFAULT); group.add(pollRow); - const pkexecRow = new Adw.SwitchRow({ - title: 'Use pkexec', - subtitle: 'Show a graphical password prompt for connect/disconnect. Turn off only if you configured passwordless sudo for these commands.', + const notificationsRow = new Adw.SwitchRow({ + title: 'Enable notifications', + subtitle: 'Show a notification when the connection state changes (connected/disconnected/error).', }); - settings.bind('use-pkexec', pkexecRow, 'active', Gio.SettingsBindFlags.DEFAULT); - group.add(pkexecRow); + settings.bind('notifications-enabled', notificationsRow, 'active', Gio.SettingsBindFlags.DEFAULT); + group.add(notificationsRow); - const binaryRow = new Adw.EntryRow({ - title: 'Twingate binary path', + const pingGroup = new Adw.PreferencesGroup({ + title: 'Resource pings', + description: 'Reachability check for each resource in the dropdown, independent of the status poll interval.', }); - settings.bind('twingate-binary', binaryRow, 'text', Gio.SettingsBindFlags.DEFAULT); - group.add(binaryRow); + page.add(pingGroup); + + const pingEnabledRow = new Adw.SwitchRow({ + title: 'Enable pings', + subtitle: 'Ping each resource periodically and show a reachability dot. Turn off to skip pinging entirely.', + }); + settings.bind('ping-enabled', pingEnabledRow, 'active', Gio.SettingsBindFlags.DEFAULT); + pingGroup.add(pingEnabledRow); + + const pingIntervalRow = new Adw.SpinRow({ + title: 'Ping interval', + subtitle: 'How often, in seconds, resource reachability is re-checked', + adjustment: new Gtk.Adjustment({lower: 1, upper: 300, step_increment: 1}), + }); + settings.bind('ping-interval-seconds', pingIntervalRow, 'value', Gio.SettingsBindFlags.DEFAULT); + settings.bind('ping-enabled', pingIntervalRow, 'sensitive', Gio.SettingsBindFlags.GET); + pingGroup.add(pingIntervalRow); } } diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index acc1d2a..445a16e 100644 Binary files a/schemas/gschemas.compiled and b/schemas/gschemas.compiled differ diff --git a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml index b5e1fee..6de5fa5 100644 --- a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml +++ b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml @@ -7,15 +7,21 @@ Poll interval How often, in seconds, status and resources are refreshed. - + true - Use pkexec for privileged actions - Run start/stop through pkexec (graphical polkit password prompt) instead of passwordless sudo. + Enable connection notifications + Send a native notification when the connection state actually changes (connected/disconnected/error). Note: GNOME's own Do Not Disturb setting already suppresses notification banners system-wide regardless of this setting. - - "/usr/bin/twingate" - Twingate binary path - Path to the twingate CLI executable. + + true + Enable resource reachability pings + Periodically ping each authorized resource's address to show a reachability dot. Disable to skip pinging entirely. + + + 5 + + Ping interval + How often, in seconds, resource reachability is re-checked via ping. Independent of the status poll interval. diff --git a/stylesheet.css b/stylesheet.css index f587443..383ad51 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -3,11 +3,13 @@ padding: 4px 8px; } -.twingate-header-logo { - width: 80px; - height: 18px; - background-size: contain; - background-repeat: no-repeat; +.twingate-network-title { + color: #ffffff; + font-size: 1em; +} + +.twingate-network-title-connected { + font-weight: bold; } .twingate-settings-button { @@ -19,21 +21,13 @@ background-color: rgba(255, 255, 255, 0.1); } -.twingate-status-dot { - /* Exact pixel size is set in JS (DOT_SIZE) β€” keep this in sync if changed. */ - border-radius: 5px; -} - -.twingate-dot-offline { - background-color: #9a9996; -} - -.twingate-dot-connecting { - background-color: #f5c211; +.twingate-header-icon { + color: #ffffff; } -.twingate-dot-online { - background-color: #33d17a; +.twingate-status-dot { + /* size comes from DOT_SIZE in JS, keep these matching if you change it */ + border-radius: 3.5px; } .twingate-dot-pending { @@ -52,6 +46,13 @@ color: #ffffff; } +.twingate-search-entry { + margin: 2px 8px; + padding: 2px 8px; + font-size: 0.85em; + min-height: 0; +} + .twingate-resource-box { spacing: 8px; } @@ -60,3 +61,10 @@ color: #ffffff; font-size: 0.85em; } + +.twingate-resource-empty { + color: rgba(255, 255, 255, 0.5); + font-size: 0.85em; + font-style: italic; + padding: 2px 8px; +}