Fix connectivity staleness, distinct checking dot, read-only groups, poll/probe robustness

This commit is contained in:
av
2026-07-18 16:45:36 +03:00
parent ec673b92f3
commit 7b08568d2c
4 changed files with 87 additions and 32 deletions
+11 -5
View File
@@ -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.
## Лицензия
+70 -20
View File
@@ -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:
+4 -5
View File
@@ -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);
}
@@ -26,7 +26,7 @@
<key name="selector-group" type="s">
<default>''</default>
<summary>Outbound selector group shown in the panel</summary>
<description>Name of the Selector group whose current node is shown in the top bar. Empty means auto-detect the first Selector.</description>
<description>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.</description>
</key>
<key name="show-outbound" type="b">
<default>true</default>
@@ -52,7 +52,7 @@
<key name="connectivity-url" type="s">
<default>'https://www.gstatic.com/generate_204'</default>
<summary>Connectivity probe URL</summary>
<description>Target URL used by the active-outbound latency probe.</description>
<description>Target URL used both by the active-outbound connectivity probe and by the manual "Test latency" action.</description>
</key>
</schema>
</schemalist>