Add optional outbound connectivity probe with three-state status dot
This commit is contained in:
@@ -7,9 +7,14 @@ GNOME, используя встроенный **Clash API**.
|
|||||||
|
|
||||||
**В панели (компактно):**
|
**В панели (компактно):**
|
||||||
|
|
||||||
- монохромный индикатор-кружок — залит, когда API доступен и прокси жив; полый
|
- монохромный индикатор-кружок с тремя состояниями (цвет берётся из цвета текста
|
||||||
(тускнее), когда API недоступен. Цвет берётся из цвета текста панели, поэтому
|
панели, поэтому индикатор одинаково читается на тёмной и светлой теме):
|
||||||
индикатор одинаково читается на тёмной и светлой теме;
|
- **залитый круг** — сервис работает и через активный outbound есть связь;
|
||||||
|
- **сплошное кольцо** — сервис работает, но связи через outbound нет;
|
||||||
|
- **пунктирное кольцо (тусклое)** — сервис (Clash API) недоступен.
|
||||||
|
|
||||||
|
Различие «есть связь / нет связи» доступно только при включённой опции
|
||||||
|
«Connectivity check» (см. ниже) — иначе кружок просто залит, когда API отвечает;
|
||||||
- имя активного outbound (текущий узел Selector-группы);
|
- имя активного outbound (текущий узел Selector-группы);
|
||||||
- текущую скорость, например `↓2.4M ↑120k`.
|
- текущую скорость, например `↓2.4M ↑120k`.
|
||||||
|
|
||||||
@@ -71,6 +76,14 @@ gnome-extensions enable "$UUID"
|
|||||||
показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**)
|
показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**)
|
||||||
выполняется через `GET /proxies/{name}/delay` только по нажатию.
|
выполняется через `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) проверен на живом
|
Сетевой слой (авторизация, разбор ответов, переключение outbound) проверен на живом
|
||||||
API sing-box 1.13.13.
|
API sing-box 1.13.13.
|
||||||
|
|
||||||
|
|||||||
+137
-35
@@ -14,12 +14,6 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j
|
|||||||
import { ClashApi } from './clashApi.js';
|
import { ClashApi } from './clashApi.js';
|
||||||
import { formatSpeed, formatSpeedShort } from './format.js';
|
import { formatSpeed, formatSpeedShort } from './format.js';
|
||||||
|
|
||||||
const STATE = {
|
|
||||||
CONNECTING: 'connecting',
|
|
||||||
ONLINE: 'online',
|
|
||||||
OFFLINE: 'offline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SingBoxIndicator = GObject.registerClass(
|
export const SingBoxIndicator = GObject.registerClass(
|
||||||
class SingBoxIndicator extends PanelMenu.Button {
|
class SingBoxIndicator extends PanelMenu.Button {
|
||||||
_init(extension) {
|
_init(extension) {
|
||||||
@@ -29,7 +23,11 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._settings = extension.getSettings();
|
this._settings = extension.getSettings();
|
||||||
this._api = new ClashApi();
|
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._prevSample = null; // { down, up, t } for speed diffing
|
||||||
this._selectorSignature = null;
|
this._selectorSignature = null;
|
||||||
this._selectorItems = new Map();
|
this._selectorItems = new Map();
|
||||||
@@ -47,6 +45,7 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._applySettings();
|
this._applySettings();
|
||||||
|
|
||||||
this._startPolling();
|
this._startPolling();
|
||||||
|
this._startConnectivityCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Panel widget -------------------------------------------------
|
// ---- Panel widget -------------------------------------------------
|
||||||
@@ -57,9 +56,10 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monochrome status dot: a ring in the panel's text color, filled when
|
// Monochrome status dot in the panel's text color (adapts to theme):
|
||||||
// the proxy is alive, hollow when it is not. Adapts to light/dark theme.
|
// filled disc — service up and outbound has connectivity;
|
||||||
this._dotFilled = false;
|
// solid ring — service up but no connectivity through the outbound;
|
||||||
|
// dashed ring — service (Clash API) unreachable.
|
||||||
this._dot = new St.DrawingArea({
|
this._dot = new St.DrawingArea({
|
||||||
style_class: 'sbx-dot',
|
style_class: 'sbx-dot',
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
@@ -138,7 +138,13 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._delays.clear();
|
this._delays.clear();
|
||||||
this._applySettings();
|
this._applySettings();
|
||||||
this._restartPolling();
|
this._restartPolling();
|
||||||
|
this._restartConnectivityCheck();
|
||||||
|
if (!this._checkConnectivity) {
|
||||||
|
this._connectivity = 'unknown';
|
||||||
|
this._connectivityDelay = null;
|
||||||
|
}
|
||||||
this._poll();
|
this._poll();
|
||||||
|
this._updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
_applySettings() {
|
_applySettings() {
|
||||||
@@ -151,6 +157,9 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._showOutbound = this._settings.get_boolean('show-outbound');
|
this._showOutbound = this._settings.get_boolean('show-outbound');
|
||||||
this._showSpeed = this._settings.get_boolean('show-speed');
|
this._showSpeed = this._settings.get_boolean('show-speed');
|
||||||
this._selectorPref = this._settings.get_string('selector-group');
|
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._outboundLabel.visible = this._showOutbound;
|
||||||
this._speedLabel.visible = this._showSpeed;
|
this._speedLabel.visible = this._showSpeed;
|
||||||
}
|
}
|
||||||
@@ -197,14 +206,82 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
if (c.is_cancelled())
|
if (c.is_cancelled())
|
||||||
return;
|
return;
|
||||||
this._updateFromData(conn, proxies);
|
this._updateFromData(conn, proxies);
|
||||||
this._setState(STATE.ONLINE);
|
this._apiUp = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (c.is_cancelled())
|
if (c.is_cancelled())
|
||||||
return;
|
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 {
|
} finally {
|
||||||
this._polling = false;
|
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 ----------------------------------------------------
|
// ---- Data → UI ----------------------------------------------------
|
||||||
@@ -286,6 +363,7 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
// Panel outbound label: chosen group's current node.
|
// Panel outbound label: chosen group's current node.
|
||||||
const panelGroup = this._pickPanelGroup(selectors);
|
const panelGroup = this._pickPanelGroup(selectors);
|
||||||
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
||||||
|
this._activeOutbound = panelGroup ? panelGroup.now ?? null : null;
|
||||||
|
|
||||||
// Rebuild the selector section only when the structure changes.
|
// Rebuild the selector section only when the structure changes.
|
||||||
const signature = JSON.stringify(selectors.map(s => [s.name, s.all]));
|
const signature = JSON.stringify(selectors.map(s => [s.name, s.all]));
|
||||||
@@ -438,29 +516,32 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
// ---- State / visuals ---------------------------------------------
|
// ---- State / visuals ---------------------------------------------
|
||||||
|
|
||||||
_setState(state, error) {
|
// Derive the dot state and status text from API + connectivity state.
|
||||||
this._state = state;
|
_updateStatus() {
|
||||||
|
let dot;
|
||||||
switch (state) {
|
if (this._apiUp === null) {
|
||||||
case STATE.ONLINE:
|
dot = 'connecting';
|
||||||
this._dotFilled = true;
|
this._statusItem.label.text = _('sing-box · Connecting…');
|
||||||
this._statusItem.label.text = _('sing-box · Active');
|
} else if (!this._apiUp) {
|
||||||
break;
|
dot = 'down';
|
||||||
case STATE.OFFLINE:
|
|
||||||
this._dotFilled = false;
|
|
||||||
this._statusItem.label.text = _('sing-box · Unreachable');
|
this._statusItem.label.text = _('sing-box · Unreachable');
|
||||||
this._outboundLabel.text = '';
|
this._outboundLabel.text = '';
|
||||||
this._speedLabel.text = '';
|
this._speedLabel.text = '';
|
||||||
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
|
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
|
||||||
this._prevSample = null;
|
} else if (this._checkConnectivity && this._connectivity === 'down') {
|
||||||
this._clearMenuData();
|
dot = 'nocon';
|
||||||
break;
|
this._statusItem.label.text = _('sing-box · No connectivity');
|
||||||
default:
|
} else {
|
||||||
this._dotFilled = false;
|
dot = 'ok';
|
||||||
this._statusItem.label.text = _('sing-box · Connecting…');
|
const ms = this._checkConnectivity && this._connectivityDelay
|
||||||
break;
|
? ` · ${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) {
|
_drawDot(area) {
|
||||||
@@ -477,15 +558,35 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
const radius = Math.min(w, h) / 2 - 2;
|
const radius = Math.min(w, h) / 2 - 2;
|
||||||
|
|
||||||
cr.setLineWidth(1.5);
|
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.setSourceRGBA(r, g, b, a);
|
||||||
cr.fillPreserve();
|
cr.fillPreserve();
|
||||||
cr.stroke();
|
cr.stroke();
|
||||||
} else {
|
break;
|
||||||
// Hollow ring, slightly dimmed so "off" reads as inactive.
|
case 'nocon':
|
||||||
cr.setSourceRGBA(r, g, b, a * 0.6);
|
// 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();
|
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();
|
cr.$dispose();
|
||||||
}
|
}
|
||||||
@@ -504,6 +605,7 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
GLib.Source.remove(this._timeoutId);
|
GLib.Source.remove(this._timeoutId);
|
||||||
this._timeoutId = 0;
|
this._timeoutId = 0;
|
||||||
}
|
}
|
||||||
|
this._stopConnectivityCheck();
|
||||||
if (this._settingsChangedId) {
|
if (this._settingsChangedId) {
|
||||||
this._settings.disconnect(this._settingsChangedId);
|
this._settings.disconnect(this._settingsChangedId);
|
||||||
this._settingsChangedId = 0;
|
this._settingsChangedId = 0;
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
|
|||||||
});
|
});
|
||||||
settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||||
dispGroup.add(intervalRow);
|
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) {
|
_entryRow(settings, key, title, subtitle) {
|
||||||
|
|||||||
@@ -38,5 +38,21 @@
|
|||||||
<summary>Show speed in the panel</summary>
|
<summary>Show speed in the panel</summary>
|
||||||
<description>Show upload/download speed in the top bar.</description>
|
<description>Show upload/download speed in the top bar.</description>
|
||||||
</key>
|
</key>
|
||||||
|
<key name="check-connectivity" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Periodically probe the active outbound</summary>
|
||||||
|
<description>When enabled, the extension periodically measures latency through the active outbound to distinguish "service up but no connectivity" from "service up and reachable".</description>
|
||||||
|
</key>
|
||||||
|
<key name="connectivity-interval" type="i">
|
||||||
|
<default>30</default>
|
||||||
|
<range min="5" max="600"/>
|
||||||
|
<summary>Connectivity probe interval (seconds)</summary>
|
||||||
|
<description>How often to probe the active outbound when connectivity checking is enabled.</description>
|
||||||
|
</key>
|
||||||
|
<key name="connectivity-url" type="s">
|
||||||
|
<default>'https://www.gstatic.com/generate_204'</default>
|
||||||
|
<summary>Connectivity probe URL</summary>
|
||||||
|
<description>Target URL used by the active-outbound latency probe.</description>
|
||||||
|
</key>
|
||||||
</schema>
|
</schema>
|
||||||
</schemalist>
|
</schemalist>
|
||||||
|
|||||||
Reference in New Issue
Block a user