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
+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()