Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions mihomo-control/group_logic.luau
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

local M = {}

M.PROXIES_INLINE_DECODE_LIMIT = 65536

function M.clamp_interval(seconds)
local value = tonumber(seconds) or 2
return math.max(1, math.min(60, value))
Expand Down Expand Up @@ -92,4 +94,104 @@ function M.traffic_changed(previous, next_values)
)
end

-- Mihomo's /connections payload can be large when many flows are active. The
-- panel only needs totals and a count, so scrape those fields without a full
-- json.decode that can exceed Noctalia's per-callback CPU budget.
function M.parse_connections_summary(body)
if type(body) ~= "string" or body == "" then
return nil
end
local downloadTotal = tonumber(body:match('"downloadTotal"%s*:%s*(%d+)'))
local uploadTotal = tonumber(body:match('"uploadTotal"%s*:%s*(%d+)'))
local memory = tonumber(body:match('"memory"%s*:%s*(%d+)'))
if downloadTotal == nil and uploadTotal == nil and memory == nil then
return nil
end
local count = 0
for _ in body:gmatch('"chains"%s*:') do
count = count + 1
end
return {
count = count,
downloadTotal = downloadTotal or 0,
uploadTotal = uploadTotal or 0,
memory = memory or 0,
}
end

function M.proxies_body_needs_external_decode(body)
return type(body) == "string" and #body > M.PROXIES_INLINE_DECODE_LIMIT
end

function M.build_groups_from_proxies(proxies, default_test_url)
if type(proxies) ~= "table" then
return {}
end
local default = default_test_url or "https://www.gstatic.com/generate_204"

local proxy_delays = {}
for name, info in pairs(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 pairs(proxies) do
if type(info) == "table" and type(info.all) == "table" then
local members = {}
for _, member_name in ipairs(info.all) do
local member = tostring(member_name)
table.insert(members, {
name = member,
delay = proxy_delays[member],
})
end
table.insert(groups, {
name = tostring(name),
type = tostring(info.type or ""),
now = info.now ~= nil and tostring(info.now) or nil,
hidden = info.hidden == true,
testUrl = type(info.testUrl) == "string" and info.testUrl or default,
members = members,
})
end
end
return groups
end

function M.build_groups_from_slim_proxies(slim, default_test_url)
if type(slim) ~= "table" or type(slim.groups) ~= "table" then
return {}
end
local default = default_test_url or "https://www.gstatic.com/generate_204"
local delays = type(slim.delays) == "table" and slim.delays or {}
local groups = {}

for _, group in ipairs(slim.groups) do
if type(group) == "table" and type(group.name) == "string" then
local members = {}
for _, member_name in ipairs(type(group.members) == "table" and group.members or {}) do
local member = tostring(member_name)
table.insert(members, {
name = member,
delay = delays[member],
})
end
table.insert(groups, {
name = group.name,
type = tostring(group.type or ""),
now = group.now ~= nil and tostring(group.now) or nil,
hidden = group.hidden == true,
testUrl = type(group.testUrl) == "string" and group.testUrl or default,
members = members,
})
end
end
return groups
end

return M
2 changes: 1 addition & 1 deletion mihomo-control/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

id = "mdj2812/mihomo-control"
name = "Mihomo Control"
version = "0.1.3"
version = "0.1.4"
plugin_api = 9
author = "mdj2812"
license = "MIT"
Expand Down
144 changes: 94 additions & 50 deletions mihomo-control/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ local function safe_call(label, fn)
end
end

local function shell_quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end

local group_logic

local function require_group_logic()
Expand Down Expand Up @@ -277,19 +281,48 @@ local function poll_connections()
if not success(res) then
return
end
local data = decode(res.body)
local data = require_group_logic().parse_connections_summary(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
state.connections.count = data.count
state.connections.downloadTotal = data.downloadTotal
state.connections.uploadTotal = data.uploadTotal
state.connections.memory = data.memory
publish("connections")
end
end)
end

-- ── Proxy groups ────────────────────────────────────────────────────────────

local PROXIES_JQ = [[
def last_delay:
if ((.history // []) | length) > 0 then .history[-1].delay else null end;
.proxies as $p | {
delays: (
$p | to_entries
| map(select((.value | last_delay) != null) | {key: .key, value: (.value | last_delay)})
| from_entries
),
groups: (
$p | to_entries
| map(select(.value.all != null) | {
name: .key,
type: (.value.type // ""),
now: .value.now,
hidden: (.value.hidden == true),
testUrl: (if (.value.testUrl | type) == "string" then .value.testUrl else null end),
members: .value.all
})
)
}
]]

local proxies_poll_pending = false

local function default_test_url()
return settings.test_url ~= "" and settings.test_url or DEFAULT_TEST_URL
end

local function merge_delays(group_name)
local delays = state.delay[group_name]
if type(delays) ~= "table" or type(delays.byName) ~= "table" then
Expand All @@ -307,59 +340,70 @@ local function merge_delays(group_name)
end
end

local function poll_proxies()
request("GET", "/proxies", auth_headers(), nil, function(res)
if not success(res) then
local function publish_proxy_groups(groups)
apply_group_order(groups)
state.groups = groups
for _, group in groups do
merge_delays(group.name)
end
publish("groups")
end

local function poll_proxies_from_body(body)
local logic = require_group_logic()
local test_url = default_test_url()

if logic.proxies_body_needs_external_decode(body) then
if proxies_poll_pending then
return
end
local data = decode(res.body)
if not data or type(data.proxies) ~= "table" then
local dir = noctalia.pluginDataDir()
if dir == nil 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
local path = dir .. "/proxies-poll.json"
local written = noctalia.writeFile(path, body)
if not written then
log("failed to cache /proxies body for jq projection")
return
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)],
})
proxies_poll_pending = true
local cmd = "jq -c "
.. shell_quote(PROXIES_JQ)
.. " "
.. shell_quote(path)
noctalia.runAsync(cmd, function(result)
proxies_poll_pending = false
safe_call("proxies jq", function()
if not result or result.exitCode ~= 0 then
return
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
apply_group_order(groups)
state.groups = groups
for _, group in groups do
merge_delays(group.name)
local slim = decode(result.stdout or "")
if not slim then
return
end
publish_proxy_groups(logic.build_groups_from_slim_proxies(slim, test_url))
end)
end, 30000)
return
end

local data = decode(body)
if not data or type(data.proxies) ~= "table" then
return
end
publish_proxy_groups(logic.build_groups_from_proxies(data.proxies, test_url))
end

local function poll_proxies()
request("GET", "/proxies", auth_headers(), nil, function(res)
if not success(res) then
return
end
publish("groups")
safe_call("poll proxies", function()
poll_proxies_from_body(res.body or "")
end)
end)
end

Expand Down
58 changes: 58 additions & 0 deletions mihomo-control/tests/group_order_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,62 @@ local traffic = { up = 1, down = 2, upTotal = 3, downTotal = 4 }
assert(logic.traffic_changed(traffic, { up = 1, down = 2, upTotal = 3, downTotal = 4 }) == false, "identical traffic is unchanged")
assert(logic.traffic_changed(traffic, { up = 9, down = 2, upTotal = 3, downTotal = 4 }) == true, "changed traffic is detected")

local sample_connections = [[
{"downloadTotal":6694145524,"uploadTotal":123456,"connections":[
{"id":"a","metadata":{"host":"example.com"},"upload":1,"download":2,"chains":["DIRECT"]},
{"id":"b","metadata":{"host":"other.com"},"upload":3,"download":4,"chains":["Proxy"]}
],"memory":116858880}
]]
local summary = logic.parse_connections_summary(sample_connections)
assert(summary ~= nil, "connections summary parses")
assert_eq(summary.count, 2, "connection count")
assert_eq(summary.downloadTotal, 6694145524, "download total")
assert_eq(summary.uploadTotal, 123456, "upload total")
assert_eq(summary.memory, 116858880, "memory")
assert(logic.parse_connections_summary("") == nil, "empty body is rejected")
assert(logic.parse_connections_summary("{") == nil, "invalid body is rejected")

local sample_proxies = {
proxies = {
["Node A"] = {
history = { { delay = 42 } },
},
["Node B"] = {
history = { { delay = 0 } },
},
Selector = {
type = "Selector",
now = "Node A",
hidden = false,
testUrl = "http://example.test/generate_204",
all = { "Node A", "Node B" },
},
},
}
local built = logic.build_groups_from_proxies(sample_proxies.proxies, "http://fallback.test")
assert_eq(#built, 1, "one proxy group")
assert_eq(built[1].name, "Selector", "group name")
assert_eq(built[1].members[1].delay, 42, "member delay from history")
assert_eq(built[1].members[2].delay, 0, "failed probe delay is kept")

local slim = {
delays = { ["Node A"] = 42, ["Node B"] = 0 },
groups = {
{
name = "Selector",
type = "Selector",
now = "Node A",
hidden = false,
testUrl = "http://example.test/generate_204",
members = { "Node A", "Node B" },
},
},
}
local from_slim = logic.build_groups_from_slim_proxies(slim, "http://fallback.test")
assert_eq(#from_slim, 1, "slim projection yields one group")
assert_eq(from_slim[1].members[1].delay, 42, "slim member delay")

assert(logic.proxies_body_needs_external_decode(string.rep("x", logic.PROXIES_INLINE_DECODE_LIMIT)) == false, "at-limit body stays inline")
assert(logic.proxies_body_needs_external_decode(string.rep("x", logic.PROXIES_INLINE_DECODE_LIMIT + 1)) == true, "over-limit body uses jq")

print("mihomo-control group_logic tests: ok")
Loading