514 lines
18 KiB
JavaScript
514 lines
18 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';
|
|
|
|
const STATE = {
|
|
CONNECTING: 'connecting',
|
|
ONLINE: 'online',
|
|
OFFLINE: 'offline',
|
|
};
|
|
|
|
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._state = STATE.CONNECTING;
|
|
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._polling = false;
|
|
this._cancellable = new Gio.Cancellable();
|
|
|
|
this._buildPanel();
|
|
this._buildMenu();
|
|
|
|
this._settingsChangedId = this._settings.connect('changed', () => this._onSettingsChanged());
|
|
this._applySettings();
|
|
|
|
this._startPolling();
|
|
}
|
|
|
|
// ---- 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: a ring in the panel's text color, filled when
|
|
// the proxy is alive, hollow when it is not. Adapts to light/dark theme.
|
|
this._dotFilled = false;
|
|
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 -----------------------------------------------------
|
|
|
|
_onSettingsChanged() {
|
|
// Latencies measured against one instance are meaningless for another.
|
|
if (this._settings.get_string('api-url') !== this._apiUrl)
|
|
this._delays.clear();
|
|
this._applySettings();
|
|
this._restartPolling();
|
|
this._poll();
|
|
}
|
|
|
|
_applySettings() {
|
|
this._apiUrl = this._settings.get_string('api-url');
|
|
this._api.setEndpoint(
|
|
this._apiUrl,
|
|
this._settings.get_string('api-secret')
|
|
);
|
|
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._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)
|
|
return;
|
|
this._polling = true;
|
|
const c = this._cancellable;
|
|
try {
|
|
const [conn, proxies] = await Promise.all([
|
|
this._api.getConnections(c),
|
|
this._api.getProxies(c),
|
|
]);
|
|
if (c.is_cancelled())
|
|
return;
|
|
this._updateFromData(conn, proxies);
|
|
this._setState(STATE.ONLINE);
|
|
} catch (e) {
|
|
if (c.is_cancelled())
|
|
return;
|
|
this._setState(STATE.OFFLINE, e);
|
|
} finally {
|
|
this._polling = 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;
|
|
const selectors = [];
|
|
for (const name of Object.keys(map)) {
|
|
const p = map[name];
|
|
if (p && p.type === 'Selector' && Array.isArray(p.all))
|
|
selectors.push({ name, now: p.now, all: p.all });
|
|
}
|
|
|
|
// Panel outbound label: chosen group's current node.
|
|
const panelGroup = this._pickPanelGroup(selectors);
|
|
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
|
|
|
|
// Rebuild the selector section only when the structure changes.
|
|
const signature = JSON.stringify(selectors.map(s => [s.name, s.all]));
|
|
if (signature !== this._selectorSignature) {
|
|
this._rebuildSelectors(selectors);
|
|
this._selectorSignature = signature;
|
|
}
|
|
|
|
// Update current selection + latencies every poll.
|
|
for (const sel of selectors) {
|
|
const stored = this._selectorItems.get(sel.name);
|
|
if (!stored)
|
|
continue;
|
|
stored.subMenu.label.text = `${sel.name}: ${sel.now ?? '—'}`;
|
|
for (const [member, entry] of stored.members) {
|
|
const isCurrent = member === sel.now;
|
|
entry.setOrnament(isCurrent
|
|
? 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(selectors) {
|
|
this._selectorsSection.removeAll();
|
|
this._selectorItems.clear();
|
|
|
|
if (selectors.length === 0) {
|
|
const none = new PopupMenu.PopupMenuItem(_('No selector groups'), {
|
|
reactive: false,
|
|
can_focus: false,
|
|
});
|
|
this._selectorsSection.addMenuItem(none);
|
|
return;
|
|
}
|
|
|
|
for (const sel of selectors) {
|
|
const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${sel.name}: ${sel.now ?? '—'}`, true);
|
|
subMenu.icon.icon_name = 'network-workgroup-symbolic';
|
|
|
|
// Manual latency probe for all nodes in this group. Override
|
|
// activate() so clicking it runs the probe without closing the menu.
|
|
const testItem = new PopupMenu.PopupImageMenuItem(_('Test latency'), 'view-refresh-symbolic');
|
|
testItem.add_style_class_name('sbx-test-item');
|
|
testItem.activate = () => this._testLatencies(sel.name);
|
|
subMenu.menu.addMenuItem(testItem);
|
|
|
|
const members = new Map();
|
|
for (const member of sel.all) {
|
|
const item = new PopupMenu.PopupMenuItem(member);
|
|
item.connect('activate', () => this._selectOutbound(sel.name, member));
|
|
subMenu.menu.addMenuItem(item);
|
|
members.set(member, item);
|
|
}
|
|
this._selectorsSection.addMenuItem(subMenu);
|
|
this._selectorItems.set(sel.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')}: —`;
|
|
}
|
|
|
|
_pickPanelGroup(selectors) {
|
|
if (selectors.length === 0)
|
|
return null;
|
|
if (this._selectorPref) {
|
|
const match = selectors.find(s => s.name === this._selectorPref);
|
|
if (match)
|
|
return match;
|
|
}
|
|
return selectors[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) {
|
|
this._probing.add(member);
|
|
item.label.text = `${member} · …`;
|
|
this._api.getDelay(member, {}, 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);
|
|
this._poll();
|
|
} catch (e) {
|
|
Main.notifyError(_('sing-box'), `${_('Failed to switch outbound')}: ${e.message}`);
|
|
logError(e, 'sing-box-status: selectProxy failed');
|
|
}
|
|
}
|
|
|
|
// ---- State / visuals ---------------------------------------------
|
|
|
|
_setState(state, error) {
|
|
this._state = state;
|
|
|
|
switch (state) {
|
|
case STATE.ONLINE:
|
|
this._dotFilled = true;
|
|
this._statusItem.label.text = _('sing-box · Active');
|
|
break;
|
|
case STATE.OFFLINE:
|
|
this._dotFilled = false;
|
|
this._statusItem.label.text = _('sing-box · Unreachable');
|
|
this._outboundLabel.text = '';
|
|
this._speedLabel.text = '';
|
|
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
|
|
this._prevSample = null;
|
|
this._clearMenuData();
|
|
break;
|
|
default:
|
|
this._dotFilled = false;
|
|
this._statusItem.label.text = _('sing-box · Connecting…');
|
|
break;
|
|
}
|
|
this._dot.queue_repaint();
|
|
}
|
|
|
|
_drawDot(area) {
|
|
const cr = area.get_context();
|
|
const [w, h] = area.get_surface_size();
|
|
const color = area.get_theme_node().get_foreground_color();
|
|
const r = color.red / 255;
|
|
const g = color.green / 255;
|
|
const b = color.blue / 255;
|
|
const a = color.alpha / 255;
|
|
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const radius = Math.min(w, h) / 2 - 2;
|
|
|
|
cr.setLineWidth(1.5);
|
|
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
|
if (this._dotFilled) {
|
|
cr.setSourceRGBA(r, g, b, a);
|
|
cr.fillPreserve();
|
|
cr.stroke();
|
|
} else {
|
|
// Hollow ring, slightly dimmed so "off" reads as inactive.
|
|
cr.setSourceRGBA(r, g, b, a * 0.6);
|
|
cr.stroke();
|
|
}
|
|
cr.$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;
|
|
}
|
|
if (this._settingsChangedId) {
|
|
this._settings.disconnect(this._settingsChangedId);
|
|
this._settingsChangedId = 0;
|
|
}
|
|
super.destroy();
|
|
}
|
|
});
|