Harden connectivity probe, add checking state, support URLTest/Fallback groups, MIT license

This commit is contained in:
av
2026-07-18 16:29:20 +03:00
parent fc83c57382
commit ec673b92f3
5 changed files with 207 additions and 106 deletions
+154 -92
View File
@@ -36,6 +36,8 @@ class SingBoxIndicator extends PanelMenu.Button {
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();
@@ -57,9 +59,10 @@ class SingBoxIndicator extends PanelMenu.Button {
});
// 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.
// 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,
@@ -132,7 +135,19 @@ class SingBoxIndicator extends PanelMenu.Button {
// ---- 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();
@@ -199,28 +214,37 @@ class SingBoxIndicator extends PanelMenu.Button {
this._polling = true;
const c = this._cancellable;
try {
const [conn, proxies] = await Promise.all([
this._api.getConnections(c),
this._api.getProxies(c),
]);
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._updateFromData(conn, proxies);
this._apiUp = true;
} 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();
// 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;
}
if (!c.is_cancelled())
this._updateStatus();
}
// ---- Connectivity check (optional active probe) -------------------
@@ -253,16 +277,18 @@ class SingBoxIndicator extends PanelMenu.Button {
// Probe the active outbound to tell "up but no internet" from "up and OK".
async _pingConnectivity() {
if (!this._checkConnectivity || this._apiUp === false)
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, timeout: 5000 }, c);
if (c.is_cancelled())
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) {
@@ -274,14 +300,19 @@ class SingBoxIndicator extends PanelMenu.Button {
this._connectivityDelay = null;
this._delays.set(node, 'timeout');
}
this._updateStatus();
} catch (e) {
if (c.is_cancelled())
if (c.is_cancelled() || node !== this._activeOutbound)
return;
this._connectivity = 'down';
// A transport/API failure is not an outbound-connectivity verdict.
this._connectivity = this._apiUp ? 'down' : 'unknown';
this._connectivityDelay = null;
this._delays.set(node, 'timeout');
if (this._apiUp)
this._delays.set(node, 'timeout');
this._updateStatus();
} finally {
this._pinging = false;
}
this._updateStatus();
}
// ---- Data → UI ----------------------------------------------------
@@ -353,34 +384,40 @@ class SingBoxIndicator extends PanelMenu.Button {
_updateSelectors(proxies) {
const map = proxies?.proxies ?? {};
this._proxiesMap = map;
const selectors = [];
// 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 && p.type === 'Selector' && Array.isArray(p.all))
selectors.push({ name, now: p.now, all: p.all });
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(selectors);
const panelGroup = this._pickPanelGroup(groups);
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]));
// 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(selectors);
this._rebuildSelectors(groups);
this._selectorSignature = signature;
}
// Update current selection + latencies every poll.
for (const sel of selectors) {
const stored = this._selectorItems.get(sel.name);
for (const g of groups) {
const stored = this._selectorItems.get(g.name);
if (!stored)
continue;
stored.subMenu.label.text = `${sel.name}: ${sel.now ?? '—'}`;
stored.subMenu.label.text = `${g.name}: ${g.now ?? '—'}`;
for (const [member, entry] of stored.members) {
const isCurrent = member === sel.now;
entry.setOrnament(isCurrent
entry.setOrnament(member === g.now
? PopupMenu.Ornament.DOT
: PopupMenu.Ornament.NONE);
// Don't overwrite the "…" placeholder while a probe is running.
@@ -391,12 +428,12 @@ class SingBoxIndicator extends PanelMenu.Button {
}
}
_rebuildSelectors(selectors) {
_rebuildSelectors(groups) {
this._selectorsSection.removeAll();
this._selectorItems.clear();
if (selectors.length === 0) {
const none = new PopupMenu.PopupMenuItem(_('No selector groups'), {
if (groups.length === 0) {
const none = new PopupMenu.PopupMenuItem(_('No outbound groups'), {
reactive: false,
can_focus: false,
});
@@ -404,26 +441,31 @@ class SingBoxIndicator extends PanelMenu.Button {
return;
}
for (const sel of selectors) {
const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${sel.name}: ${sel.now ?? '—'}`, true);
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(sel.name);
testItem.activate = () => this._testLatencies(g.name);
subMenu.menu.addMenuItem(testItem);
const members = new Map();
for (const member of sel.all) {
const item = new PopupMenu.PopupMenuItem(member);
item.connect('activate', () => this._selectOutbound(sel.name, member));
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(sel.name, { subMenu, members });
this._selectorItems.set(g.name, { subMenu, members });
}
}
@@ -437,15 +479,16 @@ class SingBoxIndicator extends PanelMenu.Button {
this._connItem.label.text = `${_('Connections')}: —`;
}
_pickPanelGroup(selectors) {
if (selectors.length === 0)
_pickPanelGroup(groups) {
if (groups.length === 0)
return null;
if (this._selectorPref) {
const match = selectors.find(s => s.name === this._selectorPref);
const match = groups.find(g => g.name === this._selectorPref);
if (match)
return match;
}
return selectors[0];
// 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
@@ -483,7 +526,7 @@ class SingBoxIndicator extends PanelMenu.Button {
for (const [member, item] of stored.members) {
this._probing.add(member);
item.label.text = `${member} · …`;
this._api.getDelay(member, {}, this._cancellable)
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');
@@ -507,8 +550,18 @@ class SingBoxIndicator extends PanelMenu.Button {
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');
}
@@ -528,6 +581,10 @@ class SingBoxIndicator extends PanelMenu.Button {
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');
@@ -546,49 +603,49 @@ class SingBoxIndicator extends PanelMenu.Button {
_drawDot(area) {
const cr = area.get_context();
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;
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;
const cx = w / 2;
const cy = h / 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);
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();
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;
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();
}
cr.$dispose();
}
_openDashboard() {
@@ -606,10 +663,15 @@ class SingBoxIndicator extends PanelMenu.Button {
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();
}
});