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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Anton Vakhrushev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -11
View File
@@ -7,25 +7,29 @@ GNOME, используя встроенный **Clash API**.
**В панели (компактно):**
- монохромный индикатор-кружок с тремя состояниями (цвет берётся из цвета текста
панели, поэтому индикатор одинаково читается на тёмной и светлой теме):
- монохромный индикатор-кружок (цвет берётся из цвета текста панели, поэтому
индикатор одинаково читается на тёмной и светлой теме):
- **залитый круг** — сервис работает и через активный outbound есть связь;
- **сплошное кольцо** — сервис работает, но связи через outbound нет;
- **пунктирное кольцо (тусклое)** — сервис (Clash API) недоступен.
- **яркое сплошное кольцо** — сервис работает, но связи через outbound нет;
- **тусклое сплошное кольцо** — сервис работает, связь ещё проверяется;
- **пунктирное тусклое кольцо** — сервис (Clash API) недоступен / подключение.
Различие «есть связь / нет связи» доступно только при включённой опции
«Connectivity check» (см. ниже) — иначе кружок просто залит, когда API отвечает;
- имя активного outbound (текущий узел Selector-группы);
Различие «есть связь / нет связи / проверяется» доступно только при включённой
опции «Connectivity check» (см. ниже) — иначе кружок просто залит, когда API
отвечает, и пунктирный, когда нет;
- имя активного outbound (текущий узел группы);
- текущую скорость, например `↓2.4M ↑120k`.
**В выпадающем меню (подробности):**
- `sing-box · Active / Unreachable`;
- полная скорость (`↓ 2.4 MB/s ↑ 120 KB/s`);
- каждая Selector-группа отдельным подменю — выбор узла **переключает активный
outbound** (текущий помечен точкой, рядом — последняя измеренная задержка, если она
есть). Пункт **Test latency** в подменю запускает ручной замер задержки всех узлов
группы и обновляет значения на месте (меню при этом не закрывается);
- каждая группа outbound'ов отдельным подменю. **Selector**-группы переключаемы —
выбор узла меняет активный outbound; **URLTest/Fallback**-группы показываются только
для просмотра (узел выбирает сам sing-box). Текущий узел помечен точкой, рядом —
последняя измеренная задержка, если она есть. Пункт **Test latency** в подменю
запускает ручной замер задержки всех узлов группы по адресу `connectivity-url` и
обновляет значения на месте (меню при этом не закрывается);
- число активных соединений с разбивкой по outbound;
- **Open dashboard** (MetaCubeXD или ваш дашборд Clash) и **Settings**.
@@ -95,3 +99,7 @@ API sing-box 1.13.13.
- `lib/format.js` — форматирование байтов и скорости.
- `prefs.js` — настройки на Adwaita.
- `schemas/` — схема GSettings.
## Лицензия
MIT — см. [LICENSE](LICENSE).
+12 -2
View File
@@ -16,7 +16,9 @@ const textEncoder = new TextEncoder();
export class ClashApi {
constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) {
this._session = new Soup.Session();
this._session.timeout = 5;
// Above the 5 s /delay probe budget so libsoup's inactivity timeout
// doesn't abort a slow-but-alive latency measurement client-side.
this._session.timeout = 10;
this.setEndpoint(url, secret);
}
@@ -25,8 +27,16 @@ export class ClashApi {
this._secret = secret || '';
}
// Abort all in-flight requests and drop idle keep-alive connections.
destroy() {
this._session.abort();
}
_message(method, path, body) {
const msg = Soup.Message.new(method, `${this._baseUrl}${path}`);
const uri = `${this._baseUrl}${path}`;
const msg = Soup.Message.new(method, uri);
if (!msg)
throw new Error(`Invalid API URL: ${uri}`);
const headers = msg.get_request_headers();
headers.append('Accept', 'application/json');
if (this._secret)
+120 -58
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();
@@ -58,8 +60,9 @@ 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.
// 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,14 +214,12 @@ class SingBoxIndicator extends PanelMenu.Button {
this._polling = true;
const c = this._cancellable;
try {
const [conn, proxies] = await Promise.all([
let conn, proxies;
try {
[conn, proxies] = await Promise.all([
this._api.getConnections(c),
this._api.getProxies(c),
]);
if (c.is_cancelled())
return;
this._updateFromData(conn, proxies);
this._apiUp = true;
} catch (e) {
if (c.is_cancelled())
return;
@@ -216,11 +229,22 @@ class SingBoxIndicator extends PanelMenu.Button {
this._connectivityDelay = null;
this._prevSample = null;
this._clearMenuData();
this._updateStatus();
return;
}
if (c.is_cancelled())
return;
this._apiUp = true;
// 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');
}
} catch (e) {
if (c.is_cancelled())
return;
this._connectivity = 'down';
this._connectivityDelay = null;
this._delays.set(node, 'timeout');
}
this._updateStatus();
} catch (e) {
if (c.is_cancelled() || node !== this._activeOutbound)
return;
// A transport/API failure is not an outbound-connectivity verdict.
this._connectivity = this._apiUp ? 'down' : 'unknown';
this._connectivityDelay = null;
if (this._apiUp)
this._delays.set(node, 'timeout');
this._updateStatus();
} finally {
this._pinging = false;
}
}
// ---- 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,6 +603,7 @@ class SingBoxIndicator extends PanelMenu.Button {
_drawDot(area) {
const cr = area.get_context();
try {
const [w, h] = area.get_surface_size();
const color = area.get_theme_node().get_foreground_color();
const r = color.red / 255;
@@ -558,38 +616,37 @@ 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);
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);
// Bright solid ring: service up but no connectivity.
cr.setSourceRGBA(r, g, b, a);
cr.stroke();
break;
case 'down':
// Dashed dim ring: service (Clash API) unreachable.
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.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;
}
} finally {
cr.$dispose();
}
}
_openDashboard() {
const url = this._settings.get_string('dashboard-url');
@@ -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();
}
});
+1 -1
View File
@@ -56,7 +56,7 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
const checkRow = this._switchRow(settings, 'check-connectivity',
_('Probe active outbound'),
_('Periodically measure latency through the current outbound.'));
_('Periodically sends a background request through the current outbound to the Probe URL below (an external site by default). Off = no background traffic.'));
connCheckGroup.add(checkRow);
const connIntervalRow = new Adw.SpinRow({