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.
+