Harden connectivity probe, add checking state, support URLTest/Fallback groups, MIT license
This commit is contained in:
@@ -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,25 +7,29 @@ GNOME, используя встроенный **Clash API**.
|
|||||||
|
|
||||||
**В панели (компактно):**
|
**В панели (компактно):**
|
||||||
|
|
||||||
- монохромный индикатор-кружок с тремя состояниями (цвет берётся из цвета текста
|
- монохромный индикатор-кружок (цвет берётся из цвета текста панели, поэтому
|
||||||
панели, поэтому индикатор одинаково читается на тёмной и светлой теме):
|
индикатор одинаково читается на тёмной и светлой теме):
|
||||||
- **залитый круг** — сервис работает и через активный outbound есть связь;
|
- **залитый круг** — сервис работает и через активный outbound есть связь;
|
||||||
- **сплошное кольцо** — сервис работает, но связи через outbound нет;
|
- **яркое сплошное кольцо** — сервис работает, но связи через outbound нет;
|
||||||
- **пунктирное кольцо (тусклое)** — сервис (Clash API) недоступен.
|
- **тусклое сплошное кольцо** — сервис работает, связь ещё проверяется;
|
||||||
|
- **пунктирное тусклое кольцо** — сервис (Clash API) недоступен / подключение.
|
||||||
|
|
||||||
Различие «есть связь / нет связи» доступно только при включённой опции
|
Различие «есть связь / нет связи / проверяется» доступно только при включённой
|
||||||
«Connectivity check» (см. ниже) — иначе кружок просто залит, когда API отвечает;
|
опции «Connectivity check» (см. ниже) — иначе кружок просто залит, когда API
|
||||||
- имя активного outbound (текущий узел Selector-группы);
|
отвечает, и пунктирный, когда нет;
|
||||||
|
- имя активного outbound (текущий узел группы);
|
||||||
- текущую скорость, например `↓2.4M ↑120k`.
|
- текущую скорость, например `↓2.4M ↑120k`.
|
||||||
|
|
||||||
**В выпадающем меню (подробности):**
|
**В выпадающем меню (подробности):**
|
||||||
|
|
||||||
- `sing-box · Active / Unreachable`;
|
- `sing-box · Active / Unreachable`;
|
||||||
- полная скорость (`↓ 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**.
|
||||||
|
|
||||||
@@ -95,3 +99,7 @@ API sing-box 1.13.13.
|
|||||||
- `lib/format.js` — форматирование байтов и скорости.
|
- `lib/format.js` — форматирование байтов и скорости.
|
||||||
- `prefs.js` — настройки на Adwaita.
|
- `prefs.js` — настройки на Adwaita.
|
||||||
- `schemas/` — схема GSettings.
|
- `schemas/` — схема GSettings.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT — см. [LICENSE](LICENSE).
|
||||||
|
|||||||
+12
-2
@@ -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)
|
||||||
|
|||||||
+154
-92
@@ -36,6 +36,8 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
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._polling = false;
|
this._polling = false;
|
||||||
|
this._pinging = false; // connectivity probe in flight
|
||||||
|
this._settingsDebounceId = 0;
|
||||||
this._cancellable = new Gio.Cancellable();
|
this._cancellable = new Gio.Cancellable();
|
||||||
|
|
||||||
this._buildPanel();
|
this._buildPanel();
|
||||||
@@ -57,9 +59,10 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Monochrome status dot in the panel's text color (adapts to theme):
|
// Monochrome status dot in the panel's text color (adapts to theme):
|
||||||
// filled disc — service up and outbound has connectivity;
|
// filled disc — service up and outbound has connectivity;
|
||||||
// solid ring — service up but no connectivity through the outbound;
|
// bright ring — service up but no connectivity through the outbound;
|
||||||
// dashed ring — service (Clash API) unreachable.
|
// dim ring — service up, connectivity not yet measured;
|
||||||
|
// 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,7 +135,19 @@ 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 (this._settings.get_string('api-url') !== this._apiUrl)
|
||||||
this._delays.clear();
|
this._delays.clear();
|
||||||
@@ -199,28 +214,37 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
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._apiUp = true;
|
||||||
} catch (e) {
|
// Note: _updateFromData is UI code — keep it out of the API try so
|
||||||
if (c.is_cancelled())
|
// a rendering bug isn't misreported as "API unreachable".
|
||||||
return;
|
this._updateFromData(conn, proxies);
|
||||||
// Service (Clash API) unreachable: connectivity is moot.
|
this._updateStatus();
|
||||||
this._apiUp = false;
|
// Resolve unknown connectivity promptly (startup, recovery, switch).
|
||||||
this._connectivity = 'unknown';
|
if (this._checkConnectivity && this._connectivity === 'unknown')
|
||||||
this._connectivityDelay = null;
|
this._pingConnectivity();
|
||||||
this._prevSample = null;
|
|
||||||
this._clearMenuData();
|
|
||||||
} finally {
|
} finally {
|
||||||
this._polling = false;
|
this._polling = false;
|
||||||
}
|
}
|
||||||
if (!c.is_cancelled())
|
|
||||||
this._updateStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Connectivity check (optional active probe) -------------------
|
// ---- Connectivity check (optional active probe) -------------------
|
||||||
@@ -253,16 +277,18 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
// Probe the active outbound to tell "up but no internet" from "up and OK".
|
// Probe the active outbound to tell "up but no internet" from "up and OK".
|
||||||
async _pingConnectivity() {
|
async _pingConnectivity() {
|
||||||
if (!this._checkConnectivity || this._apiUp === false)
|
if (!this._checkConnectivity || this._apiUp === false || this._pinging)
|
||||||
return;
|
return;
|
||||||
const node = this._activeOutbound;
|
const node = this._activeOutbound;
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
|
this._pinging = true;
|
||||||
const c = this._cancellable;
|
const c = this._cancellable;
|
||||||
try {
|
try {
|
||||||
const res = await this._api.getDelay(
|
const res = await this._api.getDelay(
|
||||||
node, { url: this._connectivityUrl, timeout: 5000 }, c);
|
node, { url: this._connectivityUrl || undefined, timeout: 5000 }, c);
|
||||||
if (c.is_cancelled())
|
// Discard if cancelled or the active outbound changed mid-flight.
|
||||||
|
if (c.is_cancelled() || node !== this._activeOutbound)
|
||||||
return;
|
return;
|
||||||
const d = Number(res?.delay);
|
const d = Number(res?.delay);
|
||||||
if (Number.isFinite(d) && d > 0) {
|
if (Number.isFinite(d) && d > 0) {
|
||||||
@@ -274,14 +300,19 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._connectivityDelay = null;
|
this._connectivityDelay = null;
|
||||||
this._delays.set(node, 'timeout');
|
this._delays.set(node, 'timeout');
|
||||||
}
|
}
|
||||||
|
this._updateStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (c.is_cancelled())
|
if (c.is_cancelled() || node !== this._activeOutbound)
|
||||||
return;
|
return;
|
||||||
this._connectivity = 'down';
|
// A transport/API failure is not an outbound-connectivity verdict.
|
||||||
|
this._connectivity = this._apiUp ? 'down' : 'unknown';
|
||||||
this._connectivityDelay = null;
|
this._connectivityDelay = null;
|
||||||
this._delays.set(node, 'timeout');
|
if (this._apiUp)
|
||||||
|
this._delays.set(node, 'timeout');
|
||||||
|
this._updateStatus();
|
||||||
|
} finally {
|
||||||
|
this._pinging = false;
|
||||||
}
|
}
|
||||||
this._updateStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Data → UI ----------------------------------------------------
|
// ---- Data → UI ----------------------------------------------------
|
||||||
@@ -353,34 +384,40 @@ 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, switchable: true });
|
||||||
|
else if (p.type === 'URLTest' || p.type === 'Fallback')
|
||||||
|
groups.push({ name, now: p.now, all: p.all, switchable: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Panel outbound label: chosen group's current node.
|
// Panel outbound label: chosen group's current node.
|
||||||
const panelGroup = this._pickPanelGroup(selectors);
|
const panelGroup = this._pickPanelGroup(groups);
|
||||||
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
||||||
this._activeOutbound = panelGroup ? panelGroup.now ?? null : null;
|
this._activeOutbound = panelGroup ? panelGroup.now ?? null : null;
|
||||||
|
|
||||||
// Rebuild the selector section only when the structure changes.
|
// Rebuild the group section only when the structure changes.
|
||||||
const signature = JSON.stringify(selectors.map(s => [s.name, s.all]));
|
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 = `${g.name}: ${g.now ?? '—'}`;
|
||||||
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.
|
||||||
@@ -391,12 +428,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,
|
||||||
});
|
});
|
||||||
@@ -404,26 +441,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(`${g.name}: ${g.now ?? '—'}`, 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,15 +479,16 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._connItem.label.text = `${_('Connections')}: —`;
|
this._connItem.label.text = `${_('Connections')}: —`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_pickPanelGroup(selectors) {
|
_pickPanelGroup(groups) {
|
||||||
if (selectors.length === 0)
|
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
|
||||||
@@ -483,7 +526,7 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
for (const [member, item] of stored.members) {
|
for (const [member, item] of stored.members) {
|
||||||
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');
|
||||||
@@ -507,8 +550,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');
|
||||||
}
|
}
|
||||||
@@ -528,6 +581,10 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
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');
|
||||||
|
} else if (this._checkConnectivity && 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') {
|
} else if (this._checkConnectivity && this._connectivity === 'down') {
|
||||||
dot = 'nocon';
|
dot = 'nocon';
|
||||||
this._statusItem.label.text = _('sing-box · No connectivity');
|
this._statusItem.label.text = _('sing-box · No connectivity');
|
||||||
@@ -546,49 +603,49 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
_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);
|
||||||
|
|
||||||
switch (this._dotState) {
|
switch (this._dotState) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
// Filled disc: service up and outbound reachable.
|
// Filled disc: service up and outbound reachable.
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
cr.setSourceRGBA(r, g, b, a);
|
||||||
cr.setSourceRGBA(r, g, b, a);
|
cr.fillPreserve();
|
||||||
cr.fillPreserve();
|
cr.stroke();
|
||||||
cr.stroke();
|
break;
|
||||||
break;
|
case 'nocon':
|
||||||
case 'nocon':
|
// Bright solid ring: service up but no connectivity.
|
||||||
// Solid hollow ring: service up but no connectivity.
|
cr.setSourceRGBA(r, g, b, a);
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
cr.stroke();
|
||||||
cr.setSourceRGBA(r, g, b, a);
|
break;
|
||||||
cr.stroke();
|
case 'checking':
|
||||||
break;
|
// Dim solid ring: service up, connectivity not measured yet.
|
||||||
case 'down':
|
cr.setSourceRGBA(r, g, b, a * 0.5);
|
||||||
// Dashed dim ring: service (Clash API) unreachable.
|
cr.stroke();
|
||||||
cr.setDash([2, 2], 0);
|
break;
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
default:
|
||||||
cr.setSourceRGBA(r, g, b, a * 0.5);
|
// Dashed dim ring: service unreachable ('down') or connecting.
|
||||||
cr.stroke();
|
cr.setDash([2, 2], 0);
|
||||||
cr.setDash([], 0);
|
cr.setSourceRGBA(r, g, b, a * 0.5);
|
||||||
break;
|
cr.stroke();
|
||||||
default:
|
cr.setDash([], 0);
|
||||||
// Connecting: dim solid ring.
|
break;
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
}
|
||||||
cr.setSourceRGBA(r, g, b, a * 0.5);
|
} finally {
|
||||||
cr.stroke();
|
cr.$dispose();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
cr.$dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_openDashboard() {
|
_openDashboard() {
|
||||||
@@ -606,10 +663,15 @@ class SingBoxIndicator extends PanelMenu.Button {
|
|||||||
this._timeoutId = 0;
|
this._timeoutId = 0;
|
||||||
}
|
}
|
||||||
this._stopConnectivityCheck();
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default class SingBoxStatusPreferences extends ExtensionPreferences {
|
|||||||
|
|
||||||
const checkRow = this._switchRow(settings, 'check-connectivity',
|
const checkRow = this._switchRow(settings, 'check-connectivity',
|
||||||
_('Probe active outbound'),
|
_('Probe active outbound'),
|
||||||
_('Periodically measure latency through the current 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);
|
connCheckGroup.add(checkRow);
|
||||||
|
|
||||||
const connIntervalRow = new Adw.SpinRow({
|
const connIntervalRow = new Adw.SpinRow({
|
||||||
|
|||||||
Reference in New Issue
Block a user