Files
sing-box-gnome-extension/lib/indicator.js
T

728 lines
28 KiB
JavaScript

// Panel button that polls the Clash API and renders sing-box status.
import GObject from 'gi://GObject';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
import { ClashApi } from './clashApi.js';
import { formatSpeed, formatSpeedShort } from './format.js';
export const SingBoxIndicator = GObject.registerClass(
class SingBoxIndicator extends PanelMenu.Button {
_init(extension) {
super._init(0.0, 'sing-box Status', false);
this._extension = extension;
this._settings = extension.getSettings();
this._api = new ClashApi();
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();
this._proxiesMap = {};
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();
this._buildMenu();
this._settingsChangedId = this._settings.connect('changed', () => this._onSettingsChanged());
this._applySettings();
this._startPolling();
this._startConnectivityCheck();
}
// ---- Panel widget -------------------------------------------------
_buildPanel() {
const box = new St.BoxLayout({
style_class: 'panel-status-menu-box sbx-panel-box',
y_align: Clutter.ActorAlign.CENTER,
});
// 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,
});
this._dot.set_width(16);
this._dot.set_height(16);
this._dot.connect('repaint', area => this._drawDot(area));
box.add_child(this._dot);
this._outboundLabel = new St.Label({
style_class: 'sbx-outbound-label',
y_align: Clutter.ActorAlign.CENTER,
text: '',
});
box.add_child(this._outboundLabel);
this._speedLabel = new St.Label({
style_class: 'sbx-speed-label',
y_align: Clutter.ActorAlign.CENTER,
text: '',
});
box.add_child(this._speedLabel);
this.add_child(box);
}
// ---- Menu ---------------------------------------------------------
_buildMenu() {
this._statusItem = new PopupMenu.PopupMenuItem(_('sing-box'), {
reactive: false,
can_focus: false,
});
this._statusItem.add_style_class_name('sbx-status-item');
this.menu.addMenuItem(this._statusItem);
this._speedItem = new PopupMenu.PopupMenuItem('', {
reactive: false,
can_focus: false,
});
this._speedItem.add_style_class_name('sbx-speed-item');
this.menu.addMenuItem(this._speedItem);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
// Selector groups (switchable outbounds) are rebuilt into this section.
this._selectorsSection = new PopupMenu.PopupMenuSection();
this.menu.addMenuItem(this._selectorsSection);
// Connections, grouped by outbound.
this._connItem = new PopupMenu.PopupSubMenuMenuItem(_('Connections: —'));
this.menu.addMenuItem(this._connItem);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const dashboardItem = new PopupMenu.PopupMenuItem(_('Open dashboard'));
dashboardItem.connect('activate', () => this._openDashboard());
this.menu.addMenuItem(dashboardItem);
const prefsItem = new PopupMenu.PopupMenuItem(_('Settings'));
prefsItem.connect('activate', () => this._extension.openPreferences());
this.menu.addMenuItem(prefsItem);
// Refresh immediately when the user opens the menu.
this.menu.connect('open-state-changed', (_menu, open) => {
if (open)
this._poll();
});
}
// ---- 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 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._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;
}
// ---- Polling ------------------------------------------------------
_startPolling() {
this._poll();
this._timeoutId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
Math.max(1, this._interval),
() => {
this._poll();
return GLib.SOURCE_CONTINUE;
}
);
}
_restartPolling() {
if (this._timeoutId) {
GLib.Source.remove(this._timeoutId);
this._timeoutId = 0;
}
this._timeoutId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
Math.max(1, this._interval),
() => {
this._poll();
return GLib.SOURCE_CONTINUE;
}
);
}
async _poll() {
if (this._polling) {
this._pollAgain = true;
return;
}
this._polling = true;
const c = this._cancellable;
try {
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._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;
}
}
// ---- Data → UI ----------------------------------------------------
_updateFromData(conn, proxies) {
this._updateSpeed(conn);
this._updateConnections(conn);
this._updateSelectors(proxies);
}
_updateSpeed(conn) {
const down = Number(conn?.downloadTotal) || 0;
const up = Number(conn?.uploadTotal) || 0;
const now = GLib.get_monotonic_time() / 1e6; // seconds
let dSpeed = 0;
let uSpeed = 0;
if (this._prevSample) {
const dt = now - this._prevSample.t;
if (dt > 0) {
dSpeed = Math.max(0, (down - this._prevSample.down) / dt);
uSpeed = Math.max(0, (up - this._prevSample.up) / dt);
}
}
this._prevSample = { down, up, t: now };
this._speedLabel.text = `↓${formatSpeedShort(dSpeed)}${formatSpeedShort(uSpeed)}`;
this._speedItem.label.text = `↓ ${formatSpeed(dSpeed)}${formatSpeed(uSpeed)}`;
}
_updateConnections(conn) {
const list = Array.isArray(conn?.connections) ? conn.connections : [];
this._connItem.label.text = `${_('Connections')}: ${list.length}`;
// Group by the actual outbound node (first element of the chain).
const counts = new Map();
for (const item of list) {
const chain = Array.isArray(item?.chains) ? item.chains : [];
const node = chain.length ? chain[0] : _('(direct)');
counts.set(node, (counts.get(node) || 0) + 1);
}
this._connItem.menu.removeAll();
if (counts.size === 0) {
const empty = new PopupMenu.PopupMenuItem(_('No active connections'), {
reactive: false,
can_focus: false,
});
this._connItem.menu.addMenuItem(empty);
return;
}
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
for (const [node, count] of sorted) {
const row = new PopupMenu.PopupMenuItem(`${node}`, {
reactive: false,
can_focus: false,
});
const badge = new St.Label({
text: String(count),
style_class: 'sbx-badge',
x_align: Clutter.ActorAlign.END,
x_expand: true,
});
row.add_child(badge);
this._connItem.menu.addMenuItem(row);
}
}
_updateSelectors(proxies) {
const map = proxies?.proxies ?? {};
this._proxiesMap = map;
// 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 || !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 });
}
// Switchable (Selector) groups first; sort is stable within each class.
groups.sort((a, b) => (b.switchable ? 1 : 0) - (a.switchable ? 1 : 0));
// Panel outbound label: chosen group's current node.
const panelGroup = this._pickPanelGroup(groups);
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
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(groups);
this._selectorSignature = signature;
}
// Update current selection + latencies every poll.
for (const g of groups) {
const stored = this._selectorItems.get(g.name);
if (!stored)
continue;
stored.subMenu.label.text = this._groupTitle(g);
for (const [member, entry] of stored.members) {
entry.setOrnament(member === g.now
? PopupMenu.Ornament.DOT
: PopupMenu.Ornament.NONE);
// Don't overwrite the "…" placeholder while a probe is running.
if (this._probing.has(member))
continue;
entry.label.text = this._memberLabelText(member, map[member]);
}
}
}
_rebuildSelectors(groups) {
this._selectorsSection.removeAll();
this._selectorItems.clear();
if (groups.length === 0) {
const none = new PopupMenu.PopupMenuItem(_('No outbound groups'), {
reactive: false,
can_focus: false,
});
this._selectorsSection.addMenuItem(none);
return;
}
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(g.name);
subMenu.menu.addMenuItem(testItem);
const members = new Map();
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(g.name, { subMenu, members });
}
}
// Drop menu content that would otherwise stay clickable while offline.
_clearMenuData() {
this._selectorsSection.removeAll();
this._selectorItems.clear();
this._selectorSignature = null;
this._probing.clear();
this._connItem.menu.removeAll();
this._connItem.label.text = `${_('Connections')}: —`;
}
// Submenu title: read-only groups also show their type (URLTest/Fallback).
_groupTitle(g) {
return g.switchable
? `${g.name}: ${g.now ?? '—'}`
: `${g.name} [${g.type}]: ${g.now ?? '—'}`;
}
_pickPanelGroup(groups) {
if (groups.length === 0)
return null;
if (this._selectorPref) {
const match = groups.find(g => g.name === this._selectorPref);
if (match)
return match;
}
// 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
// history. Returns a number (ms), the string 'timeout', or null if unknown.
_delayFor(name, proxy) {
if (this._delays.has(name))
return this._delays.get(name);
return this._latencyOf(proxy);
}
_memberLabelText(name, proxy) {
const d = this._delayFor(name, proxy);
if (d == null)
return name;
if (d === 'timeout')
return `${name} · ${_('timeout')}`;
return `${name} · ${d} ms`;
}
_latencyOf(proxy) {
const history = proxy?.history;
if (Array.isArray(history) && history.length) {
const last = history[history.length - 1];
if (last && typeof last.delay === 'number' && last.delay > 0)
return last.delay;
}
return null;
}
// Probe every node in a Selector group and update its latency labels.
_testLatencies(groupName) {
const stored = this._selectorItems.get(groupName);
if (!stored)
return;
for (const [member, item] of stored.members) {
// Skip nodes already being probed (manual re-click or overlap).
if (this._probing.has(member))
continue;
this._probing.add(member);
item.label.text = `${member} · …`;
this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable)
.then(res => {
const d = Number(res?.delay);
this._delays.set(member, Number.isFinite(d) && d > 0 ? d : 'timeout');
})
.catch(() => {
this._delays.set(member, 'timeout');
})
.finally(() => {
this._probing.delete(member);
if (this._cancellable.is_cancelled())
return;
// Re-resolve the row: the menu may have been rebuilt while
// the probe was in flight, disposing the captured actor.
const cur = this._selectorItems.get(groupName)?.members.get(member);
if (cur)
cur.label.text = this._memberLabelText(member, this._proxiesMap?.[member]);
});
}
}
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');
}
}
// ---- State / visuals ---------------------------------------------
// 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');
} 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();
}
}
_drawDot(area) {
const cr = area.get_context();
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;
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();
}
}
_openDashboard() {
const url = this._settings.get_string('dashboard-url');
if (url)
Gio.AppInfo.launch_default_for_uri(url, null);
}
// ---- Teardown -----------------------------------------------------
destroy() {
this._cancellable.cancel();
if (this._timeoutId) {
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();
}
});