Files
avandClaude Opus 4.8 11c69bf012 Center the panel menu under the button
The button was created with menuAlignment 0.0, which places the arrow at
the menu's left edge while it still points at the button's center, so the
menu opened from the middle of the widget. Use 0.5 to match the default
source alignment and center the menu under the button like other indicators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:49:15 +03:00

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.5, '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();
}
});