Compare commits

..
6 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 dd9a31e301 Stabilize panel speed label width
Cap formatBytesShort at four characters by rolling the mantissa up to the
next unit once it reaches 1000, and give .sbx-speed-label a fixed min-width
so the panel widget no longer shifts horizontally as speed values change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:49:59 +03:00
avandClaude Opus 4.8 11c69bf012 Center the panel menu under the button
The button was created with menuAlignment 0.0, which places the arrow at
the menu's left edge while it still points at the button's center, so the
menu opened from the middle of the widget. Use 0.5 to match the default
source alignment and center the menu under the button like other indicators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:49:15 +03:00
av 49df839254 Document fresh-clone install, update, and disable/uninstall steps in README 2026-07-18 16:48:16 +03:00
av 7b08568d2c Fix connectivity staleness, distinct checking dot, read-only groups, poll/probe robustness 2026-07-18 16:45:36 +03:00
av ec673b92f3 Harden connectivity probe, add checking state, support URLTest/Fallback groups, MIT license 2026-07-18 16:29:20 +03:00
av fc83c57382 Add optional outbound connectivity probe with three-state status dot 2026-07-18 15:47:39 +03:00
8 changed files with 497 additions and 110 deletions
+21
View File
@@ -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.
+104 -14
View File
@@ -7,20 +7,33 @@ GNOME, используя встроенный **Clash API**.
**В панели (компактно):** **В панели (компактно):**
- монохромный индикатор-кружок — залит, когда API доступен и прокси жив; полый - монохромный индикатор-кружок (цвет берётся из цвета текста панели, поэтому
(тускнее), когда API недоступен. Цвет берётся из цвета текста панели, поэтому индикатор одинаково читается на тёмной и светлой теме):
индикатор одинаково читается на тёмной и светлой теме; - **яркий залитый круг** — сервис работает и через активный outbound есть связь;
- имя активного outbound (текущий узел Selector-группы); - **тусклый залитый круг** — сервис работает, связь ещё проверяется;
- текущую скорость, например `↓2.4M ↑120k`. - **яркое сплошное кольцо** — сервис работает, но связи через outbound нет;
- **пунктирное тусклое кольцо** — сервис (Clash API) недоступен / подключение.
Различие «есть связь / нет связи / проверяется» доступно только при включённой
опции «Connectivity check» (см. ниже) — иначе кружок просто залит, когда API
отвечает, и пунктирный, когда нет. Связь измеряется только для узла группы,
показанной в панели; реальный трафик может идти по другим правилам и группам —
см. разбивку соединений в меню;
- имя активного outbound (текущий узел группы);
- текущую скорость, например `↓2.4M ↑120K`.
**В выпадающем меню (подробности):** **В выпадающем меню (подробности):**
- `sing-box · Active / Unreachable`; - строка статуса: `sing-box · Active` / `Unreachable`, а при включённой проверке
связности также `Active (checking…)` (связь проверяется) и `No connectivity`
(связи через outbound нет);
- полная скорость (`↓ 2.4 MB/s ↑ 120 KB/s`); - полная скорость (`↓ 2.4 MB/s ↑ 120 KB/s`);
- каждая Selector-группа отдельным подменю — выбор узла **переключает активный - каждая группа outbound'ов отдельным подменю. **Selector**-группы переключаемы —
outbound** (текущий помечен точкой, рядом — последняя измеренная задержка, если она выбор узла меняет активный outbound; **URLTest/Fallback**-группы показываются только
есть). Пункт **Test latency** в подменю запускает ручной замер задержки всех узлов для просмотра (узел выбирает сам sing-box). Текущий узел помечен точкой, рядом —
группы и обновляет значения на месте (меню при этом не закрывается); последняя измеренная задержка, если она есть. Пункт **Test latency** в подменю
запускает ручной замер задержки всех узлов группы по адресу `connectivity-url` и
обновляет значения на месте (меню при этом не закрывается);
- число активных соединений с разбивкой по outbound; - число активных соединений с разбивкой по outbound;
- **Open dashboard** (MetaCubeXD или ваш дашборд Clash) и **Settings**. - **Open dashboard** (MetaCubeXD или ваш дашборд Clash) и **Settings**.
@@ -41,23 +54,86 @@ GNOME, используя встроенный **Clash API**.
} }
``` ```
## Установка (из исходников) ## Установка
Полная последовательность после свежего `git clone` (в репозитории нет собранной
схемы — её нужно скомпилировать локально, шаг 3):
```sh ```sh
# 1. Клонировать репозиторий
git clone https://git.vakhrushev.me/av/sing-box-gnome-extension.git
cd sing-box-gnome-extension
# 2. Установить как пользовательское расширение. Симлинк на клон удобен тем, что
# обновления затем подтягиваются простым `git pull` (не удаляйте каталог клона).
UUID="sing-box-status@git.vakhrushev.me" UUID="sing-box-status@git.vakhrushev.me"
mkdir -p "$HOME/.local/share/gnome-shell/extensions"
ln -s "$PWD" "$HOME/.local/share/gnome-shell/extensions/$UUID" ln -s "$PWD" "$HOME/.local/share/gnome-shell/extensions/$UUID"
# 3. Скомпилировать схему GSettings (обязательно: gschemas.compiled не хранится в git)
glib-compile-schemas schemas/ glib-compile-schemas schemas/
# На Wayland: выйдите из сессии и войдите снова, чтобы shell зарегистрировал
# новое расширение, затем: # 4. Перезапустить GNOME Shell, чтобы он увидел новое расширение:
# X11 — Alt+F2, ввести «r», Enter
# Wayland — выйти из сессии и войти снова (перезапуск на лету невозможен)
# 5. Включить расширение
gnome-extensions enable "$UUID" gnome-extensions enable "$UUID"
``` ```
Откройте **Settings** из меню (или `gnome-extensions prefs "$UUID"`) и задайте: Вместо симлинка можно скопировать файлы
(`cp -r . "$HOME/.local/share/gnome-shell/extensions/$UUID"`), но тогда каждое
обновление придётся копировать заново.
Проверить состояние:
```sh
gnome-extensions info sing-box-status@git.vakhrushev.me # ENABLED / DISABLED / ошибки
```
После включения откройте **Settings** из меню расширения (или
`gnome-extensions prefs "$UUID"`) и задайте:
- **API URL** — например `http://127.0.0.1:9090` (ваш `external_controller`); - **API URL** — например `http://127.0.0.1:9090` (ваш `external_controller`);
- **Secret** — ваш `clash_api.secret`; - **Secret** — ваш `clash_api.secret`;
- **Dashboard URL** — например `http://127.0.0.1:9091`. - **Dashboard URL** — например `http://127.0.0.1:9091`.
### Обновление
```sh
cd sing-box-gnome-extension && git pull
glib-compile-schemas schemas/ # если менялась схема
# перезапуск GNOME Shell (см. шаг 4) — на Wayland через перелогин
```
## Отключение и удаление
Временно **отключить** (расширение остаётся установленным, настройки сохраняются):
```sh
gnome-extensions disable sing-box-status@git.vakhrushev.me
```
Снова **включить**:
```sh
gnome-extensions enable sing-box-status@git.vakhrushev.me
```
Отключать и включать можно и через GUI — приложение **Extensions** («Расширения»)
или **Extension Manager**.
Полностью **удалить**:
```sh
UUID="sing-box-status@git.vakhrushev.me"
gnome-extensions disable "$UUID"
rm "$HOME/.local/share/gnome-shell/extensions/$UUID" # симлинк (или каталог при копировании)
dconf reset -f /org/gnome/shell/extensions/sing-box-status/ # необязательно: сбросить настройки
```
На Wayland индикатор из панели исчезнет после перелогина.
## Как это работает ## Как это работает
Каждые *N* секунд (по умолчанию 2) расширение опрашивает две точки Clash API: Каждые *N* секунд (по умолчанию 2) расширение опрашивает две точки Clash API:
@@ -71,14 +147,28 @@ gnome-extensions enable "$UUID"
показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**) показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**)
выполняется через `GET /proxies/{name}/delay` только по нажатию. выполняется через `GET /proxies/{name}/delay` только по нажатию.
**Проверка связности (опция).** Если включить `check-connectivity` в настройках,
расширение раз в `connectivity-interval` секунд (по умолчанию 30) делает
`GET /proxies/{активный-узел}/delay` по адресу `connectivity-url`
(по умолчанию `https://www.gstatic.com/generate_204`). Успех → кружок залит и в меню
показывается задержка (`Active · N ms`); ошибка/таймаут → сплошное кольцо и
`No connectivity`. Это единственный режим, когда расширение шлёт трафик в фоне; по
умолчанию он выключен.
Сетевой слой (авторизация, разбор ответов, переключение outbound) проверен на живом Сетевой слой (авторизация, разбор ответов, переключение outbound) проверен на живом
API sing-box 1.13.13. API sing-box 1.13.13.
## Файлы ## Файлы
- `extension.js` — точка входа, добавляет кнопку в панель. - `extension.js` — точка входа, добавляет кнопку в панель.
- `metadata.json` — метаданные расширения (UUID, версии GNOME Shell).
- `lib/indicator.js` — виджет панели, меню, цикл опроса, отрисовка. - `lib/indicator.js` — виджет панели, меню, цикл опроса, отрисовка.
- `lib/clashApi.js` — асинхронный клиент Clash API на libsoup 3. - `lib/clashApi.js` — асинхронный клиент Clash API на libsoup 3.
- `lib/format.js` — форматирование байтов и скорости. - `lib/format.js` — форматирование байтов и скорости.
- `prefs.js` — настройки на Adwaita. - `prefs.js` — настройки на Adwaita.
- `stylesheet.css` — стили индикатора и меню.
- `schemas/` — схема GSettings. - `schemas/` — схема GSettings.
## Лицензия
MIT — см. [LICENSE](LICENSE).
+12 -2
View File
@@ -16,7 +16,9 @@ const textEncoder = new TextEncoder();
export class ClashApi { export class ClashApi {
constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) { constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) {
this._session = new Soup.Session(); 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); this.setEndpoint(url, secret);
} }
@@ -25,8 +27,16 @@ export class ClashApi {
this._secret = secret || ''; this._secret = secret || '';
} }
// Abort all in-flight requests and drop idle keep-alive connections.
destroy() {
this._session.abort();
}
_message(method, path, body) { _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(); const headers = msg.get_request_headers();
headers.append('Accept', 'application/json'); headers.append('Accept', 'application/json');
if (this._secret) if (this._secret)
+8 -1
View File
@@ -21,8 +21,15 @@ export function formatBytes(n) {
} }
// Compact for the panel: "2.4M", "120K", "0". // Compact for the panel: "2.4M", "120K", "0".
// Keeps the mantissa under three integer digits so a value is at most four
// characters wide ("999K", "9.9M", "1023") — the panel field has a fixed width
// and must never overflow it (see .sbx-speed-label in stylesheet.css).
export function formatBytesShort(n) { export function formatBytesShort(n) {
const [v, i] = scale(n); let [v, i] = scale(n);
if (i > 0 && v >= 1000 && i < SHORT_UNITS.length - 1) {
v /= 1024;
i += 1;
}
if (i === 0) if (i === 0)
return `${Math.round(v)}`; return `${Math.round(v)}`;
const digits = v < 10 ? 1 : 0; const digits = v < 10 ? 1 : 0;
+305 -91
View File
@@ -14,22 +14,20 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j
import { ClashApi } from './clashApi.js'; import { ClashApi } from './clashApi.js';
import { formatSpeed, formatSpeedShort } from './format.js'; import { formatSpeed, formatSpeedShort } from './format.js';
const STATE = {
CONNECTING: 'connecting',
ONLINE: 'online',
OFFLINE: 'offline',
};
export const SingBoxIndicator = GObject.registerClass( export const SingBoxIndicator = GObject.registerClass(
class SingBoxIndicator extends PanelMenu.Button { class SingBoxIndicator extends PanelMenu.Button {
_init(extension) { _init(extension) {
super._init(0.0, 'sing-box Status', false); super._init(0.5, 'sing-box Status', false);
this._extension = extension; this._extension = extension;
this._settings = extension.getSettings(); this._settings = extension.getSettings();
this._api = new ClashApi(); this._api = new ClashApi();
this._state = STATE.CONNECTING; this._apiUp = null; // null = connecting, true/false = reachable
this._connectivity = 'unknown'; // 'unknown' | 'ok' | 'down'
this._connectivityDelay = null; // ms of last successful outbound ping
this._activeOutbound = null; // node to probe for connectivity
this._dotState = 'connecting'; // 'connecting'|'down'|'nocon'|'ok'
this._prevSample = null; // { down, up, t } for speed diffing this._prevSample = null; // { down, up, t } for speed diffing
this._selectorSignature = null; this._selectorSignature = null;
this._selectorItems = new Map(); this._selectorItems = new Map();
@@ -37,7 +35,11 @@ class SingBoxIndicator extends PanelMenu.Button {
this._delays = new Map(); // node name -> ms number | 'timeout' this._delays = new Map(); // node name -> ms number | 'timeout'
this._probing = new Set(); // nodes with a latency probe in flight this._probing = new Set(); // nodes with a latency probe in flight
this._apiUrl = ''; this._apiUrl = '';
this._apiSecret = '';
this._polling = false; 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(); this._cancellable = new Gio.Cancellable();
this._buildPanel(); this._buildPanel();
@@ -47,6 +49,7 @@ class SingBoxIndicator extends PanelMenu.Button {
this._applySettings(); this._applySettings();
this._startPolling(); this._startPolling();
this._startConnectivityCheck();
} }
// ---- Panel widget ------------------------------------------------- // ---- Panel widget -------------------------------------------------
@@ -57,9 +60,11 @@ class SingBoxIndicator extends PanelMenu.Button {
y_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER,
}); });
// Monochrome status dot: a ring in the panel's text color, filled when // Monochrome status dot in the panel's text color (adapts to theme):
// the proxy is alive, hollow when it is not. Adapts to light/dark theme. // filled bright disc — service up and outbound has connectivity;
this._dotFilled = false; // 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({ this._dot = new St.DrawingArea({
style_class: 'sbx-dot', style_class: 'sbx-dot',
y_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER,
@@ -132,25 +137,54 @@ class SingBoxIndicator extends PanelMenu.Button {
// ---- Settings ----------------------------------------------------- // ---- 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() { _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. // 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._delays.clear();
this._cancellable.cancel();
this._cancellable = new Gio.Cancellable();
}
this._applySettings(); this._applySettings();
this._restartPolling(); this._restartPolling();
this._restartConnectivityCheck();
if (!this._checkConnectivity) {
this._connectivity = 'unknown';
this._connectivityDelay = null;
}
this._poll(); this._poll();
this._updateStatus();
} }
_applySettings() { _applySettings() {
this._apiUrl = this._settings.get_string('api-url'); this._apiUrl = this._settings.get_string('api-url');
this._apiSecret = this._settings.get_string('api-secret');
this._api.setEndpoint( this._api.setEndpoint(
this._apiUrl, this._apiUrl,
this._settings.get_string('api-secret') this._apiSecret
); );
this._interval = this._settings.get_int('refresh-interval'); this._interval = this._settings.get_int('refresh-interval');
this._showOutbound = this._settings.get_boolean('show-outbound'); this._showOutbound = this._settings.get_boolean('show-outbound');
this._showSpeed = this._settings.get_boolean('show-speed'); this._showSpeed = this._settings.get_boolean('show-speed');
this._selectorPref = this._settings.get_string('selector-group'); this._selectorPref = this._settings.get_string('selector-group');
this._checkConnectivity = this._settings.get_boolean('check-connectivity');
this._connectivityInterval = this._settings.get_int('connectivity-interval');
this._connectivityUrl = this._settings.get_string('connectivity-url');
this._outboundLabel.visible = this._showOutbound; this._outboundLabel.visible = this._showOutbound;
this._speedLabel.visible = this._showSpeed; this._speedLabel.visible = this._showSpeed;
} }
@@ -185,25 +219,127 @@ class SingBoxIndicator extends PanelMenu.Button {
} }
async _poll() { async _poll() {
if (this._polling) if (this._polling) {
this._pollAgain = true;
return; return;
}
this._polling = true; this._polling = true;
const c = this._cancellable; const c = this._cancellable;
try { try {
const [conn, proxies] = await Promise.all([ let conn, proxies;
this._api.getConnections(c), try {
this._api.getProxies(c), [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()) if (c.is_cancelled())
return; return;
this._updateFromData(conn, proxies); this._apiUp = true;
this._setState(STATE.ONLINE); // Note: _updateFromData is UI code — keep it out of the API try so
} catch (e) { // a rendering bug isn't misreported as "API unreachable". A render
if (c.is_cancelled()) // failure is logged but must not skip status/connectivity handling.
return; try {
this._setState(STATE.OFFLINE, e); 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 { } finally {
this._polling = false; this._polling = false;
// A refresh arrived mid-poll and was swallowed by the guard: run it.
if (this._pollAgain) {
this._pollAgain = false;
this._poll();
}
}
}
// ---- Connectivity check (optional active probe) -------------------
_startConnectivityCheck() {
if (!this._checkConnectivity)
return;
this._connectivityTimeoutId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
Math.max(5, this._connectivityInterval),
() => {
this._pingConnectivity();
return GLib.SOURCE_CONTINUE;
}
);
this._pingConnectivity();
}
_stopConnectivityCheck() {
if (this._connectivityTimeoutId) {
GLib.Source.remove(this._connectivityTimeoutId);
this._connectivityTimeoutId = 0;
}
}
_restartConnectivityCheck() {
this._stopConnectivityCheck();
this._startConnectivityCheck();
}
// Probe the active outbound to tell "up but no internet" from "up and OK".
async _pingConnectivity() {
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 || 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) {
this._connectivity = 'ok';
this._connectivityDelay = d;
this._delays.set(node, d);
} else {
this._connectivity = 'down';
this._connectivityDelay = null;
this._delays.set(node, 'timeout');
}
this._updateStatus();
} catch (e) {
if (c.is_cancelled() || node !== this._activeOutbound)
return;
// 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;
} }
} }
@@ -276,33 +412,51 @@ class SingBoxIndicator extends PanelMenu.Button {
_updateSelectors(proxies) { _updateSelectors(proxies) {
const map = proxies?.proxies ?? {}; const map = proxies?.proxies ?? {};
this._proxiesMap = map; 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)) { for (const name of Object.keys(map)) {
const p = map[name]; const p = map[name];
if (p && p.type === 'Selector' && Array.isArray(p.all)) if (!p || !Array.isArray(p.all))
selectors.push({ name, now: p.now, all: p.all }); continue;
if (p.type === 'Selector')
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, type: p.type, switchable: false });
} }
// Panel outbound label: chosen group's current node. // Switchable (Selector) groups first; sort is stable within each class.
const panelGroup = this._pickPanelGroup(selectors); groups.sort((a, b) => (b.switchable ? 1 : 0) - (a.switchable ? 1 : 0));
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
// Rebuild the selector section only when the structure changes. // Panel outbound label: chosen group's current node.
const signature = JSON.stringify(selectors.map(s => [s.name, s.all])); const panelGroup = this._pickPanelGroup(groups);
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
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]));
if (signature !== this._selectorSignature) { if (signature !== this._selectorSignature) {
this._rebuildSelectors(selectors); this._rebuildSelectors(groups);
this._selectorSignature = signature; this._selectorSignature = signature;
} }
// Update current selection + latencies every poll. // Update current selection + latencies every poll.
for (const sel of selectors) { for (const g of groups) {
const stored = this._selectorItems.get(sel.name); const stored = this._selectorItems.get(g.name);
if (!stored) if (!stored)
continue; continue;
stored.subMenu.label.text = `${sel.name}: ${sel.now ?? '—'}`; stored.subMenu.label.text = this._groupTitle(g);
for (const [member, entry] of stored.members) { for (const [member, entry] of stored.members) {
const isCurrent = member === sel.now; entry.setOrnament(member === g.now
entry.setOrnament(isCurrent
? PopupMenu.Ornament.DOT ? PopupMenu.Ornament.DOT
: PopupMenu.Ornament.NONE); : PopupMenu.Ornament.NONE);
// Don't overwrite the "…" placeholder while a probe is running. // Don't overwrite the "…" placeholder while a probe is running.
@@ -313,12 +467,12 @@ class SingBoxIndicator extends PanelMenu.Button {
} }
} }
_rebuildSelectors(selectors) { _rebuildSelectors(groups) {
this._selectorsSection.removeAll(); this._selectorsSection.removeAll();
this._selectorItems.clear(); this._selectorItems.clear();
if (selectors.length === 0) { if (groups.length === 0) {
const none = new PopupMenu.PopupMenuItem(_('No selector groups'), { const none = new PopupMenu.PopupMenuItem(_('No outbound groups'), {
reactive: false, reactive: false,
can_focus: false, can_focus: false,
}); });
@@ -326,26 +480,31 @@ class SingBoxIndicator extends PanelMenu.Button {
return; return;
} }
for (const sel of selectors) { for (const g of groups) {
const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${sel.name}: ${sel.now ?? '—'}`, true); const subMenu = new PopupMenu.PopupSubMenuMenuItem(this._groupTitle(g), true);
subMenu.icon.icon_name = 'network-workgroup-symbolic'; subMenu.icon.icon_name = 'network-workgroup-symbolic';
// Manual latency probe for all nodes in this group. Override // Manual latency probe for all nodes in this group. Override
// activate() so clicking it runs the probe without closing the menu. // activate() so clicking it runs the probe without closing the menu.
const testItem = new PopupMenu.PopupImageMenuItem(_('Test latency'), 'view-refresh-symbolic'); const testItem = new PopupMenu.PopupImageMenuItem(_('Test latency'), 'view-refresh-symbolic');
testItem.add_style_class_name('sbx-test-item'); testItem.add_style_class_name('sbx-test-item');
testItem.activate = () => this._testLatencies(sel.name); testItem.activate = () => this._testLatencies(g.name);
subMenu.menu.addMenuItem(testItem); subMenu.menu.addMenuItem(testItem);
const members = new Map(); const members = new Map();
for (const member of sel.all) { for (const member of g.all) {
const item = new PopupMenu.PopupMenuItem(member); // Switchable groups: click a node to select it. Read-only groups
item.connect('activate', () => this._selectOutbound(sel.name, member)); // (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); subMenu.menu.addMenuItem(item);
members.set(member, item); members.set(member, item);
} }
this._selectorsSection.addMenuItem(subMenu); this._selectorsSection.addMenuItem(subMenu);
this._selectorItems.set(sel.name, { subMenu, members }); this._selectorItems.set(g.name, { subMenu, members });
} }
} }
@@ -359,15 +518,23 @@ class SingBoxIndicator extends PanelMenu.Button {
this._connItem.label.text = `${_('Connections')}: —`; this._connItem.label.text = `${_('Connections')}: —`;
} }
_pickPanelGroup(selectors) { // Submenu title: read-only groups also show their type (URLTest/Fallback).
if (selectors.length === 0) _groupTitle(g) {
return g.switchable
? `${g.name}: ${g.now ?? '—'}`
: `${g.name} [${g.type}]: ${g.now ?? '—'}`;
}
_pickPanelGroup(groups) {
if (groups.length === 0)
return null; return null;
if (this._selectorPref) { if (this._selectorPref) {
const match = selectors.find(s => s.name === this._selectorPref); const match = groups.find(g => g.name === this._selectorPref);
if (match) if (match)
return 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 // Latest known latency: a manual probe result wins over sing-box's own
@@ -403,9 +570,12 @@ class SingBoxIndicator extends PanelMenu.Button {
if (!stored) if (!stored)
return; return;
for (const [member, item] of stored.members) { 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); this._probing.add(member);
item.label.text = `${member} · …`; item.label.text = `${member} · …`;
this._api.getDelay(member, {}, this._cancellable) this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable)
.then(res => { .then(res => {
const d = Number(res?.delay); const d = Number(res?.delay);
this._delays.set(member, Number.isFinite(d) && d > 0 ? d : 'timeout'); this._delays.set(member, Number.isFinite(d) && d > 0 ? d : 'timeout');
@@ -429,8 +599,18 @@ class SingBoxIndicator extends PanelMenu.Button {
async _selectOutbound(group, name) { async _selectOutbound(group, name) {
try { try {
await this._api.selectProxy(group, name, this._cancellable); 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(); this._poll();
} catch (e) { } catch (e) {
if (this._cancellable.is_cancelled())
return;
Main.notifyError(_('sing-box'), `${_('Failed to switch outbound')}: ${e.message}`); Main.notifyError(_('sing-box'), `${_('Failed to switch outbound')}: ${e.message}`);
logError(e, 'sing-box-status: selectProxy failed'); logError(e, 'sing-box-status: selectProxy failed');
} }
@@ -438,56 +618,84 @@ class SingBoxIndicator extends PanelMenu.Button {
// ---- State / visuals --------------------------------------------- // ---- State / visuals ---------------------------------------------
_setState(state, error) { // Derive the dot state and status text from API + connectivity state.
this._state = state; _updateStatus() {
let dot;
switch (state) { if (this._apiUp === null) {
case STATE.ONLINE: dot = 'connecting';
this._dotFilled = true; this._statusItem.label.text = _('sing-box · Connecting…');
this._statusItem.label.text = _('sing-box · Active'); } else if (!this._apiUp) {
break; dot = 'down';
case STATE.OFFLINE:
this._dotFilled = false;
this._statusItem.label.text = _('sing-box · Unreachable'); this._statusItem.label.text = _('sing-box · Unreachable');
this._outboundLabel.text = ''; this._outboundLabel.text = '';
this._speedLabel.text = ''; this._speedLabel.text = '';
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings'); this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
this._prevSample = null; } else if (this._checkConnectivity && this._activeOutbound && this._connectivity === 'unknown') {
this._clearMenuData(); // Service up, connectivity not measured yet — don't claim it works.
break; dot = 'checking';
default: this._statusItem.label.text = _('sing-box · Active (checking…)');
this._dotFilled = false; } else if (this._checkConnectivity && this._connectivity === 'down') {
this._statusItem.label.text = _('sing-box · Connecting…'); dot = 'nocon';
break; this._statusItem.label.text = _('sing-box · No connectivity');
} else {
dot = 'ok';
const ms = this._checkConnectivity && this._connectivityDelay
? ` · ${this._connectivityDelay} ms` : '';
this._statusItem.label.text = `${_('sing-box · Active')}${ms}`;
}
if (dot !== this._dotState) {
this._dotState = dot;
this._dot.queue_repaint();
} }
this._dot.queue_repaint();
} }
_drawDot(area) { _drawDot(area) {
const cr = area.get_context(); const cr = area.get_context();
const [w, h] = area.get_surface_size(); try {
const color = area.get_theme_node().get_foreground_color(); const [w, h] = area.get_surface_size();
const r = color.red / 255; const color = area.get_theme_node().get_foreground_color();
const g = color.green / 255; const r = color.red / 255;
const b = color.blue / 255; const g = color.green / 255;
const a = color.alpha / 255; const b = color.blue / 255;
const a = color.alpha / 255;
const cx = w / 2; const cx = w / 2;
const cy = h / 2; const cy = h / 2;
const radius = Math.min(w, h) / 2 - 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); cr.arc(cx, cy, radius, 0, 2 * Math.PI);
if (this._dotFilled) {
cr.setSourceRGBA(r, g, b, a); switch (this._dotState) {
cr.fillPreserve(); case 'ok':
cr.stroke(); // Filled disc: service up and outbound reachable.
} else { cr.setSourceRGBA(r, g, b, a);
// Hollow ring, slightly dimmed so "off" reads as inactive. cr.fillPreserve();
cr.setSourceRGBA(r, g, b, a * 0.6); cr.stroke();
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 filled disc: service up, connectivity not measured yet.
cr.setSourceRGBA(r, g, b, a * 0.5);
cr.fillPreserve();
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() { _openDashboard() {
@@ -504,10 +712,16 @@ class SingBoxIndicator extends PanelMenu.Button {
GLib.Source.remove(this._timeoutId); GLib.Source.remove(this._timeoutId);
this._timeoutId = 0; this._timeoutId = 0;
} }
this._stopConnectivityCheck();
if (this._settingsDebounceId) {
GLib.Source.remove(this._settingsDebounceId);
this._settingsDebounceId = 0;
}
if (this._settingsChangedId) { if (this._settingsChangedId) {
this._settings.disconnect(this._settingsChangedId); this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = 0; this._settingsChangedId = 0;
} }
this._api.destroy();
super.destroy(); super.destroy();
} }
}); });
+26 -1
View File
@@ -37,7 +37,7 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
dispGroup.add(this._switchRow(settings, 'show-speed', _('Show speed'), dispGroup.add(this._switchRow(settings, 'show-speed', _('Show speed'),
_('Show upload/download speed in the top bar.'))); _('Show upload/download speed in the top bar.')));
dispGroup.add(this._entryRow(settings, 'selector-group', _('Panel selector group'), 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({ const intervalRow = new Adw.SpinRow({
title: _('Refresh interval'), title: _('Refresh interval'),
@@ -46,6 +46,31 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
}); });
settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT); settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
dispGroup.add(intervalRow); dispGroup.add(intervalRow);
// --- 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 connectivity-based status-dot states (four in total).'),
});
page.add(connCheckGroup);
const checkRow = this._switchRow(settings, 'check-connectivity',
_('Probe active 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({
title: _('Probe interval'),
subtitle: _('Seconds between outbound probes.'),
adjustment: new Gtk.Adjustment({ lower: 5, upper: 600, step_increment: 5 }),
});
settings.bind('connectivity-interval', connIntervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
settings.bind('check-connectivity', connIntervalRow, 'sensitive', Gio.SettingsBindFlags.GET);
connCheckGroup.add(connIntervalRow);
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);
} }
_entryRow(settings, key, title, subtitle) { _entryRow(settings, key, title, subtitle) {
@@ -26,7 +26,7 @@
<key name="selector-group" type="s"> <key name="selector-group" type="s">
<default>''</default> <default>''</default>
<summary>Outbound selector group shown in the panel</summary> <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>
<key name="show-outbound" type="b"> <key name="show-outbound" type="b">
<default>true</default> <default>true</default>
@@ -38,5 +38,21 @@
<summary>Show speed in the panel</summary> <summary>Show speed in the panel</summary>
<description>Show upload/download speed in the top bar.</description> <description>Show upload/download speed in the top bar.</description>
</key> </key>
<key name="check-connectivity" type="b">
<default>false</default>
<summary>Periodically probe the active outbound</summary>
<description>When enabled, the extension periodically measures latency through the active outbound to distinguish "service up but no connectivity" from "service up and reachable".</description>
</key>
<key name="connectivity-interval" type="i">
<default>30</default>
<range min="5" max="600"/>
<summary>Connectivity probe interval (seconds)</summary>
<description>How often to probe the active outbound when connectivity checking is enabled.</description>
</key>
<key name="connectivity-url" type="s">
<default>'https://www.gstatic.com/generate_204'</default>
<summary>Connectivity probe URL</summary>
<description>Target URL used both by the active-outbound connectivity probe and by the manual "Test latency" action.</description>
</key>
</schema> </schema>
</schemalist> </schemalist>
+4
View File
@@ -15,6 +15,10 @@
.sbx-speed-label { .sbx-speed-label {
font-feature-settings: "tnum"; font-feature-settings: "tnum";
font-size: 0.9em; font-size: 0.9em;
/* Fixed width so the panel widget doesn't shift as speed values change.
Sized for the widest field: "↓1023 ↑1023" (see formatBytesShort). */
min-width: 7.5em;
text-align: left;
} }
.sbx-status-item { .sbx-status-item {