From fc83c57382aef910c387ef9bf220717ca3f39ebd Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 18 Jul 2026 15:47:39 +0300 Subject: [PATCH] Add optional outbound connectivity probe with three-state status dot --- README.md | 19 +- lib/indicator.js | 172 ++++++++++++++---- prefs.js | 26 +++ ...ell.extensions.sing-box-status.gschema.xml | 16 ++ 4 files changed, 195 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index dce4815..a114089 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,14 @@ GNOME, используя встроенный **Clash API**. **В панели (компактно):** -- монохромный индикатор-кружок — залит, когда API доступен и прокси жив; полый - (тускнее), когда API недоступен. Цвет берётся из цвета текста панели, поэтому - индикатор одинаково читается на тёмной и светлой теме; +- монохромный индикатор-кружок с тремя состояниями (цвет берётся из цвета текста + панели, поэтому индикатор одинаково читается на тёмной и светлой теме): + - **залитый круг** — сервис работает и через активный outbound есть связь; + - **сплошное кольцо** — сервис работает, но связи через outbound нет; + - **пунктирное кольцо (тусклое)** — сервис (Clash API) недоступен. + + Различие «есть связь / нет связи» доступно только при включённой опции + «Connectivity check» (см. ниже) — иначе кружок просто залит, когда API отвечает; - имя активного outbound (текущий узел Selector-группы); - текущую скорость, например `↓2.4M ↑120k`. @@ -71,6 +76,14 @@ gnome-extensions enable "$UUID" показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**) выполняется через `GET /proxies/{name}/delay` только по нажатию. +**Проверка связности (опция).** Если включить `check-connectivity` в настройках, +расширение раз в `connectivity-interval` секунд (по умолчанию 30) делает +`GET /proxies/{активный-узел}/delay` по адресу `connectivity-url` +(по умолчанию `https://www.gstatic.com/generate_204`). Успех → кружок залит и в меню +показывается задержка (`Active · N ms`); ошибка/таймаут → сплошное кольцо и +`No connectivity`. Это единственный режим, когда расширение шлёт трафик в фоне; по +умолчанию он выключен. + Сетевой слой (авторизация, разбор ответов, переключение outbound) проверен на живом API sing-box 1.13.13. diff --git a/lib/indicator.js b/lib/indicator.js index 1f298bc..b0d7e04 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -14,12 +14,6 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j import { ClashApi } from './clashApi.js'; import { formatSpeed, formatSpeedShort } from './format.js'; -const STATE = { - CONNECTING: 'connecting', - ONLINE: 'online', - OFFLINE: 'offline', -}; - export const SingBoxIndicator = GObject.registerClass( class SingBoxIndicator extends PanelMenu.Button { _init(extension) { @@ -29,7 +23,11 @@ class SingBoxIndicator extends PanelMenu.Button { this._settings = extension.getSettings(); this._api = new ClashApi(); - this._state = STATE.CONNECTING; + 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(); @@ -47,6 +45,7 @@ class SingBoxIndicator extends PanelMenu.Button { this._applySettings(); this._startPolling(); + this._startConnectivityCheck(); } // ---- Panel widget ------------------------------------------------- @@ -57,9 +56,10 @@ class SingBoxIndicator extends PanelMenu.Button { y_align: Clutter.ActorAlign.CENTER, }); - // Monochrome status dot: a ring in the panel's text color, filled when - // the proxy is alive, hollow when it is not. Adapts to light/dark theme. - this._dotFilled = false; + // Monochrome status dot in the panel's text color (adapts to theme): + // filled disc — service up and outbound has connectivity; + // solid ring — service up but no connectivity through the outbound; + // dashed ring — service (Clash API) unreachable. this._dot = new St.DrawingArea({ style_class: 'sbx-dot', y_align: Clutter.ActorAlign.CENTER, @@ -138,7 +138,13 @@ class SingBoxIndicator extends PanelMenu.Button { this._delays.clear(); this._applySettings(); this._restartPolling(); + this._restartConnectivityCheck(); + if (!this._checkConnectivity) { + this._connectivity = 'unknown'; + this._connectivityDelay = null; + } this._poll(); + this._updateStatus(); } _applySettings() { @@ -151,6 +157,9 @@ class SingBoxIndicator extends PanelMenu.Button { 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; } @@ -197,14 +206,82 @@ class SingBoxIndicator extends PanelMenu.Button { if (c.is_cancelled()) return; this._updateFromData(conn, proxies); - this._setState(STATE.ONLINE); + this._apiUp = true; } catch (e) { if (c.is_cancelled()) return; - this._setState(STATE.OFFLINE, e); + // Service (Clash API) unreachable: connectivity is moot. + this._apiUp = false; + this._connectivity = 'unknown'; + this._connectivityDelay = null; + this._prevSample = null; + this._clearMenuData(); } finally { this._polling = false; } + if (!c.is_cancelled()) + this._updateStatus(); + } + + // ---- 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) + return; + const node = this._activeOutbound; + if (!node) + return; + const c = this._cancellable; + try { + const res = await this._api.getDelay( + node, { url: this._connectivityUrl, timeout: 5000 }, c); + if (c.is_cancelled()) + 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'); + } + } catch (e) { + if (c.is_cancelled()) + return; + this._connectivity = 'down'; + this._connectivityDelay = null; + this._delays.set(node, 'timeout'); + } + this._updateStatus(); } // ---- Data → UI ---------------------------------------------------- @@ -286,6 +363,7 @@ class SingBoxIndicator extends PanelMenu.Button { // Panel outbound label: chosen group's current node. const panelGroup = this._pickPanelGroup(selectors); this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : ''; + this._activeOutbound = panelGroup ? panelGroup.now ?? null : null; // Rebuild the selector section only when the structure changes. const signature = JSON.stringify(selectors.map(s => [s.name, s.all])); @@ -438,29 +516,32 @@ class SingBoxIndicator extends PanelMenu.Button { // ---- State / visuals --------------------------------------------- - _setState(state, error) { - this._state = state; - - switch (state) { - case STATE.ONLINE: - this._dotFilled = true; - this._statusItem.label.text = _('sing-box · Active'); - break; - case STATE.OFFLINE: - this._dotFilled = false; + // 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'); - this._prevSample = null; - this._clearMenuData(); - break; - default: - this._dotFilled = false; - this._statusItem.label.text = _('sing-box · Connecting…'); - break; + } 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(); } - this._dot.queue_repaint(); } _drawDot(area) { @@ -477,15 +558,35 @@ class SingBoxIndicator extends PanelMenu.Button { const radius = Math.min(w, h) / 2 - 2; cr.setLineWidth(1.5); - cr.arc(cx, cy, radius, 0, 2 * Math.PI); - if (this._dotFilled) { + + switch (this._dotState) { + case 'ok': + // Filled disc: service up and outbound reachable. + cr.arc(cx, cy, radius, 0, 2 * Math.PI); cr.setSourceRGBA(r, g, b, a); cr.fillPreserve(); cr.stroke(); - } else { - // Hollow ring, slightly dimmed so "off" reads as inactive. - cr.setSourceRGBA(r, g, b, a * 0.6); + break; + case 'nocon': + // Solid hollow ring: service up but no connectivity. + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.setSourceRGBA(r, g, b, a); cr.stroke(); + break; + case 'down': + // Dashed dim ring: service (Clash API) unreachable. + cr.setDash([2, 2], 0); + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.setSourceRGBA(r, g, b, a * 0.5); + cr.stroke(); + cr.setDash([], 0); + break; + default: + // Connecting: dim solid ring. + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.setSourceRGBA(r, g, b, a * 0.5); + cr.stroke(); + break; } cr.$dispose(); } @@ -504,6 +605,7 @@ class SingBoxIndicator extends PanelMenu.Button { GLib.Source.remove(this._timeoutId); this._timeoutId = 0; } + this._stopConnectivityCheck(); if (this._settingsChangedId) { this._settings.disconnect(this._settingsChangedId); this._settingsChangedId = 0; diff --git a/prefs.js b/prefs.js index 4ca867d..af050cd 100644 --- a/prefs.js +++ b/prefs.js @@ -46,6 +46,32 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences { }); settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT); dispGroup.add(intervalRow); + + // --- Connectivity --------------------------------------------- + const connCheckGroup = new Adw.PreferencesGroup({ + title: _('Connectivity check'), + description: _('Actively probe the active outbound to tell “up but no internet” from “up and reachable”. Adds a third status-dot state.'), + }); + page.add(connCheckGroup); + + const checkRow = this._switchRow(settings, 'check-connectivity', + _('Probe active outbound'), + _('Periodically measure latency through the current outbound.')); + connCheckGroup.add(checkRow); + + const connIntervalRow = new Adw.SpinRow({ + title: _('Probe interval'), + subtitle: _('Seconds between outbound probes.'), + adjustment: new Gtk.Adjustment({ lower: 5, upper: 600, step_increment: 5 }), + }); + settings.bind('connectivity-interval', connIntervalRow, 'value', Gio.SettingsBindFlags.DEFAULT); + settings.bind('check-connectivity', connIntervalRow, 'sensitive', Gio.SettingsBindFlags.GET); + connCheckGroup.add(connIntervalRow); + + const connUrlRow = this._entryRow(settings, 'connectivity-url', _('Probe URL'), + _('Target URL for the latency probe (a 204/no-content endpoint is ideal).')); + settings.bind('check-connectivity', connUrlRow, 'sensitive', Gio.SettingsBindFlags.GET); + connCheckGroup.add(connUrlRow); } _entryRow(settings, key, title, subtitle) { diff --git a/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml index 16561d5..139bdb2 100644 --- a/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml +++ b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml @@ -38,5 +38,21 @@ Show speed in the panel Show upload/download speed in the top bar. + + false + Periodically probe the active outbound + When enabled, the extension periodically measures latency through the active outbound to distinguish "service up but no connectivity" from "service up and reachable". + + + 30 + + Connectivity probe interval (seconds) + How often to probe the active outbound when connectivity checking is enabled. + + + 'https://www.gstatic.com/generate_204' + Connectivity probe URL + Target URL used by the active-outbound latency probe. +