From 815f68d04cb236c1c08a7edefe3a48bc45705c02 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 18:23:03 +0200 Subject: [PATCH 01/15] Show network name as a subtitle under the logo Reads the account's network name (e.g. "urhNET") from `twingate account list`, which works even while disconnected. Fetched once at startup and cached; the label stays hidden until resolved. --- README.md | 4 +++- extension.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++- stylesheet.css | 9 +++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5d4e1db..d7c517a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ dropdown menu that does it all. | | | |---|---| | 🟒 **Live status dot** | Grey (disconnected), yellow (connecting), green (connected) β€” always visible in the dropdown header. | +| 🌐 **Network name subtitle** | Shows your Twingate network name (e.g. `urhNET`) under the logo, 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. | | πŸ–ΌοΈ **Static, theme-independent icon** | The panel icon doesn't change color or shift with light/dark shell themes β€” it's always your Twingate mark. | @@ -67,7 +68,8 @@ dropdown menu that does it all. β”‚ [Twingate icon] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ click β†’ dropdown: - β”‚ [Twingate wordmark] ● status dot βš™ (opens Preferences) + β”‚ [Twingate wordmark] ● status dot βš™ (opens Preferences) + β”‚ [network name] β”‚ ──────────────────────── β”‚ Connected [ toggle ] β”‚ ──────────────────────── diff --git a/extension.js b/extension.js index 4b6bdb5..14c64e9 100644 --- a/extension.js +++ b/extension.js @@ -123,6 +123,23 @@ async function pingAddress(address, cancellable) { * When disconnected, the CLI prints an explanatory sentence instead of a * table β€” that yields no rows here, which is the desired behavior. */ +/** + * `twingate account list` prints a tab-separated table: + * EMAIL NETWORK NETWORK URL + * Works even while disconnected. We only want the network name of the + * first (typically only) account. + */ +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; +} + function parseResources(stdout) { const lines = (stdout ?? '').split('\n').map(l => l.trim()).filter(l => l.length > 0); const rows = []; @@ -162,11 +179,28 @@ class Indicator extends PanelMenu.Button { this._refresh(); this._startPolling(); + this._loadNetworkName(); this._settingsChangedId = this._settings.connect('changed::poll-interval-seconds', () => { this._startPolling(); }); } + async _loadNetworkName() { + const binary = this._settings.get_string('twingate-binary'); + let result; + try { + result = await runCommand([binary, '-d', 'account', 'list'], this._cancellable); + } catch (e) { + return; + } + + const name = parseNetworkName(result.stdout); + if (name) { + this._networkLabel.text = name; + this._networkLabel.visible = true; + } + } + _startPolling() { if (this._timeoutId) { GLib.Source.remove(this._timeoutId); @@ -183,12 +217,23 @@ class Indicator extends PanelMenu.Button { const headerItem = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false}); const headerBox = new St.BoxLayout({style_class: 'twingate-header-box', x_expand: true}); + const logoBox = new St.BoxLayout({style_class: 'twingate-logo-box', vertical: true}); + 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); + logoBox.add_child(logo); + + this._networkLabel = new St.Label({ + style_class: 'twingate-network-label', + text: '', + visible: false, + }); + logoBox.add_child(this._networkLabel); + + headerBox.add_child(logoBox); this._statusDot = createDot('twingate-dot-offline'); headerBox.add_child(this._statusDot); diff --git a/stylesheet.css b/stylesheet.css index f587443..d00232c 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -3,6 +3,10 @@ padding: 4px 8px; } +.twingate-logo-box { + spacing: 2px; +} + .twingate-header-logo { width: 80px; height: 18px; @@ -10,6 +14,11 @@ background-repeat: no-repeat; } +.twingate-network-label { + color: rgba(255, 255, 255, 0.6); + font-size: 0.7em; +} + .twingate-settings-button { border-radius: 6px; padding: 4px; From 5cc292616343f08c6ad3150ff60c2c46cc686004 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 18:29:37 +0200 Subject: [PATCH 02/15] Split logo and network name into separate containers Logo now sits alone in its own row; the network name and connection status dot share a second row underneath, side by side. --- extension.js | 19 +++++++++++++------ stylesheet.css | 6 +++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/extension.js b/extension.js index 14c64e9..cee1385 100644 --- a/extension.js +++ b/extension.js @@ -217,26 +217,33 @@ class Indicator extends PanelMenu.Button { const headerItem = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false}); const headerBox = new St.BoxLayout({style_class: 'twingate-header-box', x_expand: true}); - const logoBox = new St.BoxLayout({style_class: 'twingate-logo-box', vertical: true}); + const infoBox = new St.BoxLayout({style_class: 'twingate-info-box', vertical: true}); + const logoContainer = new St.BoxLayout(); 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}");`, }); - logoBox.add_child(logo); + logoContainer.add_child(logo); + infoBox.add_child(logoContainer); + + const subtitleRow = new St.BoxLayout({style_class: 'twingate-subtitle-row'}); this._networkLabel = new St.Label({ style_class: 'twingate-network-label', text: '', visible: false, + y_align: Clutter.ActorAlign.CENTER, }); - logoBox.add_child(this._networkLabel); - - headerBox.add_child(logoBox); + subtitleRow.add_child(this._networkLabel); this._statusDot = createDot('twingate-dot-offline'); - headerBox.add_child(this._statusDot); + subtitleRow.add_child(this._statusDot); + + infoBox.add_child(subtitleRow); + + headerBox.add_child(infoBox); const spacer = new St.Widget({x_expand: true}); headerBox.add_child(spacer); diff --git a/stylesheet.css b/stylesheet.css index d00232c..2e65f22 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -3,7 +3,7 @@ padding: 4px 8px; } -.twingate-logo-box { +.twingate-info-box { spacing: 2px; } @@ -14,6 +14,10 @@ background-repeat: no-repeat; } +.twingate-subtitle-row { + spacing: 6px; +} + .twingate-network-label { color: rgba(255, 255, 255, 0.6); font-size: 0.7em; From 1e5f022225150bb20d9c98e89c44a4301726b170 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 21:22:34 +0200 Subject: [PATCH 03/15] Add menu-aware polling, independent ping interval, and network name header - Poll less frequently while the dropdown is closed, refresh immediately on open (BACKGROUND_POLL_MULTIPLIER). - Ping interval is now independent of the status poll interval, with its own GSettings key and an on/off toggle (schema + prefs.js updated). - Dropdown header: logo image replaced with the account's network name (from `twingate account list`), bold when connected, next to the status dot. The "Connected" switch label stays plain text. - Add .gitignore (currently just TODO.md, a local-only brainstorm file). The wordmark PNG asset is left in icons/ unused, in case the logo comes back in a future revision. --- .gitignore | 1 + extension.js | 774 ++++++++++-------- prefs.js | 22 + schemas/gschemas.compiled | Bin 468 -> 608 bytes ...hell.extensions.twingate-panel.gschema.xml | 11 + stylesheet.css | 35 +- 6 files changed, 485 insertions(+), 358 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3aa864b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +TODO.md diff --git a/extension.js b/extension.js index cee1385..c9e3a5e 100644 --- a/extension.js +++ b/extension.js @@ -7,18 +7,18 @@ // the Free Software Foundation, either version 3 of the License, or // (at your option) 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 @@ -26,21 +26,32 @@ Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async'); // 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). +// The panel icon is static; the connection status is shown as a colored +// dot glyph (via Pango markup) prefixed to the network name on the +// "Connected" switch row. Colors match stylesheet.css's dot classes. 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 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; +const PING_CLASSES = [ + "twingate-dot-pending", + "twingate-dot-reachable", + "twingate-dot-unreachable", +]; +const DOT_SIZE = 7; +// When the menu is closed, nobody is looking at the status dot, so poll +// less often β€” this many times slower than the configured interval. +const BACKGROUND_POLL_MULTIPLIER = 6; /** * A plain St.Widget with no content has a natural size of 0x0, so CSS @@ -50,18 +61,18 @@ const DOT_SIZE = 10; * regardless of theme/layout quirks. */ 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; } /** @@ -70,49 +81,57 @@ function statusInfoFor(token) { * 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) { + 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) ?? ""; + // 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"; } /** 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; + } } /** @@ -130,318 +149,415 @@ async function pingAddress(address, cancellable) { * first (typically only) account. */ 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 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; } 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; + rows.push({ name: fields[0], address: fields[1] }); + } + 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; - - 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._loadNetworkName(); - this._settingsChangedId = this._settings.connect('changed::poll-interval-seconds', () => { - this._startPolling(); - }); + 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 = []; + + 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(); + 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(); + }, + ); } async _loadNetworkName() { - const binary = this._settings.get_string('twingate-binary'); - let result; - try { - result = await runCommand([binary, '-d', 'account', 'list'], this._cancellable); - } catch (e) { - return; - } - - const name = parseNetworkName(result.stdout); - if (name) { - this._networkLabel.text = name; - this._networkLabel.visible = true; - } + const binary = this._settings.get_string("twingate-binary"); + let result; + try { + result = await runCommand( + [binary, "-d", "account", "list"], + this._cancellable, + ); + } catch (e) { + return; + } + + const name = parseNetworkName(result.stdout); + if (name) this._networkNameLabel.text = name; } _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; - }); + 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; + }, + ); } - _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 infoBox = new St.BoxLayout({style_class: 'twingate-info-box', vertical: true}); - - const logoContainer = new St.BoxLayout(); - 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}");`, - }); - logoContainer.add_child(logo); - infoBox.add_child(logoContainer); + _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; + }, + ); + } - const subtitleRow = new St.BoxLayout({style_class: 'twingate-subtitle-row'}); + _pingAllResources(generation) { + for (const { address, dot } of this._resourceDots) { + if (generation !== this._resourceGeneration) return; + pingAddress(address, this._cancellable) + .then((reachable) => { + if (generation !== this._resourceGeneration) return; + 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(() => {}); + } + } - this._networkLabel = new St.Label({ - style_class: 'twingate-network-label', - text: '', - visible: false, - y_align: Clutter.ActorAlign.CENTER, - }); - subtitleRow.add_child(this._networkLabel); + _buildMenu() { + 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", + }), + }); + 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.PopupMenuItem("Resources", { + reactive: false, + can_focus: false, + }); + 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); + } - this._statusDot = createDot('twingate-dot-offline'); - subtitleRow.add_child(this._statusDot); + _setState(token) { + const info = statusInfoFor(token); + + 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; + } - infoBox.add_child(subtitleRow); + _setResources(resources) { + this._resourcesSection.removeAll(); + this._resourcesHeader.visible = resources.length > 0; - headerBox.add_child(infoBox); + this._resourceGeneration++; + const generation = this._resourceGeneration; + this._resourceDots = []; - const spacer = new St.Widget({x_expand: true}); - headerBox.add_child(spacer); + const pingEnabled = this._settings.get_boolean("ping-enabled"); - const settingsButton = new St.Button({ - style_class: 'twingate-settings-button', - child: new St.Icon({ - icon_name: 'preferences-system-symbolic', - style_class: 'popup-menu-icon', - }), + for (const { name, address } of resources) { + const item = new PopupMenu.PopupBaseMenuItem({ + reactive: false, + can_focus: false, }); - settingsButton.connect('clicked', () => { - this.menu.close(); - this._onOpenPreferences?.(); + const box = new St.BoxLayout({ + style_class: "twingate-resource-box", + x_expand: true, }); - 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 label = new St.Label({ + style_class: "twingate-resource-label", + text: `${name} Β· ${address}`, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, }); - this.menu.addMenuItem(this._switchItem); + box.add_child(label); - this._resourcesSeparator = new PopupMenu.PopupSeparatorMenuItem(); - this.menu.addMenuItem(this._resourcesSeparator); + const pingDot = createDot("twingate-dot-pending"); + pingDot.visible = pingEnabled; + box.add_child(pingDot); - this._resourcesHeader = new PopupMenu.PopupMenuItem('Resources', { - reactive: false, - can_focus: false, - }); - this._resourcesHeader.label.add_style_class_name('twingate-resources-header'); - this._resourcesHeader.visible = false; - this.menu.addMenuItem(this._resourcesHeader); + item.add_child(box); + this._resourcesSection.addMenuItem(item); - this._resourcesSection = new PopupMenu.PopupMenuSection(); - this.menu.addMenuItem(this._resourcesSection); - } + if (pingEnabled) this._resourceDots.push({ address, dot: pingDot }); + } - _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; - } - - _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(() => {}); - } + if (pingEnabled && this._resourceDots.length > 0) + this._pingAllResources(generation); } _onToggle(wantConnected) { - this._runPrivileged(wantConnected ? 'start' : 'stop'); + this._runPrivileged(wantConnected ? "start" : "stop"); } 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(); + 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(); } _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([]); + 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([]); } 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)); + 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)); } 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(); + 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 = []; + + 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(); } -}); + }, +); 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; - } + 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..c167167 100644 --- a/prefs.js +++ b/prefs.js @@ -39,5 +39,27 @@ export default class TwingatePanelPreferences extends ExtensionPreferences { }); settings.bind('twingate-binary', binaryRow, 'text', Gio.SettingsBindFlags.DEFAULT); group.add(binaryRow); + + const pingGroup = new Adw.PreferencesGroup({ + title: 'Resource pings', + description: 'Reachability check for each resource in the dropdown, independent of the status poll interval.', + }); + 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 acc1d2a96c30725c725f4a0c15c65fb2d8825f10..5808f11331152a8c12571ee48ed13f1ae2f1f622 100644 GIT binary patch literal 608 zcmZuuu};G<5WP|g5+FcAl@JRHLn4v*2N>8ur~?xM!BDrgT0?N6I4$kasz{9d0YAXP z#s~BV7-3<7nUTHaUDC7*mY&}8JKJ~XJ35oGmL{R;+6JFz+F8g=aIyc{DokT89n%B! zDU4E?Xo7W_9a3?WBue#o2E>Z7fGXnEQhn4F)e~~ zl%N?)z|Z~bm^J5IqYhQU3OMdT1J!qgj7380qMM4M#gYM{C!0+ZJDRqq?e|#_`VV=W5!>xW~su+r|Q0 zk;m|Yb^S;tEe?~wAVfL{kq$!42Qj;n4MGr2YvTXrdWryA%6j_0{B%Bf{rh}rUOzTO Ef7k4L;S zy15w27&L%1NC0LMSQ2QG0Z_~ah`l}+^#Ca_TNJ210>~)_;sdpH+#s7lY*wIn1(03_ z#8UECBY~79n8Od0*a75$oL5?$s#}nqT9KLzGz|zC88nhKfix2kvw#GEpddddXR-#9 r%H$qKepbDL#FC834;c0MOUg6z(i2Nkb(1pl5{oJ)?`4!^gJ=K%INCN@ diff --git a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml index b5e1fee..48ee886 100644 --- a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml +++ b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml @@ -17,5 +17,16 @@ 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 2e65f22..b585c14 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -3,24 +3,13 @@ padding: 4px 8px; } -.twingate-info-box { - spacing: 2px; -} - -.twingate-header-logo { - width: 80px; - height: 18px; - background-size: contain; - background-repeat: no-repeat; -} - -.twingate-subtitle-row { - spacing: 6px; +.twingate-network-title { + color: #ffffff; + font-size: 1em; } -.twingate-network-label { - color: rgba(255, 255, 255, 0.6); - font-size: 0.7em; +.twingate-network-title-connected { + font-weight: bold; } .twingate-settings-button { @@ -34,19 +23,7 @@ .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-dot-online { - background-color: #33d17a; + border-radius: 3.5px; } .twingate-dot-pending { From b91f35034f45528ed25214a9429040b5414e53b8 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 21:31:05 +0200 Subject: [PATCH 04/15] Send native notifications on real connection state changes Notifies on connected/disconnected/error transitions (Main.notify / Main.notifyError), never on repeated polls of the same state and never for the initial state discovered right after startup. Transitional tokens (connecting/authenticating) are ignored but don't reset the baseline, so a later real change is still caught. The status dot in the header stays as the passive at-a-glance indicator. --- extension.js | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/extension.js b/extension.js index c9e3a5e..580e1c8 100644 --- a/extension.js +++ b/extension.js @@ -27,8 +27,7 @@ Gio._promisify(Gio.Subprocess.prototype, "communicate_utf8_async"); // ---- Status β†’ UI mapping -------------------------------------------------- // The panel icon is static; the connection status is shown as a colored -// dot glyph (via Pango markup) prefixed to the network name on the -// "Connected" switch row. Colors match stylesheet.css's dot classes. +// dot next to the network name in the dropdown header. const STATUS_INFO = { "not-running": { label: "Not running", connected: false, dotColor: "#9a9996" }, @@ -43,6 +42,16 @@ const STATUS_INFO = { unknown: { label: "Unknown", connected: false, dotColor: "#9a9996" }, }; +// Coarser grouping used only for deciding when to send a notification β€” +// "connecting"/"authenticating" are transitional and intentionally absent, +// so they neither trigger a notification nor reset the baseline. +const NOTIFY_CATEGORY = { + online: "connected", + "not-running": "disconnected", + offline: "disconnected", + unknown: "error", +}; + const PING_CLASSES = [ "twingate-dot-pending", "twingate-dot-reachable", @@ -191,6 +200,7 @@ const Indicator = GObject.registerClass( this._busy = false; this._resourceGeneration = 0; this._resourceDots = []; + this._previousCategory = null; const iconPath = GLib.build_filenamev([ extensionPath, @@ -479,21 +489,53 @@ const Indicator = GObject.registerClass( } catch (e) { if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; this._setState("unknown"); + this._maybeNotifyStateChange("unknown"); 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([]); } + /** + * Sends a native notification when the connection category (connected / + * disconnected / error) actually changes β€” never on every poll tick, and + * never for the very first status check after startup (that's just + * discovering the current state, not a change). Transitional tokens + * (connecting/authenticating) have no category and are ignored, but + * don't reset the baseline, so a later real change is still caught. + */ + _maybeNotifyStateChange(token) { + const category = NOTIFY_CATEGORY[token] ?? null; + if ( + category && + this._previousCategory !== null && + category !== this._previousCategory + ) { + 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; From b1f03426c5d48b2e99b3197281d13440cc7edc04 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:20:29 +0200 Subject: [PATCH 05/15] Add resource search/filter and per-connection notification toggle - Resources header gets a magnifier button (edit-find-symbolic) that reveals a compact search entry, filtering by name+address. Persisted ping-status map means filtering no longer resets known dots to gray. Search auto-collapses and clears when the dropdown menu closes. - New notifications-enabled GSettings key + prefs.js switch, gating the Main.notify/notifyError calls added earlier. GNOME's own Do Not Disturb setting (org.gnome.desktop.notifications show-banners) already suppresses banners shell-wide regardless of this toggle, so no extra DND-detection code was needed. - Header and search-toggle icons share a new .twingate-header-icon class so their color stays consistent regardless of which menu item wraps them. --- extension.js | 147 +++++++++++++++--- prefs.js | 7 + schemas/gschemas.compiled | Bin 608 -> 668 bytes ...hell.extensions.twingate-panel.gschema.xml | 5 + stylesheet.css | 11 ++ 5 files changed, 146 insertions(+), 24 deletions(-) diff --git a/extension.js b/extension.js index 580e1c8..20b0c11 100644 --- a/extension.js +++ b/extension.js @@ -199,7 +199,11 @@ const Indicator = GObject.registerClass( this._suppressToggle = false; this._busy = false; this._resourceGeneration = 0; - this._resourceDots = []; + this._resourceDots = new Map(); + this._allResources = []; + this._resourceFilter = ""; + this._searchToggledOn = false; + this._pingStatus = new Map(); this._previousCategory = null; const iconPath = GLib.build_filenamev([ @@ -225,6 +229,7 @@ const Indicator = GObject.registerClass( "open-state-changed", (_menu, isOpen) => { if (isOpen) this._refresh(); + else this._setSearchVisible(false); this._startPolling(); }, ); @@ -300,16 +305,28 @@ const Indicator = GObject.registerClass( ); } + /** + * Pings every known resource (regardless of the current search filter), + * so `_pingStatus` stays fresh even for rows hidden by the filter. Only + * updates a dot actor live if that address happens to be currently + * rendered (present in `_resourceDots`). + */ _pingAllResources(generation) { - for (const { address, dot } of this._resourceDots) { + for (const { address } of this._allResources) { if (generation !== this._resourceGeneration) return; pingAddress(address, this._cancellable) .then((reachable) => { if (generation !== this._resourceGeneration) return; - for (const cls of PING_CLASSES) dot.remove_style_class_name(cls); - dot.add_style_class_name( - reachable ? "twingate-dot-reachable" : "twingate-dot-unreachable", - ); + 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(() => {}); } @@ -343,7 +360,7 @@ const Indicator = GObject.registerClass( y_align: Clutter.ActorAlign.START, child: new St.Icon({ icon_name: "preferences-system-symbolic", - style_class: "popup-menu-icon", + style_class: "popup-menu-icon twingate-header-icon", }), }); settingsButton.connect("clicked", () => { @@ -365,16 +382,60 @@ const Indicator = GObject.registerClass( this._resourcesSeparator = new PopupMenu.PopupSeparatorMenuItem(); this.menu.addMenuItem(this._resourcesSeparator); - this._resourcesHeader = new PopupMenu.PopupMenuItem("Resources", { - reactive: false, + this._resourcesHeader = new PopupMenu.PopupBaseMenuItem({ + reactive: true, can_focus: false, + activate: false, }); - this._resourcesHeader.label.add_style_class_name( - "twingate-resources-header", - ); + 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); } @@ -399,16 +460,49 @@ const Indicator = GObject.registerClass( } _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); + } + + _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(); + } + + /** + * Rebuilds the visible resource list from `_allResources`, filtered by + * `_resourceFilter` (case-insensitive substring on name + address). + * Dot colors come from the persisted `_pingStatus` map rather than + * always starting "pending" β€” so filtering doesn't make already-known + * dots flicker gray again. + */ + _renderResourceItems() { this._resourcesSection.removeAll(); - this._resourcesHeader.visible = resources.length > 0; + + const hasResources = this._allResources.length > 0; + this._resourcesHeader.visible = hasResources; + this._resourceSearchItem.visible = hasResources && this._searchToggledOn; this._resourceGeneration++; - const generation = this._resourceGeneration; - this._resourceDots = []; + this._resourceDots.clear(); const pingEnabled = this._settings.get_boolean("ping-enabled"); - - for (const { name, address } of resources) { + const filter = this._resourceFilter; + const filtered = filter + ? this._allResources.filter(({ name, address }) => + `${name} ${address}`.toLowerCase().includes(filter), + ) + : this._allResources; + + for (const { name, address } of filtered) { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, @@ -426,18 +520,22 @@ const Indicator = GObject.registerClass( }); box.add_child(label); - const pingDot = createDot("twingate-dot-pending"); + 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.push({ address, dot: pingDot }); + if (pingEnabled) this._resourceDots.set(address, pingDot); } - - if (pingEnabled && this._resourceDots.length > 0) - this._pingAllResources(generation); } _onToggle(wantConnected) { @@ -520,7 +618,8 @@ const Indicator = GObject.registerClass( if ( category && this._previousCategory !== null && - category !== this._previousCategory + category !== this._previousCategory && + this._settings.get_boolean("notifications-enabled") ) { const network = this._networkNameLabel?.text || "Twingate"; if (category === "connected") @@ -564,7 +663,7 @@ const Indicator = GObject.registerClass( } for (const id of this._pendingRefreshIds) GLib.Source.remove(id); this._pendingRefreshIds.clear(); - this._resourceDots = []; + this._resourceDots.clear(); if (this._menuOpenStateId) { this.menu.disconnect(this._menuOpenStateId); diff --git a/prefs.js b/prefs.js index c167167..41b1366 100644 --- a/prefs.js +++ b/prefs.js @@ -40,6 +40,13 @@ export default class TwingatePanelPreferences extends ExtensionPreferences { settings.bind('twingate-binary', binaryRow, 'text', Gio.SettingsBindFlags.DEFAULT); group.add(binaryRow); + const notificationsRow = new Adw.SwitchRow({ + title: 'Enable notifications', + subtitle: 'Show a notification when the connection state changes (connected/disconnected/error).', + }); + settings.bind('notifications-enabled', notificationsRow, 'active', Gio.SettingsBindFlags.DEFAULT); + group.add(notificationsRow); + const pingGroup = new Adw.PreferencesGroup({ title: 'Resource pings', description: 'Reachability check for each resource in the dropdown, independent of the status poll interval.', diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 5808f11331152a8c12571ee48ed13f1ae2f1f622..4bafb32236f455607bac2e998fb821fabc999033 100644 GIT binary patch literal 668 zcmZWnJxc>Y6nrs8#RwJ>5F2fTJMiv0D|>@Puo5jd$(pR5d*SvH6C^=FI}01Z!d8EQ zV4FW+X_d;_MzBwucb6}5VQz-G_p$qScMnCVgpO#qR>4P!wq`~qINk15Q^S8Y<>?06 zhmpw<4X_^K-~v5@pMW5&xD_3oOV?DT@4K=YNo`c1jT>DkT@jIVYC=mt>0vD-Ox%M@ z%>tPr=D>_@yOZT!abIds6TvUQXaBv!v8;3OyWk$M_Y>Z+&zk*ja2Cba)<2HfXT1!6 z1H27UOXUgG9d5ofIK2o!^9k=BN;Y??-*GMblJppxK9Y+Xtc#2OIa;TDciPd zf$#rInvUN=_*DG3ZBc0vw)iLZSvR2vW4tM37(3pN)S~FiauSy!t7t>=J@MV~R&ZoW h1M`3mB6XrlBH}0DOj6^#y1`>OlTBGdUQ!1s^b39dk1YTI literal 608 zcmZuuu};G<5WP|g5+FcAl@JRHLn4v*2N>8ur~?xM!BDrgT0?N6I4$kasz{9d0YAXP z#s~BV7-3<7nUTHaUDC7*mY&}8JKJ~XJ35oGmL{R;+6JFz+F8g=aIyc{DokT89n%B! zDU4E?Xo7W_9a3?WBue#o2E>Z7fGXnEQhn4F)e~~ zl%N?)z|Z~bm^J5IqYhQU3OMdT1J!qgj7380qMM4M#gYM{C!0+ZJDRqq?e|#_`VV=W5!>xW~su+r|Q0 zk;m|Yb^S;tEe?~wAVfL{kq$!42Qj;n4MGr2YvTXrdWryA%6j_0{B%Bf{rh}rUOzTO Ef7kTwingate binary path Path to the twingate CLI executable. + + true + 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. + true Enable resource reachability pings diff --git a/stylesheet.css b/stylesheet.css index b585c14..5e7907c 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -21,6 +21,10 @@ background-color: rgba(255, 255, 255, 0.1); } +.twingate-header-icon { + color: #ffffff; +} + .twingate-status-dot { /* Exact pixel size is set in JS (DOT_SIZE) β€” keep this in sync if changed. */ border-radius: 3.5px; @@ -42,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; } From 0de979e93a30e433db219712da4aef2cc4ec5036 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:42:29 +0200 Subject: [PATCH 06/15] Add basic CI: syntax check, metadata validation, schema compile Runs on push/PR to main and production: node --check on extension.js and prefs.js, JSON validation of metadata.json, and glib-compile-schemas on the GSettings schema. Gives the CI monitoring panel real status to show instead of 'unavailable' (the repo had no workflows configured at all before this). --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml 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/ From 443caf884c21c9b6b68322542f814fb424d40cbf Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:47:16 +0200 Subject: [PATCH 07/15] Document CI in README Add a live GitHub Actions status badge and list .github/workflows/ci.yml in the project layout tree. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d7c517a..7be5ac5 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) @@ -159,6 +160,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 From 876004bb35314bc0a6b02188407a81439fa57181 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:49:25 +0200 Subject: [PATCH 08/15] Rewrite How it works diagram to match current behavior The diagram still showed the removed logo, a fixed 5s poll interval, and had no mention of the resource search toggle or notifications. Updated to reflect: network name replacing the logo, menu-aware polling with backoff, the independent ping interval/toggle, and notification behavior on real state changes. --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7be5ac5..b26862f 100644 --- a/README.md +++ b/README.md @@ -69,23 +69,29 @@ dropdown menu that does it all. β”‚ [Twingate icon] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ click β†’ dropdown: - β”‚ [Twingate wordmark] ● status dot βš™ (opens Preferences) - β”‚ [network name] + β”‚ 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. From e6160ed977165a4f0b383df812d5f96afc8f4476 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:51:21 +0200 Subject: [PATCH 09/15] Fix Features table: genericize network name example, add missing rows - Real network name example replaced with generic 'network-name' - 'under the logo' wording was stale (logo was replaced with the network name itself) - Added rows for resource search and connection notifications, both missing from this table despite being shipped earlier --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b26862f..5d27cf7 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ dropdown menu that does it all. | | | |---|---| -| 🟒 **Live status dot** | Grey (disconnected), yellow (connecting), green (connected) β€” always visible in the dropdown header. | -| 🌐 **Network name subtitle** | Shows your Twingate network name (e.g. `urhNET`) under the logo, read from `twingate account list`. | +| 🟒 **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. | From a12eb6a17fcb28debf23cfcd0dad431a180a1103 Mon Sep 17 00:00:00 2001 From: urhend Date: Thu, 30 Jul 2026 22:53:32 +0200 Subject: [PATCH 10/15] Full README audit: add missing Configuration rows, fix stale wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Configuration table was missing 3 of 6 real GSettings keys (notifications-enabled, ping-enabled, ping-interval-seconds) - 'opposite the Twingate logo' no longer applies β€” the logo was replaced with the network name in an earlier revision - Project layout comment for twingate-wordmark.png still claimed it's shown in the dropdown header; it's actually unused, kept only in case the logo comes back later --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d27cf7..d426c3f 100644 --- a/README.md +++ b/README.md @@ -132,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 @@ -144,6 +144,9 @@ gnome-extensions prefs twingate-panel@urhend | 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 @@ -178,7 +181,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 From 12a23180ceffb127f89adb91a8f7e116288033be Mon Sep 17 00:00:00 2001 From: urhend Date: Fri, 31 Jul 2026 22:21:20 +0200 Subject: [PATCH 11/15] Fix EGO review issues: no sudo, hardcoded privileged argv, validated address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer feedback on the v3 submission: - Removed the sudo fallback entirely; privileged actions now always go through pkexec (EGO review guidelines disallow sudo outright) - The binary path for start/stop is now a hardcoded constant (PRIVILEGED_TWINGATE_BIN), independent of the user-configurable twingate-binary setting β€” GSettings is user-writable, so it must never drive a privileged subprocess's argv - _runPrivileged(action) split into separate _runStart()/_runStop() methods with fully literal argv at the call site, so nothing about the privileged command is built from a parameter - Added VALID_ADDRESS_RE validation when parsing twingate resources output, before any address reaches the ping subprocess β€” closes a real argument-injection angle (a leading '-' could otherwise be read as a ping flag) and makes the data flow easy to trace - Removed the now-pointless use-pkexec setting (schema + prefs.js UI) since pkexec is no longer optional - Rewrote code comments in a more natural, human voice β€” shorter, less formal, dropped the ASCII-art section banners - README updated to match: Configuration table, Known limitations --- README.md | 11 +- extension.js | 151 +++++++++--------- prefs.js | 11 +- schemas/gschemas.compiled | Bin 668 -> 624 bytes ...hell.extensions.twingate-panel.gschema.xml | 7 +- stylesheet.css | 2 +- 6 files changed, 89 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index d426c3f..2847b5c 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,7 @@ 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. | +| Twingate binary path | `/usr/bin/twingate` | Path to the Twingate CLI binary, used for read-only status/resources/account checks only. Connect/disconnect always run `/usr/bin/twingate` directly via `pkexec`, independent of this setting β€” see [Known limitations](#known-limitations). | | 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. | @@ -191,8 +190,12 @@ 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), + and the binary path used for that call is hardcoded rather than + configurable, for the same reason. - 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 20b0c11..3ac7a2e 100644 --- a/extension.js +++ b/extension.js @@ -20,15 +20,13 @@ import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; 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; the connection status is shown as a colored -// dot next to the network name in the dropdown header. +// Most settings live in GSettings (schemas/ + prefs.js), but not this one. +// start/stop run as root through pkexec, and GSettings can be rewritten by +// any local process, so this path has to stay hardcoded. +const PRIVILEGED_TWINGATE_BIN = "/usr/bin/twingate"; +// panel icon never changes, connection status is just a dot next to the +// network name in the dropdown const STATUS_INFO = { "not-running": { label: "Not running", connected: false, dotColor: "#9a9996" }, offline: { label: "Offline", connected: false, dotColor: "#9a9996" }, @@ -42,9 +40,9 @@ const STATUS_INFO = { unknown: { label: "Unknown", connected: false, dotColor: "#9a9996" }, }; -// Coarser grouping used only for deciding when to send a notification β€” -// "connecting"/"authenticating" are transitional and intentionally absent, -// so they neither trigger a notification nor reset the baseline. +// just for deciding when to notify. connecting/authenticating aren't in +// here on purpose since they're transitional β€” no notification, and they +// don't touch the baseline either const NOTIFY_CATEGORY = { online: "connected", "not-running": "disconnected", @@ -58,17 +56,13 @@ const PING_CLASSES = [ "twingate-dot-unreachable", ]; const DOT_SIZE = 7; -// When the menu is closed, nobody is looking at the status dot, so poll -// less often β€” this many times slower than the configured interval. +// nobody's watching the dot when the menu's closed, so slow way down β€” +// this many times slower than whatever the user set const BACKGROUND_POLL_MULTIPLIER = 6; -/** - * 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. - */ +// empty St.Widgets default to 0x0, so CSS width/height alone doesn't +// really hold up inside a BoxLayout β€” it just stretches instead of +// staying a circle. set_size() + turning off expand actually works though function createDot(extraClass) { const dot = new St.Widget({ style_class: `twingate-status-dot ${extraClass}`, @@ -84,11 +78,8 @@ function statusInfoFor(token) { 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. - */ +// runs argv without blocking the shell. rethrows if it gets cancelled, +// otherwise just stuffs whatever went wrong into {success: false, ...} async function runCommand(argv, cancellable) { let proc; try { @@ -123,13 +114,13 @@ function parseStatusToken(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. + // being paranoid here β€” lowercase it, grab the first token, in case some + // CLI version prints extra stuff before the actual status 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. */ +// true if one ping got a reply within a second, that's the whole check async function pingAddress(address, cancellable) { try { const result = await runCommand( @@ -143,20 +134,10 @@ async function pingAddress(address, cancellable) { } } -/** - * `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. - */ -/** - * `twingate account list` prints a tab-separated table: - * EMAIL NETWORK NETWORK URL - * Works even while disconnected. We only want the network name of the - * first (typically only) account. - */ +// `twingate account list` spits out a tab-separated table: +// EMAIL NETWORK NETWORK URL +// works fine while disconnected too. we just grab the network name off +// the first account, most people only have the one anyway function parseNetworkName(stdout) { const lines = (stdout ?? "") .split("\n") @@ -170,6 +151,17 @@ function parseNetworkName(stdout) { return null; } +// anything that doesn't match this gets dropped down in parseResources(). +// mostly here so a leading '-' can't get read as a ping flag instead of +// an actual target +const VALID_ADDRESS_RE = /^[A-Za-z0-9][A-Za-z0-9.:_-]*$/; + +// `twingate resources` prints a tab-separated table: +// RESOURCE NAME ADDRESS ALIAS AUTH STATUS +// Home Assistant192.168.0.211- +// we only care about name + address, header row and extra columns get +// dropped. when disconnected it just prints a sentence instead of a +// table, so this returns nothing β€” which is exactly what we want function parseResources(stdout) { const lines = (stdout ?? "") .split("\n") @@ -179,7 +171,9 @@ function parseResources(stdout) { 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] }); + const address = fields[1]; + if (!VALID_ADDRESS_RE.test(address)) continue; + rows.push({ name: fields[0], address }); } return rows; } @@ -305,12 +299,9 @@ const Indicator = GObject.registerClass( ); } - /** - * Pings every known resource (regardless of the current search filter), - * so `_pingStatus` stays fresh even for rows hidden by the filter. Only - * updates a dot actor live if that address happens to be currently - * rendered (present in `_resourceDots`). - */ + // pings everything we know about, filtered or not, so _pingStatus + // stays current even for rows the search box is hiding. only touches + // a dot actor if it's actually on screen right now _pingAllResources(generation) { for (const { address } of this._allResources) { if (generation !== this._resourceGeneration) return; @@ -477,13 +468,9 @@ const Indicator = GObject.registerClass( if (visible) this._searchEntry.grab_key_focus(); } - /** - * Rebuilds the visible resource list from `_allResources`, filtered by - * `_resourceFilter` (case-insensitive substring on name + address). - * Dot colors come from the persisted `_pingStatus` map rather than - * always starting "pending" β€” so filtering doesn't make already-known - * dots flicker gray again. - */ + // rebuilds what's visible from _allResources + _resourceFilter. dot + // colors come from _pingStatus instead of resetting to pending every + // time, otherwise typing in the search box would flash everything gray _renderResourceItems() { this._resourcesSection.removeAll(); @@ -539,28 +526,45 @@ const Indicator = GObject.registerClass( } _onToggle(wantConnected) { - this._runPrivileged(wantConnected ? "start" : "stop"); + if (wantConnected) this._runStart(); + else this._runStop(); } - async _runPrivileged(action) { + // these two are basically copy-pasted on purpose. didn't want a shared + // helper taking an action param β€” the argv going into pkexec needs to + // just sit here as a literal array so it's obvious what runs. pkexec + // only, no sudo fallback + + async _runStart() { 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); + await runCommand( + ["pkexec", PRIVILEGED_TWINGATE_BIN, "start"], + this._cancellable, + ); } catch (e) { if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; } finally { this._busy = false; } + // daemon takes a sec to catch up, so poll again a couple times + this._scheduleQuickRefreshes(); + } - // The daemon may take a moment to reflect the new state; poll a - // couple more times shortly after the action. + async _runStop() { + if (this._busy) return; + this._busy = true; + try { + await runCommand( + ["pkexec", PRIVILEGED_TWINGATE_BIN, "stop"], + this._cancellable, + ); + } catch (e) { + if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + } finally { + this._busy = false; + } this._scheduleQuickRefreshes(); } @@ -605,14 +609,11 @@ const Indicator = GObject.registerClass( else this._setResources([]); } - /** - * Sends a native notification when the connection category (connected / - * disconnected / error) actually changes β€” never on every poll tick, and - * never for the very first status check after startup (that's just - * discovering the current state, not a change). Transitional tokens - * (connecting/authenticating) have no category and are ignored, but - * don't reset the baseline, so a later real change is still caught. - */ + // only notifies on an actual change (connected/disconnected/error) β€” + // not every poll, and not the first check on startup either. + // connecting/authenticating get skipped since they don't have a + // category, but they don't touch the baseline, so we still catch the + // real change once it happens _maybeNotifyStateChange(token) { const category = NOTIFY_CATEGORY[token] ?? null; if ( diff --git a/prefs.js b/prefs.js index 41b1366..71924ee 100644 --- a/prefs.js +++ b/prefs.js @@ -27,15 +27,12 @@ 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.', - }); - settings.bind('use-pkexec', pkexecRow, 'active', Gio.SettingsBindFlags.DEFAULT); - group.add(pkexecRow); - const binaryRow = new Adw.EntryRow({ title: 'Twingate binary path', + tooltip_text: + 'Used for status/resources/account checks only. Connect ' + + 'and disconnect always run /usr/bin/twingate directly via ' + + 'pkexec, regardless of this setting.', }); settings.bind('twingate-binary', binaryRow, 'text', Gio.SettingsBindFlags.DEFAULT); group.add(binaryRow); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 4bafb32236f455607bac2e998fb821fabc999033..a533f2bebfa0764e4908f6a2621668092d011c88 100644 GIT binary patch literal 624 zcmZuuF-rq682xIk4hl|%B8V=+9oW0Y(Or=aPNJJ>8{1&a9hX=w(xTw#KX7(;5L}(y z1Q#bKXGizy`!2oK!NB9ad*7E!-k0rtkttyujaLnP;B_!lnBel@qgxu+LfWAl=sg&f zD$x(tN#5Kb4Nie1>qH%soXW^{rPh(WaMDe$qCItCx- zgOBsU$J4=2w)nxvMAjru9jlgb{4js7?eC30@*D8tH{d%WqR;q$ocz6tJR7`Wng<_5 Ezq)#cd;kCd literal 668 zcmZWnJxc>Y6nrs8#RwJ>5F2fTJMiv0D|>@Puo5jd$(pR5d*SvH6C^=FI}01Z!d8EQ zV4FW+X_d;_MzBwucb6}5VQz-G_p$qScMnCVgpO#qR>4P!wq`~qINk15Q^S8Y<>?06 zhmpw<4X_^K-~v5@pMW5&xD_3oOV?DT@4K=YNo`c1jT>DkT@jIVYC=mt>0vD-Ox%M@ z%>tPr=D>_@yOZT!abIds6TvUQXaBv!v8;3OyWk$M_Y>Z+&zk*ja2Cba)<2HfXT1!6 z1H27UOXUgG9d5ofIK2o!^9k=BN;Y??-*GMblJppxK9Y+Xtc#2OIa;TDciPd zf$#rInvUN=_*DG3ZBc0vw)iLZSvR2vW4tM37(3pN)S~FiauSy!t7t>=J@MV~R&ZoW h1M`3mB6XrlBH}0DOj6^#y1`>OlTBGdUQ!1s^b39dk1YTI diff --git a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml index 919f32b..e64fc94 100644 --- a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml +++ b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml @@ -7,15 +7,10 @@ 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. - "/usr/bin/twingate" Twingate binary path - Path to the twingate CLI executable. + Path to the twingate CLI executable, used for read-only status/resources/account checks only. Connect/disconnect always run /usr/bin/twingate directly via pkexec, independent of this setting. true diff --git a/stylesheet.css b/stylesheet.css index 5e7907c..fc8b953 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -26,7 +26,7 @@ } .twingate-status-dot { - /* Exact pixel size is set in JS (DOT_SIZE) β€” keep this in sync if changed. */ + /* size comes from DOT_SIZE in JS, keep these matching if you change it */ border-radius: 3.5px; } From a9e3b7421f2fa87602f15657892a2c795c8ec3f8 Mon Sep 17 00:00:00 2001 From: urhend Date: Fri, 31 Jul 2026 23:03:24 +0200 Subject: [PATCH 12/15] Remove twingate-binary setting; drop unnecessary optional chaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hardcode TWINGATE_BIN for all calls, not just the privileged ones. The setting only ever covered status/resources/account (read-only), while start/stop was already fixed β€” that asymmetry meant changing the path would silently break connect/disconnect while looking like it worked for everything else. Every official Twingate installer puts the binary at /usr/bin/twingate anyway, so the config knob bought little and confused more. - Removed the setting from the schema and prefs.js UI accordingly. - Dropped e.matches?.() and _onOpenPreferences?.() β€” both are guaranteed non-null at the call site, so the optional chaining was just noise (per EGO's extension best-practices guide, and matching the reviewer's literal '?.(' comment on the v3 submission). Left this._indicator?.destroy() in disable() alone β€” that one really can be null if enable() didn't complete. - README updated to match (Configuration table, Known limitations). --- README.md | 8 ++-- extension.js | 40 +++++++++--------- prefs.js | 10 ----- schemas/gschemas.compiled | Bin 624 -> 540 bytes ...hell.extensions.twingate-panel.gschema.xml | 5 --- 5 files changed, 25 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2847b5c..d5a4b33 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,6 @@ 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. | -| Twingate binary path | `/usr/bin/twingate` | Path to the Twingate CLI binary, used for read-only status/resources/account checks only. Connect/disconnect always run `/usr/bin/twingate` directly via `pkexec`, independent of this setting β€” see [Known limitations](#known-limitations). | | 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. | @@ -193,9 +192,10 @@ twingate-panel/ - `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), - and the binary path used for that call is hardcoded rather than - configurable, for the same reason. + [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 3ac7a2e..c67295b 100644 --- a/extension.js +++ b/extension.js @@ -20,10 +20,12 @@ import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; Gio._promisify(Gio.Subprocess.prototype, "communicate_utf8_async"); -// Most settings live in GSettings (schemas/ + prefs.js), but not this one. -// start/stop run as root through pkexec, and GSettings can be rewritten by -// any local process, so this path has to stay hardcoded. -const PRIVILEGED_TWINGATE_BIN = "/usr/bin/twingate"; +// not a setting, on purpose. start/stop run as root through pkexec, and +// GSettings can be rewritten by any local process, so this can't come +// from there. Twingate's own installers always put it here, so the +// read-only calls (status/resources/account list) use the same fixed +// path instead of adding a config knob that mostly just adds confusion. +const TWINGATE_BIN = "/usr/bin/twingate"; // panel icon never changes, connection status is just a dot next to the // network name in the dropdown @@ -103,7 +105,7 @@ async function runCommand(argv, cancellable) { stderr: stderr ?? "", }; } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; return { success: false, stdout: "", stderr: e.message ?? String(e) }; } } @@ -129,7 +131,7 @@ async function pingAddress(address, cancellable) { ); return result.success; } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; return false; } } @@ -249,11 +251,10 @@ const Indicator = GObject.registerClass( } async _loadNetworkName() { - const binary = this._settings.get_string("twingate-binary"); let result; try { result = await runCommand( - [binary, "-d", "account", "list"], + [TWINGATE_BIN, "-d", "account", "list"], this._cancellable, ); } catch (e) { @@ -356,7 +357,7 @@ const Indicator = GObject.registerClass( }); settingsButton.connect("clicked", () => { this.menu.close(); - this._onOpenPreferences?.(); + this._onOpenPreferences(); }); headerBox.add_child(settingsButton); @@ -540,11 +541,11 @@ const Indicator = GObject.registerClass( this._busy = true; try { await runCommand( - ["pkexec", PRIVILEGED_TWINGATE_BIN, "start"], + ["pkexec", TWINGATE_BIN, "start"], this._cancellable, ); } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; } finally { this._busy = false; } @@ -557,11 +558,11 @@ const Indicator = GObject.registerClass( this._busy = true; try { await runCommand( - ["pkexec", PRIVILEGED_TWINGATE_BIN, "stop"], + ["pkexec", TWINGATE_BIN, "stop"], this._cancellable, ); } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; } finally { this._busy = false; } @@ -584,12 +585,14 @@ const Indicator = GObject.registerClass( } async _refresh() { - const binary = this._settings.get_string("twingate-binary"); let result; try { - result = await runCommand([binary, "-d", "status"], this._cancellable); + result = await runCommand( + [TWINGATE_BIN, "-d", "status"], + this._cancellable, + ); } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; this._setState("unknown"); this._maybeNotifyStateChange("unknown"); return; @@ -637,15 +640,14 @@ const Indicator = GObject.registerClass( } async _refreshResources() { - const binary = this._settings.get_string("twingate-binary"); let result; try { result = await runCommand( - [binary, "-d", "resources"], + [TWINGATE_BIN, "-d", "resources"], this._cancellable, ); } catch (e) { - if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; this._setResources([]); return; } diff --git a/prefs.js b/prefs.js index 71924ee..3440e00 100644 --- a/prefs.js +++ b/prefs.js @@ -27,16 +27,6 @@ export default class TwingatePanelPreferences extends ExtensionPreferences { settings.bind('poll-interval-seconds', pollRow, 'value', Gio.SettingsBindFlags.DEFAULT); group.add(pollRow); - const binaryRow = new Adw.EntryRow({ - title: 'Twingate binary path', - tooltip_text: - 'Used for status/resources/account checks only. Connect ' + - 'and disconnect always run /usr/bin/twingate directly via ' + - 'pkexec, regardless of this setting.', - }); - settings.bind('twingate-binary', binaryRow, 'text', Gio.SettingsBindFlags.DEFAULT); - group.add(binaryRow); - const notificationsRow = new Adw.SwitchRow({ title: 'Enable notifications', subtitle: 'Show a notification when the connection state changes (connected/disconnected/error).', diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a533f2bebfa0764e4908f6a2621668092d011c88..445a16eb2ff226b89210da8dafba84f7ae50eca9 100644 GIT binary patch delta 283 zcmeysGKXb?jid=90|Tol1H*qHkYHi}0v`qtEi*CHxSkEfhXSBF1`uWjVpbsDzErgu zM1u?vWhi3^0MemA{P5q|I*>Ss4H7Q^(%nG(x^c@tkT{6V0~DVDq~`*$*XN=hkT{49 zQojR8Uk2g>wRO7OAXkAntU!qyK$;EcGMH;YY>;a?fHcU(lm9VFaf95bk)&CWnU_8} ylTmqc3==;Oh@+dDmzb23nld?_(S}v8Ah9H4@&!gcF^~xi3?PGmhJ(EZ;sXFmvp6vT literal 624 zcmZuuF-rq682xIk4hl|%B8V=+9oW0Y(Or=aPNJJ>8{1&a9hX=w(xTw#KX7(;5L}(y z1Q#bKXGizy`!2oK!NB9ad*7E!-k0rtkttyujaLnP;B_!lnBel@qgxu+LfWAl=sg&f zD$x(tN#5Kb4Nie1>qH%soXW^{rPh(WaMDe$qCItCx- zgOBsU$J4=2w)nxvMAjru9jlgb{4js7?eC30@*D8tH{d%WqR;q$ocz6tJR7`Wng<_5 Ezq)#cd;kCd diff --git a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml index e64fc94..6de5fa5 100644 --- a/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml +++ b/schemas/org.gnome.shell.extensions.twingate-panel.gschema.xml @@ -7,11 +7,6 @@ Poll interval How often, in seconds, status and resources are refreshed. - - "/usr/bin/twingate" - Twingate binary path - Path to the twingate CLI executable, used for read-only status/resources/account checks only. Connect/disconnect always run /usr/bin/twingate directly via pkexec, independent of this setting. - true Enable connection notifications From a17822b966b546d6dd5fdd535f3c57c73267b44e Mon Sep 17 00:00:00 2001 From: urhend Date: Fri, 31 Jul 2026 23:52:23 +0200 Subject: [PATCH 13/15] Harden Indicator teardown against late-resolving async callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a subprocess call is still mid-flight when the extension gets disabled, cancel() only stops what's actually in flight β€” a call whose GTask already finished but hasn't been dispatched yet ignores it and resumes anyway, right after destroy() has torn the actors down. Adds a _destroyed flag set first thing in destroy(), checked after every await/then that touches an actor, GSettings, or schedules a new timer. Also makes runCommand() rethrow CANCELLED from proc.init() the same way it already does from communicate_utf8_async() β€” previously a cancelled spawn was reported back as a normal failure. Couldn't reproduce the original rejection under repeated disable/enable cycling (turns out disable/enable doesn't reload the module in a live session, so those cycles were re-running old code the whole time) β€” this is a hardening pass based on the actual GTask/mainloop mechanics, not a confirmed-then-fixed repro. --- extension.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/extension.js b/extension.js index c67295b..156b048 100644 --- a/extension.js +++ b/extension.js @@ -91,6 +91,15 @@ async function runCommand(argv, cancellable) { }); proc.init(cancellable); } catch (e) { + // this one can be a plain JS error (bad argv, spawn failure), so the + // instanceof has to come first β€” only a real GError has .matches. and + // a cancel has to propagate, otherwise the caller reads it as "twingate + // failed" and starts repainting a menu that's already being torn down + if ( + e instanceof GLib.Error && + e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) + ) + throw e; return { success: false, stdout: "", stderr: e.message ?? String(e) }; } @@ -201,6 +210,11 @@ const Indicator = GObject.registerClass( this._searchToggledOn = false; this._pingStatus = new Map(); this._previousCategory = null; + // flipped in destroy(). every async method below re-checks it after an + // await, because cancel() only stops calls that are still in flight β€” + // one that finished a moment earlier already has its continuation + // queued, and it'll happily resume after the actors are gone + this._destroyed = false; const iconPath = GLib.build_filenamev([ extensionPath, @@ -261,6 +275,8 @@ const Indicator = GObject.registerClass( return; } + if (this._destroyed) return; + const name = parseNetworkName(result.stdout); if (name) this._networkNameLabel.text = name; } @@ -308,6 +324,7 @@ const Indicator = GObject.registerClass( 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); @@ -550,6 +567,7 @@ const Indicator = GObject.registerClass( this._busy = false; } // daemon takes a sec to catch up, so poll again a couple times + if (this._destroyed) return; this._scheduleQuickRefreshes(); } @@ -566,6 +584,7 @@ const Indicator = GObject.registerClass( } finally { this._busy = false; } + if (this._destroyed) return; this._scheduleQuickRefreshes(); } @@ -593,11 +612,14 @@ const Indicator = GObject.registerClass( ); } 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"); @@ -648,14 +670,21 @@ const Indicator = GObject.registerClass( ); } 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() { + // first thing, before anything else gets torn down β€” whatever's still + // sitting on an await has to see this and bail instead of resuming + this._destroyed = true; + if (this._timeoutId) { GLib.Source.remove(this._timeoutId); this._timeoutId = null; From 8f37179c5d7d888d423cfcb5d5b8af6ed10cc9dc Mon Sep 17 00:00:00 2001 From: urhend Date: Sat, 1 Aug 2026 18:38:11 +0200 Subject: [PATCH 14/15] Show a placeholder when connected with zero authorized resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously an empty resource list looked identical whether you were disconnected or just had nothing authorized β€” the Resources section and its header simply vanished either way. Now it stays up and shows "No resources available" when _connected is true but the list is empty, so a genuinely-empty account doesn't look indistinguishable from being logged out. --- extension.js | 25 ++++++++++++++++++++++++- stylesheet.css | 7 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/extension.js b/extension.js index 156b048..b623b14 100644 --- a/extension.js +++ b/extension.js @@ -210,6 +210,9 @@ const Indicator = GObject.registerClass( this._searchToggledOn = false; this._pingStatus = new Map(); this._previousCategory = null; + // needed to tell "no resources because we're disconnected" apart from + // "connected but genuinely zero authorized resources" in _renderResourceItems + this._connected = false; // flipped in destroy(). every async method below re-checks it after an // await, because cancel() only stops calls that are still in flight β€” // one that finished a moment earlier already has its continuation @@ -451,6 +454,7 @@ const Indicator = GObject.registerClass( _setState(token) { const info = statusInfoFor(token); + this._connected = info.connected; this._statusDot.set_style(`background-color: ${info.dotColor};`); @@ -493,7 +497,11 @@ const Indicator = GObject.registerClass( this._resourcesSection.removeAll(); const hasResources = this._allResources.length > 0; - this._resourcesHeader.visible = hasResources; + // connected with nothing authorized is a real state, not a loading + // gap β€” keep the header up so the empty-list message has somewhere + // to sit, instead of the section just vanishing + const showEmptyPlaceholder = this._connected && !hasResources; + this._resourcesHeader.visible = hasResources || showEmptyPlaceholder; this._resourceSearchItem.visible = hasResources && this._searchToggledOn; this._resourceGeneration++; @@ -507,6 +515,21 @@ const Indicator = GObject.registerClass( ) : this._allResources; + if (showEmptyPlaceholder) { + const emptyItem = new PopupMenu.PopupBaseMenuItem({ + reactive: false, + can_focus: false, + }); + 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, diff --git a/stylesheet.css b/stylesheet.css index fc8b953..383ad51 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -61,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; +} From 1e5c0a81b75bdfa9c986b4dd61080cfb2918dd74 Mon Sep 17 00:00:00 2001 From: urhend Date: Sat, 1 Aug 2026 18:50:24 +0200 Subject: [PATCH 15/15] Strip explanatory comments from extension.js Keeping only the license header. The reasoning they carried lives in a local, gitignored WYJASNIENIE.md instead, so it's still around for reference without shipping as inline commentary. --- .gitignore | 1 + extension.js | 67 +--------------------------------------------------- 2 files changed, 2 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index 3aa864b..24552bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ TODO.md +WYJASNIENIE.md diff --git a/extension.js b/extension.js index b623b14..69612a5 100644 --- a/extension.js +++ b/extension.js @@ -5,7 +5,7 @@ // 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"; @@ -20,15 +20,8 @@ import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; Gio._promisify(Gio.Subprocess.prototype, "communicate_utf8_async"); -// not a setting, on purpose. start/stop run as root through pkexec, and -// GSettings can be rewritten by any local process, so this can't come -// from there. Twingate's own installers always put it here, so the -// read-only calls (status/resources/account list) use the same fixed -// path instead of adding a config knob that mostly just adds confusion. const TWINGATE_BIN = "/usr/bin/twingate"; -// panel icon never changes, connection status is just a dot next to the -// network name in the dropdown const STATUS_INFO = { "not-running": { label: "Not running", connected: false, dotColor: "#9a9996" }, offline: { label: "Offline", connected: false, dotColor: "#9a9996" }, @@ -42,9 +35,6 @@ const STATUS_INFO = { unknown: { label: "Unknown", connected: false, dotColor: "#9a9996" }, }; -// just for deciding when to notify. connecting/authenticating aren't in -// here on purpose since they're transitional β€” no notification, and they -// don't touch the baseline either const NOTIFY_CATEGORY = { online: "connected", "not-running": "disconnected", @@ -58,13 +48,8 @@ const PING_CLASSES = [ "twingate-dot-unreachable", ]; const DOT_SIZE = 7; -// nobody's watching the dot when the menu's closed, so slow way down β€” -// this many times slower than whatever the user set const BACKGROUND_POLL_MULTIPLIER = 6; -// empty St.Widgets default to 0x0, so CSS width/height alone doesn't -// really hold up inside a BoxLayout β€” it just stretches instead of -// staying a circle. set_size() + turning off expand actually works though function createDot(extraClass) { const dot = new St.Widget({ style_class: `twingate-status-dot ${extraClass}`, @@ -80,8 +65,6 @@ function statusInfoFor(token) { return STATUS_INFO[token] ?? STATUS_INFO.unknown; } -// runs argv without blocking the shell. rethrows if it gets cancelled, -// otherwise just stuffs whatever went wrong into {success: false, ...} async function runCommand(argv, cancellable) { let proc; try { @@ -91,10 +74,6 @@ async function runCommand(argv, cancellable) { }); proc.init(cancellable); } catch (e) { - // this one can be a plain JS error (bad argv, spawn failure), so the - // instanceof has to come first β€” only a real GError has .matches. and - // a cancel has to propagate, otherwise the caller reads it as "twingate - // failed" and starts repainting a menu that's already being torn down if ( e instanceof GLib.Error && e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) @@ -125,13 +104,10 @@ function parseStatusToken(stdout) { .split("\n") .map((l) => l.trim()) .find((l) => l.length > 0) ?? ""; - // being paranoid here β€” lowercase it, grab the first token, in case some - // CLI version prints extra stuff before the actual status const token = firstLine.toLowerCase().split(/\s+/)[0] ?? ""; return token in STATUS_INFO ? token : "unknown"; } -// true if one ping got a reply within a second, that's the whole check async function pingAddress(address, cancellable) { try { const result = await runCommand( @@ -145,10 +121,6 @@ async function pingAddress(address, cancellable) { } } -// `twingate account list` spits out a tab-separated table: -// EMAIL NETWORK NETWORK URL -// works fine while disconnected too. we just grab the network name off -// the first account, most people only have the one anyway function parseNetworkName(stdout) { const lines = (stdout ?? "") .split("\n") @@ -162,17 +134,8 @@ function parseNetworkName(stdout) { return null; } -// anything that doesn't match this gets dropped down in parseResources(). -// mostly here so a leading '-' can't get read as a ping flag instead of -// an actual target const VALID_ADDRESS_RE = /^[A-Za-z0-9][A-Za-z0-9.:_-]*$/; -// `twingate resources` prints a tab-separated table: -// RESOURCE NAME ADDRESS ALIAS AUTH STATUS -// Home Assistant192.168.0.211- -// we only care about name + address, header row and extra columns get -// dropped. when disconnected it just prints a sentence instead of a -// table, so this returns nothing β€” which is exactly what we want function parseResources(stdout) { const lines = (stdout ?? "") .split("\n") @@ -210,13 +173,7 @@ const Indicator = GObject.registerClass( this._searchToggledOn = false; this._pingStatus = new Map(); this._previousCategory = null; - // needed to tell "no resources because we're disconnected" apart from - // "connected but genuinely zero authorized resources" in _renderResourceItems this._connected = false; - // flipped in destroy(). every async method below re-checks it after an - // await, because cancel() only stops calls that are still in flight β€” - // one that finished a moment earlier already has its continuation - // queued, and it'll happily resume after the actors are gone this._destroyed = false; const iconPath = GLib.build_filenamev([ @@ -319,9 +276,6 @@ const Indicator = GObject.registerClass( ); } - // pings everything we know about, filtered or not, so _pingStatus - // stays current even for rows the search box is hiding. only touches - // a dot actor if it's actually on screen right now _pingAllResources(generation) { for (const { address } of this._allResources) { if (generation !== this._resourceGeneration) return; @@ -490,16 +444,10 @@ const Indicator = GObject.registerClass( if (visible) this._searchEntry.grab_key_focus(); } - // rebuilds what's visible from _allResources + _resourceFilter. dot - // colors come from _pingStatus instead of resetting to pending every - // time, otherwise typing in the search box would flash everything gray _renderResourceItems() { this._resourcesSection.removeAll(); const hasResources = this._allResources.length > 0; - // connected with nothing authorized is a real state, not a loading - // gap β€” keep the header up so the empty-list message has somewhere - // to sit, instead of the section just vanishing const showEmptyPlaceholder = this._connected && !hasResources; this._resourcesHeader.visible = hasResources || showEmptyPlaceholder; this._resourceSearchItem.visible = hasResources && this._searchToggledOn; @@ -571,11 +519,6 @@ const Indicator = GObject.registerClass( else this._runStop(); } - // these two are basically copy-pasted on purpose. didn't want a shared - // helper taking an action param β€” the argv going into pkexec needs to - // just sit here as a literal array so it's obvious what runs. pkexec - // only, no sudo fallback - async _runStart() { if (this._busy) return; this._busy = true; @@ -589,7 +532,6 @@ const Indicator = GObject.registerClass( } finally { this._busy = false; } - // daemon takes a sec to catch up, so poll again a couple times if (this._destroyed) return; this._scheduleQuickRefreshes(); } @@ -657,11 +599,6 @@ const Indicator = GObject.registerClass( else this._setResources([]); } - // only notifies on an actual change (connected/disconnected/error) β€” - // not every poll, and not the first check on startup either. - // connecting/authenticating get skipped since they don't have a - // category, but they don't touch the baseline, so we still catch the - // real change once it happens _maybeNotifyStateChange(token) { const category = NOTIFY_CATEGORY[token] ?? null; if ( @@ -704,8 +641,6 @@ const Indicator = GObject.registerClass( } destroy() { - // first thing, before anything else gets torn down β€” whatever's still - // sitting on an await has to see this and bail instead of resuming this._destroyed = true; if (this._timeoutId) {