commit 629e031845b4f60e84c96d5cc33256a2833a4761 Author: Anton Vakhrushev Date: Sat Jul 18 15:25:26 2026 +0300 Add GNOME Shell extension showing sing-box status via the Clash API diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b1b3a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +schemas/gschemas.compiled diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce29985 --- /dev/null +++ b/README.md @@ -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 45–48. +- 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. diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..91f830e --- /dev/null +++ b/extension.js @@ -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; + } +} diff --git a/lib/clashApi.js b/lib/clashApi.js new file mode 100644 index 0000000..e3a2184 --- /dev/null +++ b/lib/clashApi.js @@ -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); + } +} diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..449d60e --- /dev/null +++ b/lib/format.js @@ -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); +} diff --git a/lib/indicator.js b/lib/indicator.js new file mode 100644 index 0000000..076bc9b --- /dev/null +++ b/lib/indicator.js @@ -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(); + } +}); diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..73bb368 --- /dev/null +++ b/metadata.json @@ -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 +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..8a71a23 --- /dev/null +++ b/prefs.js @@ -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; + } +} diff --git a/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml new file mode 100644 index 0000000..16561d5 --- /dev/null +++ b/schemas/org.gnome.shell.extensions.sing-box-status.gschema.xml @@ -0,0 +1,42 @@ + + + + + 'http://127.0.0.1:9090' + Clash API base URL + Base URL of the sing-box Clash API (experimental_clash_api.external_controller). + + + '' + Clash API secret + Secret token for the Clash API (experimental_clash_api.secret). Sent as a Bearer token. + + + 'http://127.0.0.1:9091' + Dashboard URL + URL opened by the "Open dashboard" menu item. + + + 2 + + Refresh interval (seconds) + How often to poll the Clash API. + + + '' + Outbound selector group shown in the panel + Name of the Selector group whose current node is shown in the top bar. Empty means auto-detect the first Selector. + + + true + Show active outbound in the panel + Show the name of the active outbound next to the speed. + + + true + Show speed in the panel + Show upload/download speed in the top bar. + + + diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..7430beb --- /dev/null +++ b/stylesheet.css @@ -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; +}