diff --git a/mihomo-control/README.md b/mihomo-control/README.md new file mode 100644 index 00000000..13fbd34f --- /dev/null +++ b/mihomo-control/README.md @@ -0,0 +1,95 @@ +# Mihomo Control + +Monitor and control a Mihomo (Clash Meta) instance right from the Noctalia +bar: live traffic, proxy mode, proxy-group selection, latency tests and active +connections. It talks to the Mihomo external controller, which can run on this +machine (`127.0.0.1`) or on a remote host — just configure the IP, port and +secret. + +## Plugin + +| Field | Value | +| ------- | --------------------------- | +| ID | `mdj2812/mihomo-control` | +| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; shortcut: `mode` | + +## Usage + +Add the **Mihomo Control** widget from the Add-widget picker. It shows the +current proxy mode (rule / global / direct); hover it for the connection +status, live download and upload rates, connection count and each proxy +group's current selection. Left-click the widget to toggle the control panel, +or open it with: + +```sh +noctalia msg panel-toggle mdj2812/mihomo-control:panel +``` + +The panel lets you switch the proxy mode (rule / global / direct), restart the +server, refresh the status, and manage every proxy group. Each group card +lists all of its members with their latency — sourced from mihomo's health +checks and refreshed by the group's **Test latency** button. Each card also +shows an overall latency (the selected member's, or the best tested one) right +in its subtitle, visible without expanding. Member lists are collapsed by +default; click a group header to expand it. Click a member to select it; the +current selection is marked with a dot. Use **Test all** next to the Proxy +groups heading to run a latency test on every group at once. + +Add the **Mihomo: Rule** shortcut from Settings → Control Center shortcuts to +quickly toggle between rule and global mode. + +Before the widget shows anything, enable the plugin's `service` entry — it owns +all communication with the external controller and streams the traffic data. + +## Settings + +| Setting | Type | Default | Description | +| ------------------- | ------- | ------------ | --------------------------------------------------------------------------- | +| Host | string | `127.0.0.1` | Hostname or IP of the Mihomo external controller. | +| Port | string | `9090` | Port of the Mihomo external controller. | +| Secret | string | *(empty)* | `secret` from your Mihomo config; empty when authentication is disabled. | +| HTTPS | bool | off | Use `https://` (external-controller-tls or a TLS reverse proxy). | +| Allow insecure TLS | bool | off | Skip certificate verification for self-signed TLS controllers. | +| Test URL | string | `https://www.gstatic.com/generate_204` | URL used for latency tests; empty uses each group's configured test URL. | +| Refresh interval | int | `2` | Seconds between status polls (1–60); traffic rates stream in real time. | + +## IPC + +The service exposes two IPC events for scripting and debugging: + +```sh +noctalia msg plugin mdj2812/mihomo-control:service all refresh +noctalia msg plugin mdj2812/mihomo-control:service all cmd '{"op":"mode","mode":"global"}' +``` + +`refresh` re-polls version, config, connections and proxy groups. `cmd` accepts +the same command tables the panel sends (`mode`, `select`, `delay_test`, +`delay_test_all`, `restart`, `close_connections`, `refresh`). + +## Notes + +- The plugin only talks HTTP to the configured external controller. It spawns + no processes, runs no external commands, and writes no files. +- The bar widget and card use the Clash cat logo (`icon.png`), the official + mascot of the Clash / mihomo project. In the bar widget the cat is tinted by + connection status: green online, amber while connecting, red offline; the + panel uses the neutral logo next to its own status indicator. +- The traffic rate uses mihomo's streaming `GET /traffic` endpoint; status, + connections and proxy groups are polled every refresh interval. +- The secret is stored in your Noctalia config and sent as the standard + `Authorization: Bearer ` header. It is sent in plain text unless + HTTPS is enabled — use HTTPS for remote controllers. +- **Restart** posts to `/restart`, which re-executes the core; the plugin + reconnects automatically once it is back. There is no API endpoint that + stops the core — switching the mode to `direct` is the closest equivalent. +- A latency test where every node fails is reported as *all nodes timed out*: + mihomo answers the delay endpoint with HTTP 504 in that case. If that keeps + happening, set **Test URL** to an endpoint your network can reach. + +## Development + +- `service.luau` — headless API backend, publishes `mihomo.*` state. +- `widget.luau` — bar widget (rates + tooltip). +- `panel.luau` — control panel. +- `shortcut.luau` — rule/global mode toggle. +- `translations/` — user-facing strings. diff --git a/mihomo-control/icon-connecting.png b/mihomo-control/icon-connecting.png new file mode 100644 index 00000000..b48ff0d1 Binary files /dev/null and b/mihomo-control/icon-connecting.png differ diff --git a/mihomo-control/icon-offline.png b/mihomo-control/icon-offline.png new file mode 100644 index 00000000..9117b61e Binary files /dev/null and b/mihomo-control/icon-offline.png differ diff --git a/mihomo-control/icon-online.png b/mihomo-control/icon-online.png new file mode 100644 index 00000000..a4cfddb7 Binary files /dev/null and b/mihomo-control/icon-online.png differ diff --git a/mihomo-control/icon.png b/mihomo-control/icon.png new file mode 100644 index 00000000..c624b94d Binary files /dev/null and b/mihomo-control/icon.png differ diff --git a/mihomo-control/panel.luau b/mihomo-control/panel.luau new file mode 100644 index 00000000..955edfa5 --- /dev/null +++ b/mihomo-control/panel.luau @@ -0,0 +1,482 @@ +--!nonstrict +-- Mihomo Control — control panel. +-- +-- A settings-style panel driven by the service's "mihomo.*" state. Every +-- action goes through the "mihomo.command" channel so the service stays the +-- single owner of HTTP access. +-- +-- Sections: +-- 1. Header: title, connection status, close button. +-- 2. Connection: mode select (rule/global/direct) and refresh button. +-- 3. Traffic: live up/down rates, totals, memory, active connections, and +-- a "close all connections" action. +-- 4. Proxy groups: one card per group — current selection, member select, +-- and a latency-test button. + +local MODES = { "rule", "global", "direct" } + +local snapshot = { + connection = {}, + config = {}, + traffic = {}, + connections = {}, + groups = {}, +} + +-- Rolling traffic-rate history for the graph: one sample per published value +-- change, capped so the window stays fixed. +local TRAFFIC_HISTORY = 90 +local down_history = {} +local up_history = {} + +local function record_traffic(traffic) + local down = tonumber(traffic.down) or 0 + local up = tonumber(traffic.up) or 0 + local last_index = #down_history + if last_index == 0 or down_history[last_index] ~= down or up_history[last_index] ~= up then + table.insert(down_history, down) + table.insert(up_history, up) + if #down_history > TRAFFIC_HISTORY then + table.remove(down_history, 1) + table.remove(up_history, 1) + end + end +end + +local function normalized_series(values, peak) + local out = {} + for i, value in ipairs(values) do + out[i] = peak > 0 and value / peak or 0 + end + return out +end + +local function refresh_snapshot() + snapshot.connection = noctalia.state.get("mihomo.connection") or snapshot.connection + snapshot.config = noctalia.state.get("mihomo.config") or snapshot.config + snapshot.traffic = noctalia.state.get("mihomo.traffic") or snapshot.traffic + snapshot.connections = noctalia.state.get("mihomo.connections") or snapshot.connections + snapshot.groups = noctalia.state.get("mihomo.groups") or snapshot.groups + record_traffic(snapshot.traffic) +end + +local function send_command(cmd) + cmd.seq = math.floor(os.clock() * 1000000) + noctalia.state.set("mihomo.command", cmd) +end + +-- Expansion state for proxy-group cards: collapsed by default so long member +-- lists do not flood the panel. Persists across re-renders while the panel +-- script stays loaded; a fresh panel starts collapsed again. +local expanded_groups = {} +local render -- forward declaration; assigned below + +local function toggle_group(group_name) + expanded_groups[group_name] = not expanded_groups[group_name] + render() +end + +-- ── Formatting helpers (small duplicates per the plugin sandbox) ──────────── + +local function format_rate(bytes_per_second) + local value = tonumber(bytes_per_second) or 0 + if value >= 1024 * 1024 * 1024 then + return string.format("%.2f GB/s", value / (1024 * 1024 * 1024)) + end + if value >= 1024 * 1024 then + return string.format("%.1f MB/s", value / (1024 * 1024)) + end + if value >= 1024 then + return string.format("%.1f KB/s", value / 1024) + end + return string.format("%d B/s", value) +end + +local function format_total(bytes) + local value = tonumber(bytes) or 0 + if value >= 1024 ^ 4 then + return string.format("%.2f TB", value / (1024 ^ 4)) + end + if value >= 1024 ^ 3 then + return string.format("%.2f GB", value / (1024 ^ 3)) + end + if value >= 1024 ^ 2 then + return string.format("%.1f MB", value / (1024 ^ 2)) + end + if value >= 1024 then + return string.format("%.1f KB", value / 1024) + end + return string.format("%d B", value) +end + +local function latency_text(delay) + if delay == nil then + return "—" + end + if delay > 0 then + return `{delay} ms` + end + return noctalia.tr("panel.timeout") +end + +local function latency_color(delay) + if delay == nil then + return "on_surface_variant" + end + if delay > 0 then + if delay <= 300 then + return "primary" + end + if delay <= 800 then + return "warning" + end + return "error" + end + return "error" +end + +-- The status chip keeps the classic green/amber/red identity instead of theme +-- roles, but uses darker shades in light mode so it stays readable on light +-- surfaces. +local function status_color() + if noctalia.isDarkMode() then + return { online = "#4ade80", connecting = "#facc15", offline = "#f87171" } + end + return { online = "#15803d", connecting = "#a16207", offline = "#b91c1c" } +end + +-- A single "overall" latency per group: the currently selected member's delay +-- (Selector/URLTest/Fallback), or the best tested member latency otherwise +-- (LoadBalance has no selection). Returns 0 when every tested member failed. +local function group_latency(group) + if group.now and group.now ~= "" then + for _, member in ipairs(group.members) do + if member.name == group.now and member.delay ~= nil then + return member.delay + end + end + end + + local best = nil + local any_tested = false + for _, member in ipairs(group.members) do + local delay = member.delay + if delay ~= nil then + any_tested = true + if delay > 0 and (best == nil or delay < best) then + best = delay + end + end + end + if best ~= nil then + return best + end + if any_tested then + return 0 + end + return nil +end + +-- ── UI helpers ────────────────────────────────────────────────────────────── + +local function connection_text() + local conn = snapshot.connection + local endpoint = `{conn.host or ""}:{conn.port or ""}` + if conn.status == "online" then + local version = tostring(conn.version or "") + return version ~= "" + and noctalia.tr("panel.online_with_version", { version = version, endpoint = endpoint }) + or noctalia.tr("panel.online_plain", { endpoint = endpoint }) + end + if conn.status == "connecting" then + return endpoint + end + local err = tostring(conn.error or "") + return err ~= "" + and noctalia.tr("panel.offline_with_error", { error = err }) + or noctalia.tr("panel.offline_plain", { endpoint = endpoint }) +end + +local function status_chip() + local conn = snapshot.connection + local online = conn.status == "online" + local connecting = conn.status == "connecting" + local colors = status_color() + local text = online + and noctalia.tr("panel.status_online") + or (connecting and noctalia.tr("panel.status_connecting") or noctalia.tr("panel.status_offline")) + local color = online and colors.online or (connecting and colors.connecting or colors.offline) + return ui.row({ gap = 6, align = "center", fill = "surface/0.6", radius = 999, paddingH = 10, paddingV = 4 }, { + ui.box({ width = 8, height = 8, radius = 4, fill = color }), + ui.label({ text = text, fontSize = 12, color = "on_surface_variant" }), + }) +end + +local function controls_row() + local mode = snapshot.config.mode or "rule" + local index = 0 + for i, candidate in MODES do + if candidate == mode then + index = i - 1 + break + end + end + return ui.row({ gap = 8, align = "center" }, { + ui.select({ + options = { + noctalia.tr("panel.mode_rule"), + noctalia.tr("panel.mode_global"), + noctalia.tr("panel.mode_direct"), + }, + selectedIndex = index, + onChange = function(index_text) + local chosen = MODES[(tonumber(index_text) or 0) + 1] + if chosen then + send_command({ op = "mode", mode = chosen }) + end + end, + }), + ui.button({ + text = noctalia.tr("panel.restart"), + variant = "outline", + onClick = function() + send_command({ op = "restart" }) + end, + }), + }) +end + +local function traffic_card() + local traffic = snapshot.traffic + local connections = snapshot.connections + local peak = 1 + for _, value in ipairs(down_history) do + if value > peak then + peak = value + end + end + for _, value in ipairs(up_history) do + if value > peak then + peak = value + end + end + return ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = noctalia.tr("panel.traffic"), fontWeight = "medium", color = "on_surface_variant" }), + ui.label({ + text = `{connections.count or 0} {noctalia.tr("panel.connections")}`, + color = "on_surface_variant", + fontSize = 12, + }), + }), + ui.graph({ + values = normalized_series(down_history, peak), + values2 = normalized_series(up_history, peak), + color = "primary", + color2 = "secondary", + lineWidth = 2, + fillOpacity = 0.15, + height = 72, + }), + ui.row({ gap = 16, align = "center" }, { + ui.label({ text = `▼ {format_rate(traffic.down)}`, fontSize = 16, fontWeight = "bold", color = "primary" }), + ui.label({ text = `▲ {format_rate(traffic.up)}`, fontSize = 16, fontWeight = "bold", color = "secondary" }), + }), + ui.row({ gap = 16, align = "center" }, { + ui.label({ + text = `↓ {format_total(traffic.downTotal)} · ↑ {format_total(traffic.upTotal)} · {format_total(connections.memory)} {noctalia.tr("panel.memory")}`, + color = "on_surface_variant", + fontSize = 12, + }), + }), + }) +end + +local function group_card(group) + local name_text = group.name + if group.hidden == true then + name_text = name_text .. ` ({noctalia.tr("panel.hidden")})` + end + local expanded = expanded_groups[group.name] == true + local overall = group_latency(group) + + local name_children = { + ui.label({ text = name_text, fontWeight = "bold" }), + } + if group.now and group.now ~= "" then + table.insert(name_children, ui.label({ text = ">", color = "on_surface_variant" })) + table.insert(name_children, ui.label({ + text = group.now, + color = "on_surface_variant", + fontSize = 12, + flexGrow = 1, + })) + end + + local children = { + ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function() + toggle_group(group.name) + end }, { + ui.row({ gap = 4, align = "center", flexGrow = 1 }, name_children), + ui.button({ + glyph = "activity", + tooltip = noctalia.tr("panel.delay_test"), + variant = "ghost", + onClick = function() + send_command({ op = "delay_test", group = group.name }) + end, + }), + }), + ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function() + toggle_group(group.name) + end }, { + ui.button({ + glyph = expanded and "chevron-down" or "chevron-right", + variant = "ghost", + onClick = function() + toggle_group(group.name) + end, + }), + ui.label({ + text = `{group.type or ""} · {noctalia.trp("panel.members_count", #group.members)}`, + color = "on_surface_variant", + fontSize = 12, + flexGrow = 1, + }), + ui.label({ + text = latency_text(overall), + fontSize = 12, + fontWeight = "medium", + color = latency_color(overall), + }), + }), + } + + if expanded and #group.members > 0 then + local member_rows = {} + for _, member in ipairs(group.members) do + local is_current = member.name == group.now + local delay_text = latency_text(member.delay) + local delay_color = latency_color(member.delay) + table.insert(member_rows, ui.row({ gap = 6, align = "center", onClick = function() + send_command({ op = "select", group = group.name, proxy = member.name }) + end }, { + ui.box({ width = 6, height = 6, radius = 3, fill = is_current and "primary" or "surface_variant" }), + ui.label({ + text = member.name, + flexGrow = 1, + color = is_current and "on_surface" or "on_surface_variant", + fontSize = 12, + }), + ui.label({ text = delay_text, fontSize = 12, color = delay_color }), + })) + end + table.insert(children, ui.column({ gap = 3 }, member_rows)) + elseif expanded then + table.insert(children, ui.label({ + text = noctalia.tr("panel.no_members"), + color = "on_surface_variant", + fontSize = 12, + })) + end + + return ui.column({ + gap = 8, + border = "outline", + borderWidth = 1, + radius = 10, + padding = 10, + flexGrow = 1, + }, children) +end + +render = function() + local groups = type(snapshot.groups) == "table" and snapshot.groups or {} + + local body = { + controls_row(), + traffic_card(), + } + + if #groups > 0 then + local group_children = { + ui.row({ align = "center", gap = 8 }, { + ui.label({ + text = noctalia.tr("panel.proxy_groups"), + fontWeight = "medium", + color = "on_surface_variant", + flexGrow = 1, + }), + ui.button({ + text = noctalia.tr("panel.test_all"), + variant = "ghost", + onClick = function() + send_command({ op = "delay_test_all" }) + end, + }), + }), + } + for i = 1, #groups, 2 do + local row_children = { group_card(groups[i]) } + if groups[i + 1] then + table.insert(row_children, group_card(groups[i + 1])) + else + table.insert(row_children, ui.box({ flexGrow = 1 })) + end + table.insert(group_children, ui.row({ gap = 10, align = "start" }, row_children)) + end + table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, group_children)) + else + table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, { + ui.label({ text = noctalia.tr("panel.proxy_groups"), fontWeight = "medium", color = "on_surface_variant" }), + ui.label({ text = noctalia.tr("panel.no_groups"), color = "on_surface_variant" }), + })) + end + + panel.render(ui.column({ flexGrow = 1, gap = 12 }, { + ui.column({ gap = 6 }, { + ui.row({ align = "center", gap = 8 }, { + ui.image({ path = "icon.png", width = 16, height = 16 }), + ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + status_chip(), + ui.button({ + glyph = "refresh", + tooltip = noctalia.tr("panel.refresh"), + variant = "ghost", + onClick = function() + send_command({ op = "refresh" }) + end, + }), + ui.button({ + glyph = "close", + onClick = function() + panel.close() + end, + }), + }), + ui.label({ text = connection_text(), color = "on_surface_variant", fontSize = 12 }), + }), + ui.scroll({ flexGrow = 1, gap = 12 }, body), + })) +end + +for _, key in { + "mihomo.connection", + "mihomo.config", + "mihomo.traffic", + "mihomo.connections", + "mihomo.groups", +} do + noctalia.state.watch(key, function(_value) + refresh_snapshot() + render() + end) +end + +function onOpen(_context) + refresh_snapshot() + render() +end + +refresh_snapshot() +render() diff --git a/mihomo-control/plugin.toml b/mihomo-control/plugin.toml new file mode 100644 index 00000000..8d135645 --- /dev/null +++ b/mihomo-control/plugin.toml @@ -0,0 +1,94 @@ +# Mihomo Control — monitor and control a Mihomo (Clash Meta) external +# controller: live traffic, proxy mode, group selection, latency tests and +# active connections, locally (127.0.0.1) or on a remote host. +# +# The service entry owns every HTTP request and publishes "mihomo.*" state; +# the widget, panel and shortcut are pure subscribers and send commands +# through the "mihomo.command" state channel. + +id = "mdj2812/mihomo-control" +name = "Mihomo Control" +version = "0.1.0" +plugin_api = 9 +author = "mdj2812" +license = "MIT" +icon = "cat" +description = "Monitor and control Mihomo (Clash Meta) — live traffic, proxy mode, group selection, and connections, local or remote." +tags = ["network", "indicator", "utility", "bar", "panel", "service", "shortcut"] +dependencies = [] + +# ── Shared settings ────────────────────────────────────────────────────────── +[[setting]] +key = "host" +type = "string" +default = "127.0.0.1" +label_key = "settings.host.label" +description_key = "settings.host.description" + +[[setting]] +key = "port" +type = "string" +default = "9090" +label_key = "settings.port.label" +description_key = "settings.port.description" + +[[setting]] +key = "secret" +type = "string" +default = "" +label_key = "settings.secret.label" +description_key = "settings.secret.description" + +[[setting]] +key = "use_https" +type = "bool" +default = false +advanced = true +label_key = "settings.use_https.label" +description_key = "settings.use_https.description" + +[[setting]] +key = "allow_insecure_tls" +type = "bool" +default = false +advanced = true +label_key = "settings.allow_insecure_tls.label" +description_key = "settings.allow_insecure_tls.description" + +[[setting]] +key = "test_url" +type = "string" +default = "https://www.gstatic.com/generate_204" +label_key = "settings.test_url.label" +description_key = "settings.test_url.description" + +[[setting]] +key = "refresh_interval" +type = "int" +default = 2 +min = 1 +max = 60 +advanced = true +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" + +# ── Entries ────────────────────────────────────────────────────────────────── +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 560 +height = 720 +placement = "floating" +position = "center" + +[[service]] +id = "service" +entry = "service.luau" + +[[shortcut]] +id = "mode" +entry = "shortcut.luau" diff --git a/mihomo-control/service.luau b/mihomo-control/service.luau new file mode 100644 index 00000000..bd0f24b3 --- /dev/null +++ b/mihomo-control/service.luau @@ -0,0 +1,613 @@ +--!nonstrict +-- Mihomo Control — background service. +-- +-- This entry owns every request to the Mihomo external controller +-- (http(s)://host:port) and publishes the results as shared "mihomo.*" state. +-- The widget, panel and shortcut never touch the network; to make the service +-- act they write a command table to "mihomo.command": +-- +-- { op = "select", group = "", proxy = "" } +-- { op = "mode", mode = "rule" | "global" | "direct" } +-- { op = "delay_test", group = "" } +-- { op = "restart" } POST /restart (core re-execs) +-- { op = "close_connections" } +-- { op = "refresh" } +-- +-- Endpoints used (MetaCubeX/Meta-Docs "API" reference): +-- GET /version /configs /connections /proxies polling +-- GET /traffic streamed (httpStream) +-- PATCH /configs {"mode": ...} switch proxy mode +-- PUT /proxies/ {"name": ...} select group member +-- GET /group//delay?url=..&timeout=.. latency test +-- DELETE /connections close all connections + +local MODES = { rule = true, global = true, direct = true } +local DEFAULT_TEST_URL = "https://www.gstatic.com/generate_204" +local GROUPS_REFRESH_EVERY = 5 -- every Nth tick, re-fetch /proxies +local STREAM_RETRY_EVERY = 5 -- ticks between traffic-stream retry attempts + +-- ── Settings ──────────────────────────────────────────────────────────────── + +local settings = { + host = "127.0.0.1", + port = 9090, + secret = "", + use_https = false, + insecure = false, + test_url = "", + interval = 2, +} + +local function reload_settings() + local host = noctalia.getConfig("host") + local port = noctalia.getConfig("port") + local secret = noctalia.getConfig("secret") + local test_url = noctalia.getConfig("test_url") + local interval = tonumber(noctalia.getConfig("refresh_interval")) or 2 + + settings.host = type(host) == "string" and host ~= "" and host or "127.0.0.1" + local port_number = tonumber(port) + settings.port = (type(port_number) == "number" and port_number >= 1 and port_number <= 65535) + and math.floor(port_number) + or 9090 + settings.secret = type(secret) == "string" and secret or "" + settings.use_https = noctalia.getConfig("use_https") == true + settings.insecure = noctalia.getConfig("allow_insecure_tls") == true + settings.test_url = type(test_url) == "string" and noctalia.string.trim(test_url) or "" + settings.interval = math.max(1, math.min(60, interval)) +end + +local function base_url() + local scheme = settings.use_https and "https" or "http" + return `{scheme}://{settings.host}:{settings.port}` +end + +local function auth_headers() + if settings.secret == "" then + return {} + end + return { `Authorization: Bearer {settings.secret}` } +end + +local function json_headers() + local headers = { "Content-Type: application/json" } + for _, header in auth_headers() do + table.insert(headers, header) + end + return headers +end + +-- ── Shared state ──────────────────────────────────────────────────────────── + +local state = { + connection = { + status = "connecting", -- connecting | online | offline + host = settings.host, + port = settings.port, + version = "", + meta = false, + error = "", + }, + config = { mode = "rule", mixedPort = 0, port = 0, socksPort = 0, allowLan = false, ipv6 = false }, + traffic = { up = 0, down = 0, upTotal = 0, downTotal = 0 }, + connections = { count = 0, downloadTotal = 0, uploadTotal = 0, memory = 0 }, + groups = {}, + delay = {}, +} + +local function publish(key) + noctalia.state.set("mihomo." .. key, state[key]) +end + +local function set_online() + state.connection.status = "online" + state.connection.error = "" + publish("connection") +end + +local function set_offline(err) + state.connection.status = "offline" + state.connection.error = err or "" + publish("connection") +end + +-- ── HTTP helpers ──────────────────────────────────────────────────────────── + +local function request(method, path, headers, body, on_done) + local req = { + url = base_url() .. path, + method = method, + headers = headers, + } + if body ~= nil then + req.body = noctalia.json.encode(body) + end + if settings.insecure then + req.allow_insecure_tls = true + end + noctalia.http(req, function(res) + on_done(res) + end) +end + +local function success(res) + return res.ok and res.status >= 200 and res.status < 300 +end + +local function decode(body) + local ok, data = pcall(noctalia.json.decode, body) + if ok and type(data) == "table" then + return data + end + return nil +end + +-- ── Polling ───────────────────────────────────────────────────────────────── + +local function poll_version() + request("GET", "/version", auth_headers(), nil, function(res) + if not success(res) then + local reason + if res.status == 401 then + reason = "unauthorized" + elseif not res.ok then + reason = "connection failed" + else + reason = `HTTP {res.status}` + end + set_offline(reason) + return + end + local data = decode(res.body) + if data then + -- Some builds report "v1.19.0", others "1.19.0"; normalize so the UI + -- never shows a doubled or forced "v". + state.connection.version = tostring(data.version or ""):gsub("^v", "") + state.connection.meta = data.meta == true + set_online() + end + end) +end + +local function poll_configs() + request("GET", "/configs", auth_headers(), nil, function(res) + if not success(res) then + return + end + local data = decode(res.body) + if data then + state.config.mode = type(data.mode) == "string" and data.mode or state.config.mode + state.config.mixedPort = tonumber(data["mixed-port"]) or 0 + state.config.port = tonumber(data.port) or 0 + state.config.socksPort = tonumber(data["socks-port"]) or 0 + state.config.allowLan = data["allow-lan"] == true + state.config.ipv6 = data.ipv6 == true + publish("config") + end + end) +end + +local function poll_connections() + request("GET", "/connections", auth_headers(), nil, function(res) + if not success(res) then + return + end + local data = decode(res.body) + if data then + state.connections.count = type(data.connections) == "table" and #data.connections or 0 + state.connections.downloadTotal = tonumber(data.downloadTotal) or 0 + state.connections.uploadTotal = tonumber(data.uploadTotal) or 0 + state.connections.memory = tonumber(data.memory) or 0 + publish("connections") + end + end) +end + +-- ── Proxy groups ──────────────────────────────────────────────────────────── + +local function merge_delays(group_name) + local delays = state.delay[group_name] + if type(delays) ~= "table" or type(delays.byName) ~= "table" then + return + end + for _, group in state.groups do + if group.name == group_name then + for _, member in group.members do + local delay = delays.byName[member.name] + if delay ~= nil then + member.delay = delay + end + end + end + end +end + +local function poll_proxies() + request("GET", "/proxies", auth_headers(), nil, function(res) + if not success(res) then + return + end + local data = decode(res.body) + if not data or type(data.proxies) ~= "table" then + return + end + + -- Every proxy (and nested group) carries a `history` array with the last + -- known delay from mihomo's health checks; 0 means the last probe failed. + -- Use the newest entry as each proxy's latency so the panel can show + -- per-node latencies without waiting for a manual test. + local proxy_delays = {} + for name, info in data.proxies do + if type(info) == "table" and type(info.history) == "table" and #info.history > 0 then + local last = info.history[#info.history] + if type(last) == "table" and last.delay ~= nil then + proxy_delays[tostring(name)] = tonumber(last.delay) or 0 + end + end + end + + local groups = {} + for name, info in data.proxies do + -- Any entry with an `all` list is a proxy group (Selector, URLTest, + -- Fallback, LoadBalance, and Relay on forks that still ship it). + -- Filtering by type would silently drop group kinds we did not think of. + if type(info) == "table" and type(info.all) == "table" then + local members = {} + for _, member_name in info.all do + table.insert(members, { + name = tostring(member_name), + delay = proxy_delays[tostring(member_name)], + }) + end + table.insert(groups, { + name = tostring(name), + type = tostring(info.type), + now = info.now ~= nil and tostring(info.now) or nil, + hidden = info.hidden == true, + testUrl = type(info.testUrl) == "string" and info.testUrl or DEFAULT_TEST_URL, + members = members, + }) + end + end + table.sort(groups, function(a, b) + return a.name < b.name + end) + state.groups = groups + for _, group in groups do + merge_delays(group.name) + end + publish("groups") + end) +end + +-- ── Traffic stream ────────────────────────────────────────────────────────── + +local traffic_stream = nil +local stream_attempt_tick = -100 + +local function start_traffic_stream() + if traffic_stream ~= nil then + return + end + traffic_stream = noctalia.httpStream( + { + url = base_url() .. "/traffic", + headers = auth_headers(), + allow_insecure_tls = settings.insecure, + }, + function(line) + if line == "" then + return + end + local data = decode(line) + if data then + state.traffic.up = tonumber(data.up) or state.traffic.up + state.traffic.down = tonumber(data.down) or state.traffic.down + state.traffic.upTotal = tonumber(data.upTotal) or state.traffic.upTotal + state.traffic.downTotal = tonumber(data.downTotal) or state.traffic.downTotal + publish("traffic") + end + end, + function(result) + traffic_stream = nil + if result and not result.ok then + stream_attempt_tick = -1 -- retry soon + end + end + ) + if traffic_stream == nil then + stream_attempt_tick = -1 -- could not start; retry later + end +end + +-- ── Commands ──────────────────────────────────────────────────────────────── + +local last_cmd_seq = 0 + +local function run_delay_test(group_name, opts) + local silent = opts ~= nil and opts.silent == true + local on_done = opts ~= nil and opts.on_done + local function finish() + if on_done then + on_done() + end + end + + local group = nil + for _, candidate in state.groups do + if candidate.name == group_name then + group = candidate + end + end + local test_url = settings.test_url ~= "" + and settings.test_url + or (group and group.testUrl or DEFAULT_TEST_URL) + local path = "/group/" + .. noctalia.string.urlEncode(group_name) + .. "/delay?url=" + .. noctalia.string.urlEncode(test_url) + .. "&timeout=5000" + + request("GET", path, auth_headers(), nil, function(res) + if res.status == 504 then + -- mihomo reports a failed group latency test as HTTP 504 Gateway Timeout + -- (every node timed out or errored). That is a test result, not a + -- request failure, so surface it as such and drop stale latencies. + state.delay[group_name] = { tested = 0, timedOut = true, byName = {}, at = os.time() } + for _, candidate in state.groups do + if candidate.name == group_name then + for _, member in candidate.members do + member.delay = 0 -- every member failed, show as timeout + end + end + end + publish("groups") + publish("delay") + if not silent then + noctalia.notify(noctalia.tr("notify.delay_done"), noctalia.tr("notify.delay_timeout")) + end + finish() + return + end + if not success(res) then + if not silent then + noctalia.notifyError(noctalia.tr("notify.request_failed"), `HTTP {res.status}`) + end + finish() + return + end + local data = decode(res.body) + if not data then + finish() + return + end + + local count = 0 + local by_name = {} + for name, delay in data do + count += 1 + by_name[tostring(name)] = tonumber(delay) + end + + local selected_delay = nil + local selected = group and group.now + if selected and by_name[selected] ~= nil then + selected_delay = by_name[selected] + end + + state.delay[group_name] = { + tested = count, + selectedDelay = selected_delay, + byName = by_name, + at = os.time(), + } + merge_delays(group_name) + publish("groups") + publish("delay") + + local message = selected_delay ~= nil + and noctalia.tr("notify.delay_body", { count = count, delay = selected_delay }) + or noctalia.tr("notify.delay_body_no_selection", { count = count }) + if not silent then + noctalia.notify(noctalia.tr("notify.delay_done"), message) + end + finish() + end) +end + +-- Run latency tests for every group, one at a time, with a single summary +-- notification at the end. +local function test_all_groups() + local queue = {} + for _, group in state.groups do + table.insert(queue, group.name) + end + if #queue == 0 then + return + end + + local index = 1 + local function next_test() + if index > #queue then + noctalia.notify(noctalia.tr("notify.delay_all_done")) + return + end + local name = queue[index] + index += 1 + run_delay_test(name, { silent = true, on_done = next_test }) + end + next_test() +end + +local function handle_command(cmd) + local op = cmd.op + if op == "refresh" then + poll_version() + poll_configs() + poll_connections() + poll_proxies() + return + end + if op == "mode" then + local mode = cmd.mode + if MODES[mode] then + request("PATCH", "/configs", json_headers(), { mode = mode }, function(res) + if success(res) then + state.config.mode = mode + publish("config") + noctalia.notify( + noctalia.tr("notify.mode_changed"), + noctalia.tr("panel.mode_" .. mode) + ) + else + noctalia.notifyError(noctalia.tr("notify.request_failed"), `HTTP {res.status}`) + end + end) + end + return + end + if op == "select" then + local group = tostring(cmd.group or "") + local proxy = tostring(cmd.proxy or "") + if group ~= "" and proxy ~= "" then + local path = "/proxies/" .. noctalia.string.urlEncode(group) + request("PUT", path, json_headers(), { name = proxy }, function(res) + if success(res) then + for _, candidate in state.groups do + if candidate.name == group then + candidate.now = proxy + end + end + publish("groups") + noctalia.notify( + noctalia.tr("notify.proxy_selected"), + noctalia.tr("notify.proxy_selected_body", { group = group, proxy = proxy }) + ) + else + noctalia.notifyError(noctalia.tr("notify.request_failed"), `HTTP {res.status}`) + end + end) + end + return + end + if op == "delay_test_all" then + test_all_groups() + return + end + if op == "delay_test" then + local group = tostring(cmd.group or "") + if group ~= "" then + run_delay_test(group) + end + return + end + if op == "restart" then + state.connection.status = "connecting" + state.connection.error = "" + publish("connection") + noctalia.notify(noctalia.tr("notify.restarting")) + request("POST", "/restart", auth_headers(), nil, function(res) + -- The core re-execs and drops the connection mid-response; a transport + -- failure with no status is the expected outcome, anything else is an error. + if success(res) or (not res.ok and res.status == 0) then + return + end + noctalia.notifyError(noctalia.tr("notify.request_failed"), `HTTP {res.status}`) + end) + return + end + if op == "close_connections" then + request("DELETE", "/connections", auth_headers(), nil, function(res) + if success(res) then + state.connections.count = 0 + publish("connections") + noctalia.notify(noctalia.tr("notify.connections_closed")) + else + noctalia.notifyError(noctalia.tr("notify.request_failed"), `HTTP {res.status}`) + end + end) + end +end + +noctalia.state.watch("mihomo.command", function(cmd) + if type(cmd) ~= "table" then + return + end + local seq = tonumber(cmd.seq) or 0 + if seq <= last_cmd_seq then + return + end + last_cmd_seq = seq + handle_command(cmd) +end) + +-- ── Entry-point callbacks ─────────────────────────────────────────────────── + +local tick = 0 + +function update() + tick += 1 + noctalia.setUpdateInterval(settings.interval * 1000) + + poll_version() + poll_configs() + poll_connections() + if tick % GROUPS_REFRESH_EVERY == 1 then + poll_proxies() + end + + if traffic_stream == nil and tick - stream_attempt_tick >= STREAM_RETRY_EVERY then + stream_attempt_tick = tick + start_traffic_stream() + end +end + +function onConfigChanged() + reload_settings() + state.connection.host = settings.host + state.connection.port = settings.port + publish("connection") + + if traffic_stream ~= nil then + traffic_stream.stop() + traffic_stream = nil + end + stream_attempt_tick = -1 + + poll_version() + poll_configs() + poll_connections() + poll_proxies() +end + +function onIpc(event, payload) + if event == "refresh" then + poll_version() + poll_configs() + poll_connections() + poll_proxies() + elseif event == "cmd" and payload then + local ok, cmd = pcall(noctalia.json.decode, payload) + if ok and type(cmd) == "table" then + handle_command(cmd) + end + end +end + +-- ── Init ──────────────────────────────────────────────────────────────────── + +reload_settings() +state.connection.host = settings.host +state.connection.port = settings.port +publish("connection") +publish("config") +publish("traffic") +publish("connections") +publish("groups") +publish("delay") + +poll_version() +poll_configs() +poll_connections() +poll_proxies() +start_traffic_stream() diff --git a/mihomo-control/shortcut.luau b/mihomo-control/shortcut.luau new file mode 100644 index 00000000..9726e07d --- /dev/null +++ b/mihomo-control/shortcut.luau @@ -0,0 +1,33 @@ +--!nonstrict +-- Mihomo Control — control-center shortcut. +-- +-- Quick toggle for the proxy mode: clicking switches between rule and global. +-- The tile shows the current mode and lights up while global mode is active. + +local function current_mode() + local config = noctalia.state.get("mihomo.config") or {} + return config.mode or "rule" +end + +local function render() + local mode = current_mode() + shortcut.setLabel(noctalia.tr("shortcut.label", { mode = noctalia.tr("shortcut.mode_" .. mode) })) + shortcut.setIcon("cat") + shortcut.setActive(mode == "global") + shortcut.setEnabled(true) +end + +noctalia.state.watch("mihomo.config", function(_value) + render() +end) + +function onClick() + local next_mode = current_mode() == "global" and "rule" or "global" + noctalia.state.set("mihomo.command", { + op = "mode", + mode = next_mode, + seq = math.floor(os.clock() * 1000000), + }) +end + +render() diff --git a/mihomo-control/thumbnail.webp b/mihomo-control/thumbnail.webp new file mode 100644 index 00000000..6e69fd8c Binary files /dev/null and b/mihomo-control/thumbnail.webp differ diff --git a/mihomo-control/translations/en.json b/mihomo-control/translations/en.json new file mode 100644 index 00000000..9c670859 --- /dev/null +++ b/mihomo-control/translations/en.json @@ -0,0 +1,92 @@ +{ + "settings": { + "host": { + "label": "Host", + "description": "Hostname or IP of the Mihomo external controller." + }, + "port": { + "label": "Port", + "description": "Port of the Mihomo external controller (9090 by default)." + }, + "secret": { + "label": "Secret", + "description": "External-controller secret from your Mihomo config; empty when authentication is disabled." + }, + "use_https": { + "label": "HTTPS", + "description": "Connect with https:// instead of http:// (external-controller-tls or a TLS reverse proxy)." + }, + "allow_insecure_tls": { + "label": "Allow insecure TLS", + "description": "Skip certificate verification when the controller uses a self-signed certificate." + }, + "test_url": { + "label": "Test URL", + "description": "URL used for latency tests; empty uses each group's configured test URL." + }, + "refresh_interval": { + "label": "Refresh interval", + "description": "Seconds between status polls (1–60)." + } + }, + "panel": { + "title": "Mihomo Control", + "status_online": "Online", + "status_offline": "Offline", + "status_connecting": "Connecting", + "mode_rule": "Rule", + "mode_global": "Global", + "mode_direct": "Direct", + "refresh": "Refresh", + "traffic": "Traffic", + "connections": "connections", + "memory": "RAM", + "proxy_groups": "Proxy groups", + "no_groups": "No proxy groups found.", + "delay_test": "Test latency", + "test_all": "Test all", + "restart": "Restart", + "timeout": "timeout", + "no_members": "no members", + "members_count": { + "one": "{count} member", + "other": "{count} members" + }, + "hidden": "hidden", + "online_with_version": "{version} · {endpoint}", + "online_plain": "{endpoint}", + "offline_with_error": "{error}", + "offline_plain": "{endpoint}" + }, + "widget": { + "status": "Status", + "mode": "Mode", + "connections": "Connections", + "up": "Up total", + "down": "Down total", + "status_online_with_version": "Online · {version}", + "status_online_plain": "Online", + "status_connecting": "Connecting", + "status_offline_with_error": "Offline — {error}", + "status_offline_plain": "Offline" + }, + "shortcut": { + "label": "Mihomo: {mode}", + "mode_rule": "Rule", + "mode_global": "Global", + "mode_direct": "Direct" + }, + "notify": { + "mode_changed": "Mihomo mode changed", + "proxy_selected": "Proxy selected", + "proxy_selected_body": "{group} → {proxy}", + "delay_done": "Latency test finished", + "delay_all_done": "All groups tested", + "delay_body": "{count} nodes tested, selected {delay} ms", + "delay_body_no_selection": "{count} nodes tested", + "delay_timeout": "All nodes timed out (504) — check the test URL", + "restarting": "Mihomo is restarting", + "connections_closed": "All connections closed", + "request_failed": "Mihomo request failed" + } +} diff --git a/mihomo-control/translations/zh-Hans.json b/mihomo-control/translations/zh-Hans.json new file mode 100644 index 00000000..838ca6e7 --- /dev/null +++ b/mihomo-control/translations/zh-Hans.json @@ -0,0 +1,92 @@ +{ + "settings": { + "host": { + "label": "主机", + "description": "Mihomo 外部控制器的地址或 IP。" + }, + "port": { + "label": "端口", + "description": "Mihomo 外部控制器的端口(默认为 9090)。" + }, + "secret": { + "label": "密钥", + "description": "Mihomo 配置中的外部控制器密钥;未启用认证时留空。" + }, + "use_https": { + "label": "HTTPS", + "description": "使用 https:// 而非 http://(external-controller-tls 或 TLS 反向代理)。" + }, + "allow_insecure_tls": { + "label": "允许不安全的 TLS", + "description": "控制器使用自签名证书时跳过证书校验。" + }, + "test_url": { + "label": "测试 URL", + "description": "延迟测试使用的 URL;留空则使用各策略组配置的测试 URL。" + }, + "refresh_interval": { + "label": "刷新间隔", + "description": "状态轮询间隔(1–60 秒)。" + } + }, + "panel": { + "title": "Mihomo 控制", + "status_online": "在线", + "status_offline": "离线", + "status_connecting": "连接中", + "mode_rule": "规则", + "mode_global": "全局", + "mode_direct": "直连", + "refresh": "刷新", + "traffic": "流量", + "connections": "条连接", + "memory": "内存", + "proxy_groups": "策略组", + "no_groups": "未找到策略组。", + "delay_test": "测试延迟", + "test_all": "全部测试", + "restart": "重启", + "timeout": "超时", + "no_members": "无成员", + "members_count": { + "one": "{count} 个成员", + "other": "{count} 个成员" + }, + "hidden": "隐藏", + "online_with_version": "{version} · {endpoint}", + "online_plain": "{endpoint}", + "offline_with_error": "{error}", + "offline_plain": "{endpoint}" + }, + "widget": { + "status": "状态", + "mode": "模式", + "connections": "连接数", + "up": "上传", + "down": "下载", + "status_online_with_version": "在线 · {version}", + "status_online_plain": "在线", + "status_connecting": "连接中", + "status_offline_with_error": "离线 — {error}", + "status_offline_plain": "离线" + }, + "shortcut": { + "label": "Mihomo:{mode}", + "mode_rule": "规则", + "mode_global": "全局", + "mode_direct": "直连" + }, + "notify": { + "mode_changed": "Mihomo 模式已更改", + "proxy_selected": "已选择代理", + "proxy_selected_body": "{group} → {proxy}", + "delay_done": "延迟测试完成", + "delay_all_done": "全部策略组测试完成", + "delay_body": "已测试 {count} 个节点,选中 {delay} 毫秒", + "delay_body_no_selection": "已测试 {count} 个节点", + "delay_timeout": "所有节点均超时(504)——请检查测试 URL", + "restarting": "Mihomo 正在重启", + "connections_closed": "所有连接已关闭", + "request_failed": "Mihomo 请求失败" + } +} diff --git a/mihomo-control/widget.luau b/mihomo-control/widget.luau new file mode 100644 index 00000000..cd5011c4 --- /dev/null +++ b/mihomo-control/widget.luau @@ -0,0 +1,100 @@ +--!nonstrict +-- Mihomo Control — bar widget. +-- +-- Shows live download/upload rates from the service's streamed /traffic data. +-- Left click toggles the control panel; the tooltip holds the connection +-- status, mode, totals, connection count and the current selection of every +-- proxy group. + +local PLUGIN_ID = "mdj2812/mihomo-control" + +local function format_rate(bytes_per_second) + local value = tonumber(bytes_per_second) or 0 + if value >= 1024 * 1024 * 1024 then + return string.format("%.2f GB/s", value / (1024 * 1024 * 1024)) + end + if value >= 1024 * 1024 then + return string.format("%.1f MB/s", value / (1024 * 1024)) + end + if value >= 1024 then + return string.format("%.1f KB/s", value / 1024) + end + return string.format("%d B/s", value) +end + +local function render() + local conn = noctalia.state.get("mihomo.connection") or {} + local config = noctalia.state.get("mihomo.config") or {} + local traffic = noctalia.state.get("mihomo.traffic") or {} + local connections = noctalia.state.get("mihomo.connections") or {} + local groups = noctalia.state.get("mihomo.groups") or {} + + local online = conn.status == "online" + local mode = noctalia.tr("panel.mode_" .. (config.mode or "rule")) + local icon_path = "icon-offline.png" + if online then + icon_path = "icon-online.png" + elseif conn.status == "connecting" then + icon_path = "icon-connecting.png" + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, { + ui.image({ path = icon_path, width = 16, height = 16 }), + ui.label({ text = mode, fontSize = 12, color = "on_surface", fontWeight = "medium" }), + })) + + local status_value = "Offline" + if online then + local version = tostring(conn.version or "") + status_value = version ~= "" + and noctalia.tr("widget.status_online_with_version", { version = version }) + or noctalia.tr("widget.status_online_plain") + elseif conn.status == "connecting" then + status_value = noctalia.tr("widget.status_connecting") + elseif tostring(conn.error or "") ~= "" then + status_value = noctalia.tr("widget.status_offline_with_error", { error = conn.error }) + else + status_value = noctalia.tr("widget.status_offline_plain") + end + + local tooltip = { + { key = noctalia.tr("widget.status"), value = status_value }, + { key = noctalia.tr("widget.mode"), value = noctalia.tr("panel.mode_" .. (config.mode or "rule")) }, + { key = noctalia.tr("widget.down"), value = `▼ {format_rate(traffic.down)}` }, + { key = noctalia.tr("widget.up"), value = `▲ {format_rate(traffic.up)}` }, + { key = noctalia.tr("widget.connections"), value = tostring(connections.count or 0) }, + } + for _, group in ipairs(type(groups) == "table" and groups or {}) do + if #tooltip < 14 then + table.insert(tooltip, { + key = tostring(group.name or ""), + value = tostring(group.now or "—"), + }) + end + end + barWidget.setTooltip(tooltip) +end + +for _, key in { + "mihomo.connection", + "mihomo.config", + "mihomo.traffic", + "mihomo.connections", + "mihomo.groups", +} do + noctalia.state.watch(key, function(_value) + render() + end) +end + +function update() + noctalia.setUpdateInterval(1000) + render() +end + +function onClick() + noctalia.togglePanel(PLUGIN_ID .. ":panel") +end + +render()