Compare commits
4
Commits
74aa0ee384
...
49df839254
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49df839254
|
||
|
|
7b08568d2c
|
||
|
|
ec673b92f3
|
||
|
|
fc83c57382
|
@@ -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.
|
||||
@@ -7,20 +7,33 @@ GNOME, используя встроенный **Clash API**.
|
||||
|
||||
**В панели (компактно):**
|
||||
|
||||
- монохромный индикатор-кружок — залит, когда API доступен и прокси жив; полый
|
||||
(тускнее), когда API недоступен. Цвет берётся из цвета текста панели, поэтому
|
||||
индикатор одинаково читается на тёмной и светлой теме;
|
||||
- имя активного outbound (текущий узел Selector-группы);
|
||||
- текущую скорость, например `↓2.4M ↑120k`.
|
||||
- монохромный индикатор-кружок (цвет берётся из цвета текста панели, поэтому
|
||||
индикатор одинаково читается на тёмной и светлой теме):
|
||||
- **яркий залитый круг** — сервис работает и через активный outbound есть связь;
|
||||
- **тусклый залитый круг** — сервис работает, связь ещё проверяется;
|
||||
- **яркое сплошное кольцо** — сервис работает, но связи через 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`);
|
||||
- каждая Selector-группа отдельным подменю — выбор узла **переключает активный
|
||||
outbound** (текущий помечен точкой, рядом — последняя измеренная задержка, если она
|
||||
есть). Пункт **Test latency** в подменю запускает ручной замер задержки всех узлов
|
||||
группы и обновляет значения на месте (меню при этом не закрывается);
|
||||
- каждая группа outbound'ов отдельным подменю. **Selector**-группы переключаемы —
|
||||
выбор узла меняет активный outbound; **URLTest/Fallback**-группы показываются только
|
||||
для просмотра (узел выбирает сам sing-box). Текущий узел помечен точкой, рядом —
|
||||
последняя измеренная задержка, если она есть. Пункт **Test latency** в подменю
|
||||
запускает ручной замер задержки всех узлов группы по адресу `connectivity-url` и
|
||||
обновляет значения на месте (меню при этом не закрывается);
|
||||
- число активных соединений с разбивкой по outbound;
|
||||
- **Open dashboard** (MetaCubeXD или ваш дашборд Clash) и **Settings**.
|
||||
|
||||
@@ -41,23 +54,86 @@ GNOME, используя встроенный **Clash API**.
|
||||
}
|
||||
```
|
||||
|
||||
## Установка (из исходников)
|
||||
## Установка
|
||||
|
||||
Полная последовательность после свежего `git clone` (в репозитории нет собранной
|
||||
схемы — её нужно скомпилировать локально, шаг 3):
|
||||
|
||||
```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"
|
||||
mkdir -p "$HOME/.local/share/gnome-shell/extensions"
|
||||
ln -s "$PWD" "$HOME/.local/share/gnome-shell/extensions/$UUID"
|
||||
|
||||
# 3. Скомпилировать схему GSettings (обязательно: gschemas.compiled не хранится в git)
|
||||
glib-compile-schemas schemas/
|
||||
# На Wayland: выйдите из сессии и войдите снова, чтобы shell зарегистрировал
|
||||
# новое расширение, затем:
|
||||
|
||||
# 4. Перезапустить GNOME Shell, чтобы он увидел новое расширение:
|
||||
# X11 — Alt+F2, ввести «r», Enter
|
||||
# Wayland — выйти из сессии и войти снова (перезапуск на лету невозможен)
|
||||
|
||||
# 5. Включить расширение
|
||||
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`);
|
||||
- **Secret** — ваш `clash_api.secret`;
|
||||
- **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:
|
||||
@@ -71,14 +147,28 @@ gnome-extensions enable "$UUID"
|
||||
показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**)
|
||||
выполняется через `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) проверен на живом
|
||||
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.
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT — см. [LICENSE](LICENSE).
|
||||
|
||||
+12
-2
@@ -16,7 +16,9 @@ const textEncoder = new TextEncoder();
|
||||
export class ClashApi {
|
||||
constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -25,8 +27,16 @@ export class ClashApi {
|
||||
this._secret = secret || '';
|
||||
}
|
||||
|
||||
// Abort all in-flight requests and drop idle keep-alive connections.
|
||||
destroy() {
|
||||
this._session.abort();
|
||||
}
|
||||
|
||||
_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();
|
||||
headers.append('Accept', 'application/json');
|
||||
if (this._secret)
|
||||
|
||||
+304
-90
@@ -14,12 +14,6 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j
|
||||
import { ClashApi } from './clashApi.js';
|
||||
import { formatSpeed, formatSpeedShort } from './format.js';
|
||||
|
||||
const STATE = {
|
||||
CONNECTING: 'connecting',
|
||||
ONLINE: 'online',
|
||||
OFFLINE: 'offline',
|
||||
};
|
||||
|
||||
export const SingBoxIndicator = GObject.registerClass(
|
||||
class SingBoxIndicator extends PanelMenu.Button {
|
||||
_init(extension) {
|
||||
@@ -29,7 +23,11 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
this._settings = extension.getSettings();
|
||||
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._selectorSignature = null;
|
||||
this._selectorItems = new Map();
|
||||
@@ -37,7 +35,11 @@ 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();
|
||||
|
||||
this._buildPanel();
|
||||
@@ -47,6 +49,7 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
this._applySettings();
|
||||
|
||||
this._startPolling();
|
||||
this._startConnectivityCheck();
|
||||
}
|
||||
|
||||
// ---- Panel widget -------------------------------------------------
|
||||
@@ -57,9 +60,11 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
|
||||
// Monochrome status dot: a ring in the panel's text color, filled when
|
||||
// the proxy is alive, hollow when it is not. Adapts to light/dark theme.
|
||||
this._dotFilled = false;
|
||||
// Monochrome status dot in the panel's text color (adapts to theme):
|
||||
// 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,
|
||||
@@ -132,25 +137,54 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
|
||||
// ---- 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() {
|
||||
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.
|
||||
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();
|
||||
if (!this._checkConnectivity) {
|
||||
this._connectivity = 'unknown';
|
||||
this._connectivityDelay = null;
|
||||
}
|
||||
this._poll();
|
||||
this._updateStatus();
|
||||
}
|
||||
|
||||
_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');
|
||||
this._showSpeed = this._settings.get_boolean('show-speed');
|
||||
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._speedLabel.visible = this._showSpeed;
|
||||
}
|
||||
@@ -185,25 +219,127 @@ 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 {
|
||||
const [conn, proxies] = await Promise.all([
|
||||
this._api.getConnections(c),
|
||||
this._api.getProxies(c),
|
||||
]);
|
||||
let conn, proxies;
|
||||
try {
|
||||
[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())
|
||||
return;
|
||||
this._updateFromData(conn, proxies);
|
||||
this._setState(STATE.ONLINE);
|
||||
} catch (e) {
|
||||
if (c.is_cancelled())
|
||||
return;
|
||||
this._setState(STATE.OFFLINE, e);
|
||||
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". 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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) {
|
||||
const map = proxies?.proxies ?? {};
|
||||
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)) {
|
||||
const p = map[name];
|
||||
if (p && p.type === 'Selector' && Array.isArray(p.all))
|
||||
selectors.push({ name, now: p.now, all: p.all });
|
||||
if (!p || !Array.isArray(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.
|
||||
const panelGroup = this._pickPanelGroup(selectors);
|
||||
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
||||
// Switchable (Selector) groups first; sort is stable within each class.
|
||||
groups.sort((a, b) => (b.switchable ? 1 : 0) - (a.switchable ? 1 : 0));
|
||||
|
||||
// Rebuild the selector section only when the structure changes.
|
||||
const signature = JSON.stringify(selectors.map(s => [s.name, s.all]));
|
||||
// Panel outbound label: chosen group's current node.
|
||||
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) {
|
||||
this._rebuildSelectors(selectors);
|
||||
this._rebuildSelectors(groups);
|
||||
this._selectorSignature = signature;
|
||||
}
|
||||
|
||||
// Update current selection + latencies every poll.
|
||||
for (const sel of selectors) {
|
||||
const stored = this._selectorItems.get(sel.name);
|
||||
for (const g of groups) {
|
||||
const stored = this._selectorItems.get(g.name);
|
||||
if (!stored)
|
||||
continue;
|
||||
stored.subMenu.label.text = `${sel.name}: ${sel.now ?? '—'}`;
|
||||
stored.subMenu.label.text = this._groupTitle(g);
|
||||
for (const [member, entry] of stored.members) {
|
||||
const isCurrent = member === sel.now;
|
||||
entry.setOrnament(isCurrent
|
||||
entry.setOrnament(member === g.now
|
||||
? PopupMenu.Ornament.DOT
|
||||
: PopupMenu.Ornament.NONE);
|
||||
// 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._selectorItems.clear();
|
||||
|
||||
if (selectors.length === 0) {
|
||||
const none = new PopupMenu.PopupMenuItem(_('No selector groups'), {
|
||||
if (groups.length === 0) {
|
||||
const none = new PopupMenu.PopupMenuItem(_('No outbound groups'), {
|
||||
reactive: false,
|
||||
can_focus: false,
|
||||
});
|
||||
@@ -326,26 +480,31 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sel of selectors) {
|
||||
const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${sel.name}: ${sel.now ?? '—'}`, true);
|
||||
for (const g of groups) {
|
||||
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
|
||||
// activate() so clicking it runs the probe without closing the menu.
|
||||
const testItem = new PopupMenu.PopupImageMenuItem(_('Test latency'), 'view-refresh-symbolic');
|
||||
testItem.add_style_class_name('sbx-test-item');
|
||||
testItem.activate = () => this._testLatencies(sel.name);
|
||||
testItem.activate = () => this._testLatencies(g.name);
|
||||
subMenu.menu.addMenuItem(testItem);
|
||||
|
||||
const members = new Map();
|
||||
for (const member of sel.all) {
|
||||
const item = new PopupMenu.PopupMenuItem(member);
|
||||
item.connect('activate', () => this._selectOutbound(sel.name, member));
|
||||
for (const member of g.all) {
|
||||
// Switchable groups: click a node to select it. Read-only groups
|
||||
// (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);
|
||||
members.set(member, item);
|
||||
}
|
||||
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')}: —`;
|
||||
}
|
||||
|
||||
_pickPanelGroup(selectors) {
|
||||
if (selectors.length === 0)
|
||||
// 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;
|
||||
if (this._selectorPref) {
|
||||
const match = selectors.find(s => s.name === this._selectorPref);
|
||||
const match = groups.find(g => g.name === this._selectorPref);
|
||||
if (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
|
||||
@@ -403,9 +570,12 @@ 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, {}, this._cancellable)
|
||||
this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable)
|
||||
.then(res => {
|
||||
const d = Number(res?.delay);
|
||||
this._delays.set(member, Number.isFinite(d) && d > 0 ? d : 'timeout');
|
||||
@@ -429,8 +599,18 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
async _selectOutbound(group, name) {
|
||||
try {
|
||||
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();
|
||||
} catch (e) {
|
||||
if (this._cancellable.is_cancelled())
|
||||
return;
|
||||
Main.notifyError(_('sing-box'), `${_('Failed to switch outbound')}: ${e.message}`);
|
||||
logError(e, 'sing-box-status: selectProxy failed');
|
||||
}
|
||||
@@ -438,56 +618,84 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
|
||||
// ---- State / visuals ---------------------------------------------
|
||||
|
||||
_setState(state, error) {
|
||||
this._state = state;
|
||||
|
||||
switch (state) {
|
||||
case STATE.ONLINE:
|
||||
this._dotFilled = true;
|
||||
this._statusItem.label.text = _('sing-box · Active');
|
||||
break;
|
||||
case STATE.OFFLINE:
|
||||
this._dotFilled = false;
|
||||
// Derive the dot state and status text from API + connectivity state.
|
||||
_updateStatus() {
|
||||
let dot;
|
||||
if (this._apiUp === null) {
|
||||
dot = 'connecting';
|
||||
this._statusItem.label.text = _('sing-box · Connecting…');
|
||||
} else if (!this._apiUp) {
|
||||
dot = 'down';
|
||||
this._statusItem.label.text = _('sing-box · Unreachable');
|
||||
this._outboundLabel.text = '';
|
||||
this._speedLabel.text = '';
|
||||
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
|
||||
this._prevSample = null;
|
||||
this._clearMenuData();
|
||||
break;
|
||||
default:
|
||||
this._dotFilled = false;
|
||||
this._statusItem.label.text = _('sing-box · Connecting…');
|
||||
break;
|
||||
} 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…)');
|
||||
} else if (this._checkConnectivity && this._connectivity === 'down') {
|
||||
dot = 'nocon';
|
||||
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) {
|
||||
const cr = area.get_context();
|
||||
const [w, h] = area.get_surface_size();
|
||||
const color = area.get_theme_node().get_foreground_color();
|
||||
const r = color.red / 255;
|
||||
const g = color.green / 255;
|
||||
const b = color.blue / 255;
|
||||
const a = color.alpha / 255;
|
||||
try {
|
||||
const [w, h] = area.get_surface_size();
|
||||
const color = area.get_theme_node().get_foreground_color();
|
||||
const r = color.red / 255;
|
||||
const g = color.green / 255;
|
||||
const b = color.blue / 255;
|
||||
const a = color.alpha / 255;
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) / 2 - 2;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) / 2 - 2;
|
||||
|
||||
cr.setLineWidth(1.5);
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
if (this._dotFilled) {
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
cr.fillPreserve();
|
||||
cr.stroke();
|
||||
} else {
|
||||
// Hollow ring, slightly dimmed so "off" reads as inactive.
|
||||
cr.setSourceRGBA(r, g, b, a * 0.6);
|
||||
cr.stroke();
|
||||
cr.setLineWidth(1.5);
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
|
||||
switch (this._dotState) {
|
||||
case 'ok':
|
||||
// Filled disc: service up and outbound reachable.
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
cr.fillPreserve();
|
||||
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() {
|
||||
@@ -504,10 +712,16 @@ class SingBoxIndicator extends PanelMenu.Button {
|
||||
GLib.Source.remove(this._timeoutId);
|
||||
this._timeoutId = 0;
|
||||
}
|
||||
this._stopConnectivityCheck();
|
||||
if (this._settingsDebounceId) {
|
||||
GLib.Source.remove(this._settingsDebounceId);
|
||||
this._settingsDebounceId = 0;
|
||||
}
|
||||
if (this._settingsChangedId) {
|
||||
this._settings.disconnect(this._settingsChangedId);
|
||||
this._settingsChangedId = 0;
|
||||
}
|
||||
this._api.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
@@ -46,6 +46,31 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
|
||||
});
|
||||
settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -38,5 +38,21 @@
|
||||
<summary>Show speed in the panel</summary>
|
||||
<description>Show upload/download speed in the top bar.</description>
|
||||
</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>
|
||||
</schemalist>
|
||||
|
||||
Reference in New Issue
Block a user