Harden connectivity probe, add checking state, support URLTest/Fallback groups, MIT license

This commit is contained in:
av
2026-07-18 16:29:20 +03:00
parent fc83c57382
commit ec673b92f3
5 changed files with 207 additions and 106 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.
+19 -11
View File
@@ -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
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)
+154 -92
View File
@@ -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();
} }
}); });
+1 -1
View File
@@ -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({