Fix connectivity staleness, distinct checking dot, read-only groups, poll/probe robustness
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user