diff --git a/README.md b/README.md index 6f4ec2c..8c4dd83 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,24 @@ GNOME, используя встроенный **Clash API**. - монохромный индикатор-кружок (цвет берётся из цвета текста панели, поэтому индикатор одинаково читается на тёмной и светлой теме): - - **залитый круг** — сервис работает и через активный outbound есть связь; + - **яркий залитый круг** — сервис работает и через активный outbound есть связь; + - **тусклый залитый круг** — сервис работает, связь ещё проверяется; - **яркое сплошное кольцо** — сервис работает, но связи через outbound нет; - - **тусклое сплошное кольцо** — сервис работает, связь ещё проверяется; - **пунктирное тусклое кольцо** — сервис (Clash API) недоступен / подключение. Различие «есть связь / нет связи / проверяется» доступно только при включённой опции «Connectivity check» (см. ниже) — иначе кружок просто залит, когда API - отвечает, и пунктирный, когда нет; + отвечает, и пунктирный, когда нет. Связь измеряется только для узла группы, + показанной в панели; реальный трафик может идти по другим правилам и группам — + см. разбивку соединений в меню; - имя активного outbound (текущий узел группы); -- текущую скорость, например `↓2.4M ↑120k`. +- текущую скорость, например `↓2.4M ↑120K`. **В выпадающем меню (подробности):** -- `sing-box · Active / Unreachable`; +- строка статуса: `sing-box · Active` / `Unreachable`, а при включённой проверке + связности также `Active (checking…)` (связь проверяется) и `No connectivity` + (связи через outbound нет); - полная скорость (`↓ 2.4 MB/s ↑ 120 KB/s`); - каждая группа outbound'ов отдельным подменю. **Selector**-группы переключаемы — выбор узла меняет активный outbound; **URLTest/Fallback**-группы показываются только @@ -94,10 +98,12 @@ API sing-box 1.13.13. ## Файлы - `extension.js` — точка входа, добавляет кнопку в панель. +- `metadata.json` — метаданные расширения (UUID, версии GNOME Shell). - `lib/indicator.js` — виджет панели, меню, цикл опроса, отрисовка. - `lib/clashApi.js` — асинхронный клиент Clash API на libsoup 3. - `lib/format.js` — форматирование байтов и скорости. - `prefs.js` — настройки на Adwaita. +- `stylesheet.css` — стили индикатора и меню. - `schemas/` — схема GSettings. ## Лицензия diff --git a/lib/indicator.js b/lib/indicator.js index 5e18fb7..fa76df8 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -35,7 +35,9 @@ class SingBoxIndicator extends PanelMenu.Button { this._delays = new Map(); // node name -> ms number | 'timeout' this._probing = new Set(); // nodes with a latency probe in flight this._apiUrl = ''; + this._apiSecret = ''; this._polling = false; + this._pollAgain = false; // a refresh arrived while polling this._pinging = false; // connectivity probe in flight this._settingsDebounceId = 0; this._cancellable = new Gio.Cancellable(); @@ -59,10 +61,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; - // 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. + // filled bright disc — service up and outbound has connectivity; + // filled dim disc — service up, connectivity not yet measured; + // bright ring — service up but no connectivity through the outbound; + // dashed dim ring — service (Clash API) unreachable / connecting. this._dot = new St.DrawingArea({ style_class: 'sbx-dot', y_align: Clutter.ActorAlign.CENTER, @@ -149,8 +151,15 @@ class SingBoxIndicator extends PanelMenu.Button { _reconfigure() { // Latencies measured against one instance are meaningless for another. - if (this._settings.get_string('api-url') !== this._apiUrl) + // If the endpoint (URL or secret) changed, drop cached latencies and + // rotate the cancellable so stale old-endpoint responses are discarded. + const urlChanged = this._settings.get_string('api-url') !== this._apiUrl; + const secretChanged = this._settings.get_string('api-secret') !== this._apiSecret; + if (urlChanged || secretChanged) { this._delays.clear(); + this._cancellable.cancel(); + this._cancellable = new Gio.Cancellable(); + } this._applySettings(); this._restartPolling(); this._restartConnectivityCheck(); @@ -164,9 +173,10 @@ class SingBoxIndicator extends PanelMenu.Button { _applySettings() { this._apiUrl = this._settings.get_string('api-url'); + this._apiSecret = this._settings.get_string('api-secret'); this._api.setEndpoint( this._apiUrl, - this._settings.get_string('api-secret') + this._apiSecret ); this._interval = this._settings.get_int('refresh-interval'); this._showOutbound = this._settings.get_boolean('show-outbound'); @@ -209,8 +219,10 @@ class SingBoxIndicator extends PanelMenu.Button { } async _poll() { - if (this._polling) + if (this._polling) { + this._pollAgain = true; return; + } this._polling = true; const c = this._cancellable; try { @@ -236,14 +248,24 @@ class SingBoxIndicator extends PanelMenu.Button { 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); + // a rendering bug isn't misreported as "API unreachable". A render + // failure is logged but must not skip status/connectivity handling. + try { + this._updateFromData(conn, proxies); + } catch (e) { + logError(e, 'sing-box-status: render failed'); + } this._updateStatus(); // Resolve unknown connectivity promptly (startup, recovery, switch). if (this._checkConnectivity && this._connectivity === 'unknown') this._pingConnectivity(); } finally { this._polling = false; + // A refresh arrived mid-poll and was swallowed by the guard: run it. + if (this._pollAgain) { + this._pollAgain = false; + this._poll(); + } } } @@ -304,11 +326,17 @@ class SingBoxIndicator extends PanelMenu.Button { } 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) + // An HTTP error is a real outbound-failure verdict; a transport/API + // error (unreachable, aborted) is not — don't record it as a timeout. + const httpFail = typeof e?.message === 'string' && e.message.startsWith('HTTP'); + if (httpFail) { + this._connectivity = 'down'; + this._connectivityDelay = null; this._delays.set(node, 'timeout'); + } else { + this._connectivity = 'unknown'; + this._connectivityDelay = null; + } this._updateStatus(); } finally { this._pinging = false; @@ -393,15 +421,26 @@ class SingBoxIndicator extends PanelMenu.Button { if (!p || !Array.isArray(p.all)) continue; if (p.type === 'Selector') - groups.push({ name, now: p.now, all: p.all, switchable: true }); + groups.push({ name, now: p.now, all: p.all, type: p.type, switchable: true }); else if (p.type === 'URLTest' || p.type === 'Fallback') - groups.push({ name, now: p.now, all: p.all, switchable: false }); + groups.push({ name, now: p.now, all: p.all, type: p.type, switchable: false }); } + // Switchable (Selector) groups first; sort is stable within each class. + groups.sort((a, b) => (b.switchable ? 1 : 0) - (a.switchable ? 1 : 0)); + // Panel outbound label: chosen group's current node. const panelGroup = this._pickPanelGroup(groups); this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : ''; - this._activeOutbound = panelGroup ? panelGroup.now ?? null : null; + const newActive = panelGroup ? panelGroup.now ?? null : null; + // If the active outbound changed outside _selectOutbound (e.g. a + // URLTest re-pick or an external switch), the last connectivity verdict + // is stale — reset it so the poll-side re-ping re-probes the new node. + if (newActive !== this._activeOutbound && this._checkConnectivity && newActive) { + this._connectivity = 'unknown'; + this._connectivityDelay = null; + } + this._activeOutbound = newActive; // Rebuild the group section only when the structure changes. const signature = JSON.stringify(groups.map(g => [g.name, g.all, g.switchable])); @@ -415,7 +454,7 @@ class SingBoxIndicator extends PanelMenu.Button { const stored = this._selectorItems.get(g.name); if (!stored) continue; - stored.subMenu.label.text = `${g.name}: ${g.now ?? '—'}`; + stored.subMenu.label.text = this._groupTitle(g); for (const [member, entry] of stored.members) { entry.setOrnament(member === g.now ? PopupMenu.Ornament.DOT @@ -442,7 +481,7 @@ class SingBoxIndicator extends PanelMenu.Button { } for (const g of groups) { - const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${g.name}: ${g.now ?? '—'}`, true); + const subMenu = new PopupMenu.PopupSubMenuMenuItem(this._groupTitle(g), true); subMenu.icon.icon_name = 'network-workgroup-symbolic'; // Manual latency probe for all nodes in this group. Override @@ -479,6 +518,13 @@ class SingBoxIndicator extends PanelMenu.Button { this._connItem.label.text = `${_('Connections')}: —`; } + // Submenu title: read-only groups also show their type (URLTest/Fallback). + _groupTitle(g) { + return g.switchable + ? `${g.name}: ${g.now ?? '—'}` + : `${g.name} [${g.type}]: ${g.now ?? '—'}`; + } + _pickPanelGroup(groups) { if (groups.length === 0) return null; @@ -524,6 +570,9 @@ class SingBoxIndicator extends PanelMenu.Button { if (!stored) return; for (const [member, item] of stored.members) { + // Skip nodes already being probed (manual re-click or overlap). + if (this._probing.has(member)) + continue; this._probing.add(member); item.label.text = `${member} · …`; this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable) @@ -581,7 +630,7 @@ 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') { + } else if (this._checkConnectivity && this._activeOutbound && this._connectivity === 'unknown') { // Service up, connectivity not measured yet — don't claim it works. dot = 'checking'; this._statusItem.label.text = _('sing-box · Active (checking…)'); @@ -631,8 +680,9 @@ class SingBoxIndicator extends PanelMenu.Button { cr.stroke(); break; case 'checking': - // Dim solid ring: service up, connectivity not measured yet. + // Dim filled disc: service up, connectivity not measured yet. cr.setSourceRGBA(r, g, b, a * 0.5); + cr.fillPreserve(); cr.stroke(); break; default: diff --git a/prefs.js b/prefs.js index 9ef101b..8227ef2 100644 --- a/prefs.js +++ b/prefs.js @@ -37,7 +37,7 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences { dispGroup.add(this._switchRow(settings, 'show-speed', _('Show speed'), _('Show upload/download speed in the top bar.'))); dispGroup.add(this._entryRow(settings, 'selector-group', _('Panel selector group'), - _('Selector group shown in the panel. Empty = first one found.'))); + _('Group shown in the panel; a URLTest/Fallback name is also accepted. Empty = first switchable (Selector) group, otherwise the first group.'))); const intervalRow = new Adw.SpinRow({ title: _('Refresh interval'), @@ -50,7 +50,7 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences { // --- 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.'), + description: _('Actively probe the active outbound to tell “up but no internet” from “up and reachable”. Adds connectivity-based status-dot states (four in total).'), }); page.add(connCheckGroup); @@ -68,9 +68,8 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences { 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); + const connUrlRow = this._entryRow(settings, 'connectivity-url', _('Probe / test URL'), + _('Used by both the connectivity check and the manual Test latency action.')); connCheckGroup.add(connUrlRow); } diff --git a/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml index 139bdb2..96756ab 100644 --- a/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml +++ b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml @@ -26,7 +26,7 @@ '' Outbound selector group shown in the panel - Name of the Selector group whose current node is shown in the top bar. Empty means auto-detect the first Selector. + Name of the group whose current node is shown in the top bar. Accepts any group name (Selector, URLTest or Fallback). Empty means auto-pick the first switchable (Selector) group, or the first group if none is switchable. true @@ -52,7 +52,7 @@ 'https://www.gstatic.com/generate_204' Connectivity probe URL - Target URL used by the active-outbound latency probe. + Target URL used both by the active-outbound connectivity probe and by the manual "Test latency" action.