From ec673b92f325d6bc2b83a503dc316c08e9854556 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 18 Jul 2026 16:29:20 +0300 Subject: [PATCH] Harden connectivity probe, add checking state, support URLTest/Fallback groups, MIT license --- LICENSE | 21 ++++ README.md | 30 +++--- lib/clashApi.js | 14 ++- lib/indicator.js | 246 +++++++++++++++++++++++++++++------------------ prefs.js | 2 +- 5 files changed, 207 insertions(+), 106 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e73777 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index a114089..6f4ec2c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/lib/clashApi.js b/lib/clashApi.js index e3a2184..ed97725 100644 --- a/lib/clashApi.js +++ b/lib/clashApi.js @@ -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) diff --git a/lib/indicator.js b/lib/indicator.js index b0d7e04..5e18fb7 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -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(); } }); diff --git a/prefs.js b/prefs.js index af050cd..9ef101b 100644 --- a/prefs.js +++ b/prefs.js @@ -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({