Add GNOME Shell extension showing sing-box status via the Clash API

This commit is contained in:
av
2026-07-18 15:25:26 +03:00
commit 629e031845
10 changed files with 893 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
schemas/gschemas.compiled
+84
View File
@@ -0,0 +1,84 @@
# sing-box Status — расширение для GNOME Shell
Показывает статус прокси [sing-box](https://sing-box.sagernet.org/) в верхней панели
GNOME, используя встроенный **Clash API**.
## Что показывает
**В панели (компактно):**
- монохромный индикатор-кружок — залит, когда API доступен и прокси жив; полый
(тускнее), когда API недоступен. Цвет берётся из цвета текста панели, поэтому
индикатор одинаково читается на тёмной и светлой теме;
- имя активного outbound (текущий узел Selector-группы);
- текущую скорость, например `↓2.4M ↑120k`.
**В выпадающем меню (подробности):**
- `sing-box · Active / Unreachable`;
- полная скорость (`↓ 2.4 MB/s ↑ 120 KB/s`);
- каждая Selector-группа отдельным подменю — выбор узла **переключает активный
outbound** (текущий помечен точкой, рядом — последняя измеренная задержка, если она
есть). Пункт **Test latency** в подменю запускает ручной замер задержки всех узлов
группы и обновляет значения на месте (меню при этом не закрывается);
- число активных соединений с разбивкой по outbound;
- **Open dashboard** (MetaCubeXD или ваш дашборд Clash) и **Settings**.
## Требования
- GNOME Shell 4548.
- sing-box с включённым `experimental_clash_api`:
```jsonc
{
"experimental": {
"clash_api": {
"external_controller": "127.0.0.1:9090",
"secret": "ваш-секрет",
"external_ui": "..." // опционально, для дашборда
}
}
}
```
## Установка (из исходников)
```sh
UUID="sing-box-status@anwinged.github.io"
ln -s "$PWD" "$HOME/.local/share/gnome-shell/extensions/$UUID"
glib-compile-schemas schemas/
# На Wayland: выйдите из сессии и войдите снова, чтобы shell зарегистрировал
# новое расширение, затем:
gnome-extensions enable "$UUID"
```
Откройте **Settings** из меню (или `gnome-extensions prefs "$UUID"`) и задайте:
- **API URL** — например `http://127.0.0.1:9090` (ваш `external_controller`);
- **Secret** — ваш `clash_api.secret`;
- **Dashboard URL** — например `http://127.0.0.1:9091`.
## Как это работает
Каждые *N* секунд (по умолчанию 2) расширение опрашивает две точки Clash API:
- `GET /connections` — накопительные `downloadTotal` / `uploadTotal` (скорость — их
приращение за интервал) и список активных соединений с цепочками outbound;
- `GET /proxies` — Selector-группы, их текущий узел и история задержек по узлам.
Переключение outbound выполняется запросом `PUT /proxies/{group}` с телом
`{"name": "..."}`. По умолчанию расширение не шлёт активных замеров задержки —
показываются только те, что sing-box измерил сам. Ручной замер (пункт **Test latency**)
выполняется через `GET /proxies/{name}/delay` только по нажатию.
Сетевой слой (авторизация, разбор ответов, переключение outbound) проверен на живом
API sing-box 1.13.13.
## Файлы
- `extension.js` — точка входа, добавляет кнопку в панель.
- `lib/indicator.js` — виджет панели, меню, цикл опроса, отрисовка.
- `lib/clashApi.js` — асинхронный клиент Clash API на libsoup 3.
- `lib/format.js` — форматирование байтов и скорости.
- `prefs.js` — настройки на Adwaita.
- `schemas/` — схема GSettings.
+16
View File
@@ -0,0 +1,16 @@
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { SingBoxIndicator } from './lib/indicator.js';
export default class SingBoxStatusExtension extends Extension {
enable() {
this._indicator = new SingBoxIndicator(this);
Main.panel.addToStatusArea(this.uuid, this._indicator);
}
disable() {
this._indicator?.destroy();
this._indicator = null;
}
}
+93
View File
@@ -0,0 +1,93 @@
// Minimal async client for the sing-box Clash API, built on libsoup 3.
//
// Endpoints used:
// GET /version liveness + version string
// GET /connections snapshot: totals + active connections
// GET /proxies all proxies and selector groups
// PUT /proxies/{group} {name} switch a Selector group's active node
// GET /proxies/{name}/delay?url=... measure latency of a node
import Soup from 'gi://Soup';
import GLib from 'gi://GLib';
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
export class ClashApi {
constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) {
this._session = new Soup.Session();
this._session.timeout = 5;
this.setEndpoint(url, secret);
}
setEndpoint(url, secret) {
this._baseUrl = String(url || '').replace(/\/+$/, '');
this._secret = secret || '';
}
_message(method, path, body) {
const msg = Soup.Message.new(method, `${this._baseUrl}${path}`);
const headers = msg.get_request_headers();
headers.append('Accept', 'application/json');
if (this._secret)
headers.append('Authorization', `Bearer ${this._secret}`);
if (body !== undefined) {
const bytes = new GLib.Bytes(textEncoder.encode(JSON.stringify(body)));
msg.set_request_body_from_bytes('application/json', bytes);
}
return msg;
}
_request(method, path, body, cancellable) {
return new Promise((resolve, reject) => {
const msg = this._message(method, path, body);
this._session.send_and_read_async(
msg,
GLib.PRIORITY_DEFAULT,
cancellable ?? null,
(session, res) => {
let bytes;
try {
bytes = session.send_and_read_finish(res);
} catch (e) {
reject(e);
return;
}
const status = msg.get_status();
if (status < 200 || status >= 300) {
reject(new Error(`HTTP ${status} ${msg.get_reason_phrase() ?? ''} (${path})`.trim()));
return;
}
try {
const data = bytes?.get_data();
const text = data && data.length ? textDecoder.decode(data) : '';
resolve(text ? JSON.parse(text) : null);
} catch (e) {
reject(e);
}
}
);
});
}
getVersion(cancellable) {
return this._request('GET', '/version', undefined, cancellable);
}
getConnections(cancellable) {
return this._request('GET', '/connections', undefined, cancellable);
}
getProxies(cancellable) {
return this._request('GET', '/proxies', undefined, cancellable);
}
selectProxy(group, name, cancellable) {
return this._request('PUT', `/proxies/${encodeURIComponent(group)}`, { name }, cancellable);
}
getDelay(name, { url = 'https://www.gstatic.com/generate_204', timeout = 3000 } = {}, cancellable) {
const query = `url=${encodeURIComponent(url)}&timeout=${timeout}`;
return this._request('GET', `/proxies/${encodeURIComponent(name)}/delay?${query}`, undefined, cancellable);
}
}
+40
View File
@@ -0,0 +1,40 @@
// Formatting helpers for byte counts and transfer rates.
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const SHORT_UNITS = ['', 'K', 'M', 'G', 'T', 'P'];
function scale(n) {
n = Number(n) || 0;
let i = 0;
while (n >= 1024 && i < UNITS.length - 1) {
n /= 1024;
i += 1;
}
return [n, i];
}
// "2.4 MB", human readable with a space.
export function formatBytes(n) {
const [v, i] = scale(n);
const digits = i === 0 ? 0 : v < 10 ? 1 : v < 100 ? 1 : 0;
return `${v.toFixed(digits)} ${UNITS[i]}`;
}
// Compact for the panel: "2.4M", "120K", "0".
export function formatBytesShort(n) {
const [v, i] = scale(n);
if (i === 0)
return `${Math.round(v)}`;
const digits = v < 10 ? 1 : 0;
return `${v.toFixed(digits)}${SHORT_UNITS[i]}`;
}
// "2.4 MB/s" for the menu.
export function formatSpeed(bytesPerSec) {
return `${formatBytes(bytesPerSec)}/s`;
}
// "2.4M" for the panel (no /s, arrow provides context).
export function formatSpeedShort(bytesPerSec) {
return formatBytesShort(bytesPerSec);
}
+489
View File
@@ -0,0 +1,489 @@
// 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._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() {
this._applySettings();
this._restartPolling();
this._poll();
}
_applySettings() {
this._api.setEndpoint(
this._settings.get_string('api-url'),
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);
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 });
}
}
_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) {
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(() => {
if (this._cancellable.is_cancelled())
return;
item.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 = error
? _('sing-box · Unreachable')
: _('sing-box · Offline');
this._outboundLabel.text = '';
this._speedLabel.text = '';
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
this._prevSample = null;
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();
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"uuid": "sing-box-status@anwinged.github.io",
"name": "sing-box Status",
"description": "Shows sing-box proxy status in the top bar via the Clash API: liveness, active outbound, current speed and connections. Details and outbound switching are available in the drop-down menu.",
"shell-version": ["45", "46", "47", "48"],
"url": "https://github.com/anwinged/sing-box-gnome-extension",
"settings-schema": "org.gnome.shell.extensions.sing-box-status",
"gettext-domain": "sing-box-status",
"version": 1
}
+82
View File
@@ -0,0 +1,82 @@
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class SingBoxStatusPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
const settings = this.getSettings();
const page = new Adw.PreferencesPage({
title: _('General'),
icon_name: 'network-vpn-symbolic',
});
window.add(page);
// --- Connection ------------------------------------------------
const connGroup = new Adw.PreferencesGroup({
title: _('Clash API'),
description: _('Matches experimental_clash_api in your sing-box config.'),
});
page.add(connGroup);
connGroup.add(this._entryRow(settings, 'api-url', _('API URL'),
_('e.g. http://127.0.0.1:9090')));
connGroup.add(this._passwordRow(settings, 'api-secret', _('Secret'),
_('Bearer token; leave empty if none.')));
connGroup.add(this._entryRow(settings, 'dashboard-url', _('Dashboard URL'),
_('Opened by “Open dashboard”.')));
// --- Display ---------------------------------------------------
const dispGroup = new Adw.PreferencesGroup({ title: _('Display') });
page.add(dispGroup);
dispGroup.add(this._switchRow(settings, 'show-outbound', _('Show active outbound'),
_('Show the current outbound name in the top bar.')));
dispGroup.add(this._switchRow(settings, 'show-speed', _('Show speed'),
_('Show upload/download speed in the top bar.')));
dispGroup.add(this._entryRow(settings, 'selector-group', _('Panel selector group'),
_('Selector group shown in the panel. Empty = first one found.')));
const intervalRow = new Adw.SpinRow({
title: _('Refresh interval'),
subtitle: _('Seconds between Clash API polls.'),
adjustment: new Gtk.Adjustment({ lower: 1, upper: 30, step_increment: 1 }),
});
settings.bind('refresh-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
dispGroup.add(intervalRow);
}
_entryRow(settings, key, title, subtitle) {
const row = new Adw.EntryRow({ title });
if (subtitle && row.set_subtitle) // EntryRow gained subtitle later; guard.
row.set_subtitle(subtitle);
row.text = settings.get_string(key);
row.connect('changed', () => settings.set_string(key, row.text));
settings.connect(`changed::${key}`, () => {
const v = settings.get_string(key);
if (row.text !== v)
row.text = v;
});
return row;
}
_passwordRow(settings, key, title, subtitle) {
const row = new Adw.PasswordEntryRow({ title });
row.text = settings.get_string(key);
row.connect('changed', () => settings.set_string(key, row.text));
settings.connect(`changed::${key}`, () => {
const v = settings.get_string(key);
if (row.text !== v)
row.text = v;
});
return row;
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
return row;
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.gnome.shell.extensions.sing-box-status"
path="/org/gnome/shell/extensions/sing-box-status/">
<key name="api-url" type="s">
<default>'http://127.0.0.1:9090'</default>
<summary>Clash API base URL</summary>
<description>Base URL of the sing-box Clash API (experimental_clash_api.external_controller).</description>
</key>
<key name="api-secret" type="s">
<default>''</default>
<summary>Clash API secret</summary>
<description>Secret token for the Clash API (experimental_clash_api.secret). Sent as a Bearer token.</description>
</key>
<key name="dashboard-url" type="s">
<default>'http://127.0.0.1:9091'</default>
<summary>Dashboard URL</summary>
<description>URL opened by the "Open dashboard" menu item.</description>
</key>
<key name="refresh-interval" type="i">
<default>2</default>
<range min="1" max="30"/>
<summary>Refresh interval (seconds)</summary>
<description>How often to poll the Clash API.</description>
</key>
<key name="selector-group" type="s">
<default>''</default>
<summary>Outbound selector group shown in the panel</summary>
<description>Name of the Selector group whose current node is shown in the top bar. Empty means auto-detect the first Selector.</description>
</key>
<key name="show-outbound" type="b">
<default>true</default>
<summary>Show active outbound in the panel</summary>
<description>Show the name of the active outbound next to the speed.</description>
</key>
<key name="show-speed" type="b">
<default>true</default>
<summary>Show speed in the panel</summary>
<description>Show upload/download speed in the top bar.</description>
</key>
</schema>
</schemalist>
+36
View File
@@ -0,0 +1,36 @@
.sbx-panel-box {
spacing: 4px;
}
/* Status dot is drawn in code (St.DrawingArea) using the panel text color. */
.sbx-dot {
width: 16px;
height: 16px;
}
.sbx-outbound-label {
font-weight: bold;
}
.sbx-speed-label {
font-feature-settings: "tnum";
font-size: 0.9em;
}
.sbx-status-item {
font-weight: bold;
}
.sbx-speed-item {
font-feature-settings: "tnum";
}
.sbx-test-item {
font-size: 0.9em;
}
.sbx-badge {
font-feature-settings: "tnum";
padding: 0 6px;
opacity: 0.7;
}