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.