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