// Panel button that polls the Clash API and renders sing-box status. import GObject from 'gi://GObject'; import St from 'gi://St'; import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; 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 { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js'; import { ClashApi } from './clashApi.js'; import { formatSpeed, formatSpeedShort } from './format.js'; export const SingBoxIndicator = GObject.registerClass( class SingBoxIndicator extends PanelMenu.Button { _init(extension) { super._init(0.0, 'sing-box Status', false); this._extension = extension; this._settings = extension.getSettings(); this._api = new ClashApi(); this._apiUp = null; // null = connecting, true/false = reachable this._connectivity = 'unknown'; // 'unknown' | 'ok' | 'down' this._connectivityDelay = null; // ms of last successful outbound ping this._activeOutbound = null; // node to probe for connectivity this._dotState = 'connecting'; // 'connecting'|'down'|'nocon'|'ok' this._prevSample = null; // { down, up, t } for speed diffing this._selectorSignature = null; this._selectorItems = new Map(); this._proxiesMap = {}; this._delays = new Map(); // node name -> ms number | 'timeout' this._probing = new Set(); // nodes with a latency probe in flight this._apiUrl = ''; this._polling = false; this._pinging = false; // connectivity probe in flight this._settingsDebounceId = 0; this._cancellable = new Gio.Cancellable(); this._buildPanel(); this._buildMenu(); this._settingsChangedId = this._settings.connect('changed', () => this._onSettingsChanged()); this._applySettings(); this._startPolling(); this._startConnectivityCheck(); } // ---- Panel widget ------------------------------------------------- _buildPanel() { const box = new St.BoxLayout({ style_class: 'panel-status-menu-box sbx-panel-box', y_align: Clutter.ActorAlign.CENTER, }); // Monochrome status dot in the panel's text color (adapts to theme): // filled disc — service up and outbound has connectivity; // bright ring — service up but no connectivity through the outbound; // dim ring — service up, connectivity not yet measured; // dashed dim ring — service (Clash API) unreachable / connecting. this._dot = new St.DrawingArea({ style_class: 'sbx-dot', y_align: Clutter.ActorAlign.CENTER, }); this._dot.set_width(16); this._dot.set_height(16); this._dot.connect('repaint', area => this._drawDot(area)); box.add_child(this._dot); this._outboundLabel = new St.Label({ style_class: 'sbx-outbound-label', y_align: Clutter.ActorAlign.CENTER, text: '', }); box.add_child(this._outboundLabel); this._speedLabel = new St.Label({ style_class: 'sbx-speed-label', y_align: Clutter.ActorAlign.CENTER, text: '', }); box.add_child(this._speedLabel); this.add_child(box); } // ---- Menu --------------------------------------------------------- _buildMenu() { this._statusItem = new PopupMenu.PopupMenuItem(_('sing-box'), { reactive: false, can_focus: false, }); this._statusItem.add_style_class_name('sbx-status-item'); this.menu.addMenuItem(this._statusItem); this._speedItem = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false, }); this._speedItem.add_style_class_name('sbx-speed-item'); this.menu.addMenuItem(this._speedItem); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); // Selector groups (switchable outbounds) are rebuilt into this section. this._selectorsSection = new PopupMenu.PopupMenuSection(); this.menu.addMenuItem(this._selectorsSection); // Connections, grouped by outbound. this._connItem = new PopupMenu.PopupSubMenuMenuItem(_('Connections: —')); this.menu.addMenuItem(this._connItem); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); const dashboardItem = new PopupMenu.PopupMenuItem(_('Open dashboard')); dashboardItem.connect('activate', () => this._openDashboard()); this.menu.addMenuItem(dashboardItem); const prefsItem = new PopupMenu.PopupMenuItem(_('Settings')); prefsItem.connect('activate', () => this._extension.openPreferences()); this.menu.addMenuItem(prefsItem); // Refresh immediately when the user opens the menu. this.menu.connect('open-state-changed', (_menu, open) => { if (open) this._poll(); }); } // ---- Settings ----------------------------------------------------- // Debounce: prefs entry rows emit "changed" per keystroke, and a multi-key // reset fires once per key — collapse the burst into a single apply. _onSettingsChanged() { if (this._settingsDebounceId) GLib.Source.remove(this._settingsDebounceId); this._settingsDebounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { this._settingsDebounceId = 0; this._reconfigure(); return GLib.SOURCE_REMOVE; }); } _reconfigure() { // Latencies measured against one instance are meaningless for another. if (this._settings.get_string('api-url') !== this._apiUrl) this._delays.clear(); this._applySettings(); this._restartPolling(); this._restartConnectivityCheck(); if (!this._checkConnectivity) { this._connectivity = 'unknown'; this._connectivityDelay = null; } this._poll(); this._updateStatus(); } _applySettings() { this._apiUrl = this._settings.get_string('api-url'); this._api.setEndpoint( this._apiUrl, this._settings.get_string('api-secret') ); this._interval = this._settings.get_int('refresh-interval'); this._showOutbound = this._settings.get_boolean('show-outbound'); this._showSpeed = this._settings.get_boolean('show-speed'); this._selectorPref = this._settings.get_string('selector-group'); this._checkConnectivity = this._settings.get_boolean('check-connectivity'); this._connectivityInterval = this._settings.get_int('connectivity-interval'); this._connectivityUrl = this._settings.get_string('connectivity-url'); this._outboundLabel.visible = this._showOutbound; this._speedLabel.visible = this._showSpeed; } // ---- Polling ------------------------------------------------------ _startPolling() { this._poll(); this._timeoutId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, Math.max(1, this._interval), () => { this._poll(); return GLib.SOURCE_CONTINUE; } ); } _restartPolling() { if (this._timeoutId) { GLib.Source.remove(this._timeoutId); this._timeoutId = 0; } this._timeoutId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, Math.max(1, this._interval), () => { this._poll(); return GLib.SOURCE_CONTINUE; } ); } async _poll() { if (this._polling) return; this._polling = true; const c = this._cancellable; try { let conn, proxies; try { [conn, proxies] = await Promise.all([ this._api.getConnections(c), this._api.getProxies(c), ]); } catch (e) { if (c.is_cancelled()) return; // Service (Clash API) unreachable: connectivity is moot. this._apiUp = false; this._connectivity = 'unknown'; this._connectivityDelay = null; this._prevSample = null; this._clearMenuData(); this._updateStatus(); return; } if (c.is_cancelled()) return; this._apiUp = true; // Note: _updateFromData is UI code — keep it out of the API try so // a rendering bug isn't misreported as "API unreachable". this._updateFromData(conn, proxies); this._updateStatus(); // Resolve unknown connectivity promptly (startup, recovery, switch). if (this._checkConnectivity && this._connectivity === 'unknown') this._pingConnectivity(); } finally { this._polling = false; } } // ---- Connectivity check (optional active probe) ------------------- _startConnectivityCheck() { if (!this._checkConnectivity) return; this._connectivityTimeoutId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, Math.max(5, this._connectivityInterval), () => { this._pingConnectivity(); return GLib.SOURCE_CONTINUE; } ); this._pingConnectivity(); } _stopConnectivityCheck() { if (this._connectivityTimeoutId) { GLib.Source.remove(this._connectivityTimeoutId); this._connectivityTimeoutId = 0; } } _restartConnectivityCheck() { this._stopConnectivityCheck(); this._startConnectivityCheck(); } // Probe the active outbound to tell "up but no internet" from "up and OK". async _pingConnectivity() { if (!this._checkConnectivity || this._apiUp === false || this._pinging) return; const node = this._activeOutbound; if (!node) return; this._pinging = true; const c = this._cancellable; try { const res = await this._api.getDelay( node, { url: this._connectivityUrl || undefined, timeout: 5000 }, c); // Discard if cancelled or the active outbound changed mid-flight. if (c.is_cancelled() || node !== this._activeOutbound) return; const d = Number(res?.delay); if (Number.isFinite(d) && d > 0) { this._connectivity = 'ok'; this._connectivityDelay = d; this._delays.set(node, d); } else { this._connectivity = 'down'; this._connectivityDelay = null; this._delays.set(node, 'timeout'); } this._updateStatus(); } catch (e) { if (c.is_cancelled() || node !== this._activeOutbound) return; // A transport/API failure is not an outbound-connectivity verdict. this._connectivity = this._apiUp ? 'down' : 'unknown'; this._connectivityDelay = null; if (this._apiUp) this._delays.set(node, 'timeout'); this._updateStatus(); } finally { this._pinging = false; } } // ---- Data → UI ---------------------------------------------------- _updateFromData(conn, proxies) { this._updateSpeed(conn); this._updateConnections(conn); this._updateSelectors(proxies); } _updateSpeed(conn) { const down = Number(conn?.downloadTotal) || 0; const up = Number(conn?.uploadTotal) || 0; const now = GLib.get_monotonic_time() / 1e6; // seconds let dSpeed = 0; let uSpeed = 0; if (this._prevSample) { const dt = now - this._prevSample.t; if (dt > 0) { dSpeed = Math.max(0, (down - this._prevSample.down) / dt); uSpeed = Math.max(0, (up - this._prevSample.up) / dt); } } this._prevSample = { down, up, t: now }; this._speedLabel.text = `↓${formatSpeedShort(dSpeed)} ↑${formatSpeedShort(uSpeed)}`; this._speedItem.label.text = `↓ ${formatSpeed(dSpeed)} ↑ ${formatSpeed(uSpeed)}`; } _updateConnections(conn) { const list = Array.isArray(conn?.connections) ? conn.connections : []; this._connItem.label.text = `${_('Connections')}: ${list.length}`; // Group by the actual outbound node (first element of the chain). const counts = new Map(); for (const item of list) { const chain = Array.isArray(item?.chains) ? item.chains : []; const node = chain.length ? chain[0] : _('(direct)'); counts.set(node, (counts.get(node) || 0) + 1); } this._connItem.menu.removeAll(); if (counts.size === 0) { const empty = new PopupMenu.PopupMenuItem(_('No active connections'), { reactive: false, can_focus: false, }); this._connItem.menu.addMenuItem(empty); return; } const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); for (const [node, count] of sorted) { const row = new PopupMenu.PopupMenuItem(`${node}`, { reactive: false, can_focus: false, }); const badge = new St.Label({ text: String(count), style_class: 'sbx-badge', x_align: Clutter.ActorAlign.END, x_expand: true, }); row.add_child(badge); this._connItem.menu.addMenuItem(row); } } _updateSelectors(proxies) { const map = proxies?.proxies ?? {}; this._proxiesMap = map; // Outbound groups the extension understands. Selector is switchable // (PUT); URLTest/Fallback are shown read-only (sing-box picks the node). const groups = []; for (const name of Object.keys(map)) { const p = map[name]; if (!p || !Array.isArray(p.all)) continue; if (p.type === 'Selector') groups.push({ name, now: p.now, all: p.all, switchable: true }); else if (p.type === 'URLTest' || p.type === 'Fallback') groups.push({ name, now: p.now, all: p.all, switchable: false }); } // Panel outbound label: chosen group's current node. const panelGroup = this._pickPanelGroup(groups); this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : ''; this._activeOutbound = panelGroup ? panelGroup.now ?? null : null; // Rebuild the group section only when the structure changes. const signature = JSON.stringify(groups.map(g => [g.name, g.all, g.switchable])); if (signature !== this._selectorSignature) { this._rebuildSelectors(groups); this._selectorSignature = signature; } // Update current selection + latencies every poll. for (const g of groups) { const stored = this._selectorItems.get(g.name); if (!stored) continue; stored.subMenu.label.text = `${g.name}: ${g.now ?? '—'}`; for (const [member, entry] of stored.members) { entry.setOrnament(member === g.now ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); // Don't overwrite the "…" placeholder while a probe is running. if (this._probing.has(member)) continue; entry.label.text = this._memberLabelText(member, map[member]); } } } _rebuildSelectors(groups) { this._selectorsSection.removeAll(); this._selectorItems.clear(); if (groups.length === 0) { const none = new PopupMenu.PopupMenuItem(_('No outbound groups'), { reactive: false, can_focus: false, }); this._selectorsSection.addMenuItem(none); return; } for (const g of groups) { const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${g.name}: ${g.now ?? '—'}`, true); subMenu.icon.icon_name = 'network-workgroup-symbolic'; // Manual latency probe for all nodes in this group. Override // activate() so clicking it runs the probe without closing the menu. const testItem = new PopupMenu.PopupImageMenuItem(_('Test latency'), 'view-refresh-symbolic'); testItem.add_style_class_name('sbx-test-item'); testItem.activate = () => this._testLatencies(g.name); subMenu.menu.addMenuItem(testItem); const members = new Map(); for (const member of g.all) { // Switchable groups: click a node to select it. Read-only groups // (URLTest/Fallback): show nodes but don't try to PUT-switch. const item = g.switchable ? new PopupMenu.PopupMenuItem(member) : new PopupMenu.PopupMenuItem(member, { reactive: false, can_focus: false }); if (g.switchable) item.connect('activate', () => this._selectOutbound(g.name, member)); subMenu.menu.addMenuItem(item); members.set(member, item); } this._selectorsSection.addMenuItem(subMenu); this._selectorItems.set(g.name, { subMenu, members }); } } // Drop menu content that would otherwise stay clickable while offline. _clearMenuData() { this._selectorsSection.removeAll(); this._selectorItems.clear(); this._selectorSignature = null; this._probing.clear(); this._connItem.menu.removeAll(); this._connItem.label.text = `${_('Connections')}: —`; } _pickPanelGroup(groups) { if (groups.length === 0) return null; if (this._selectorPref) { const match = groups.find(g => g.name === this._selectorPref); if (match) return match; } // Default to a switchable (Selector) group; else the first group. return groups.find(g => g.switchable) ?? groups[0]; } // Latest known latency: a manual probe result wins over sing-box's own // history. Returns a number (ms), the string 'timeout', or null if unknown. _delayFor(name, proxy) { if (this._delays.has(name)) return this._delays.get(name); return this._latencyOf(proxy); } _memberLabelText(name, proxy) { const d = this._delayFor(name, proxy); if (d == null) return name; if (d === 'timeout') return `${name} · ${_('timeout')}`; return `${name} · ${d} ms`; } _latencyOf(proxy) { const history = proxy?.history; if (Array.isArray(history) && history.length) { const last = history[history.length - 1]; if (last && typeof last.delay === 'number' && last.delay > 0) return last.delay; } return null; } // Probe every node in a Selector group and update its latency labels. _testLatencies(groupName) { const stored = this._selectorItems.get(groupName); if (!stored) return; for (const [member, item] of stored.members) { this._probing.add(member); item.label.text = `${member} · …`; this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable) .then(res => { const d = Number(res?.delay); this._delays.set(member, Number.isFinite(d) && d > 0 ? d : 'timeout'); }) .catch(() => { this._delays.set(member, 'timeout'); }) .finally(() => { this._probing.delete(member); if (this._cancellable.is_cancelled()) return; // Re-resolve the row: the menu may have been rebuilt while // the probe was in flight, disposing the captured actor. const cur = this._selectorItems.get(groupName)?.members.get(member); if (cur) cur.label.text = this._memberLabelText(member, this._proxiesMap?.[member]); }); } } async _selectOutbound(group, name) { try { await this._api.selectProxy(group, name, this._cancellable); if (this._cancellable.is_cancelled()) return; // The outbound changed: the last connectivity verdict is stale. // Reset it; the poll below refreshes the active node and re-probes. if (this._checkConnectivity) { this._connectivity = 'unknown'; this._connectivityDelay = null; } this._poll(); } catch (e) { if (this._cancellable.is_cancelled()) return; Main.notifyError(_('sing-box'), `${_('Failed to switch outbound')}: ${e.message}`); logError(e, 'sing-box-status: selectProxy failed'); } } // ---- State / visuals --------------------------------------------- // Derive the dot state and status text from API + connectivity state. _updateStatus() { let dot; if (this._apiUp === null) { dot = 'connecting'; this._statusItem.label.text = _('sing-box · Connecting…'); } else if (!this._apiUp) { dot = 'down'; this._statusItem.label.text = _('sing-box · Unreachable'); this._outboundLabel.text = ''; this._speedLabel.text = ''; this._speedItem.label.text = _('API unreachable — check URL and secret in Settings'); } else if (this._checkConnectivity && this._connectivity === 'unknown') { // Service up, connectivity not measured yet — don't claim it works. dot = 'checking'; this._statusItem.label.text = _('sing-box · Active (checking…)'); } else if (this._checkConnectivity && this._connectivity === 'down') { dot = 'nocon'; this._statusItem.label.text = _('sing-box · No connectivity'); } else { dot = 'ok'; const ms = this._checkConnectivity && this._connectivityDelay ? ` · ${this._connectivityDelay} ms` : ''; this._statusItem.label.text = `${_('sing-box · Active')}${ms}`; } if (dot !== this._dotState) { this._dotState = dot; this._dot.queue_repaint(); } } _drawDot(area) { const cr = area.get_context(); try { const [w, h] = area.get_surface_size(); const color = area.get_theme_node().get_foreground_color(); const r = color.red / 255; const g = color.green / 255; const b = color.blue / 255; const a = color.alpha / 255; const cx = w / 2; const cy = h / 2; const radius = Math.min(w, h) / 2 - 2; cr.setLineWidth(1.5); cr.arc(cx, cy, radius, 0, 2 * Math.PI); switch (this._dotState) { case 'ok': // Filled disc: service up and outbound reachable. cr.setSourceRGBA(r, g, b, a); cr.fillPreserve(); cr.stroke(); break; case 'nocon': // Bright solid ring: service up but no connectivity. cr.setSourceRGBA(r, g, b, a); cr.stroke(); break; case 'checking': // Dim solid ring: service up, connectivity not measured yet. cr.setSourceRGBA(r, g, b, a * 0.5); cr.stroke(); break; default: // Dashed dim ring: service unreachable ('down') or connecting. cr.setDash([2, 2], 0); cr.setSourceRGBA(r, g, b, a * 0.5); cr.stroke(); cr.setDash([], 0); break; } } finally { cr.$dispose(); } } _openDashboard() { const url = this._settings.get_string('dashboard-url'); if (url) Gio.AppInfo.launch_default_for_uri(url, null); } // ---- Teardown ----------------------------------------------------- destroy() { this._cancellable.cancel(); if (this._timeoutId) { GLib.Source.remove(this._timeoutId); this._timeoutId = 0; } this._stopConnectivityCheck(); if (this._settingsDebounceId) { GLib.Source.remove(this._settingsDebounceId); this._settingsDebounceId = 0; } if (this._settingsChangedId) { this._settings.disconnect(this._settingsChangedId); this._settingsChangedId = 0; } this._api.destroy(); super.destroy(); } });