Dev/mihomo control (#339)

* Add Mihomo Control plugin

Monitor and control a Mihomo (Clash Meta) external controller from the
bar: live traffic graph, proxy mode, proxy-group management with latency
tests, and core restart. Works locally (127.0.0.1) or remotely via
host/port/secret settings.

* Add Chinese (Simplified) translation

* Add test-all latency button

Adds a Test all button next to the Proxy groups heading that runs latency
tests on every group sequentially, with a single summary notification.

* Unify traffic and proxy groups section styles

Give both sections the same card look: muted medium-weight heading on the
left, secondary info on the right, and proxy-group cards restyled as
bordered sub-cards inside the section.

* Polish proxy group cards

Show the selected proxy next to the bold group name (ECS > ecs-proxy),
move the expand chevron to the type/members row where the list opens,
and drop the list glyph from the card header.

* Decouple heights of paired group cards

Keep each card in a two-column row at its natural height instead of
stretching the neighbor when one is expanded.

* Use thumbnail generator to generate thumbnail

* mihomo-control: theme-aware panel colors

Use theme roles for traffic and latency colors so they stay readable in
light mode, and keep the status chip's green/amber/red identity with
darker light-mode shades.
This commit is contained in:
MA Dingjie
2026-08-12 09:34:17 -04:00
committed by GitHub
parent 86e04099bc
commit 222fec4875
13 changed files with 1601 additions and 0 deletions
+95
View File
@@ -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 <secret>` 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+482
View File
@@ -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()
+94
View File
@@ -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"
+613
View File
@@ -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 = "<group>", proxy = "<proxy>" }
-- { op = "mode", mode = "rule" | "global" | "direct" }
-- { op = "delay_test", group = "<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/<group> {"name": ...} select group member
-- GET /group/<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()
+33
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+92
View File
@@ -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"
}
}
+92
View File
@@ -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 请求失败"
}
}
+100
View File
@@ -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()