Add optional outbound connectivity probe with three-state status dot

This commit is contained in:
av
2026-07-18 15:47:39 +03:00
parent 74aa0ee384
commit fc83c57382
4 changed files with 195 additions and 38 deletions
+137 -35
View File
@@ -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;