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:
@@ -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 |
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 请求失败"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user