diff --git a/mihomo-control/group_logic.luau b/mihomo-control/group_logic.luau index 19de0af9..0e3fcf08 100644 --- a/mihomo-control/group_logic.luau +++ b/mihomo-control/group_logic.luau @@ -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)) @@ -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 diff --git a/mihomo-control/plugin.toml b/mihomo-control/plugin.toml index b75c484d..43a66b3e 100644 --- a/mihomo-control/plugin.toml +++ b/mihomo-control/plugin.toml @@ -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" diff --git a/mihomo-control/service.luau b/mihomo-control/service.luau index b0843ce3..8aa112b9 100644 --- a/mihomo-control/service.luau +++ b/mihomo-control/service.luau @@ -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() @@ -277,12 +281,12 @@ 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) @@ -290,6 +294,35 @@ 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 @@ -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 diff --git a/mihomo-control/tests/group_order_test.lua b/mihomo-control/tests/group_order_test.lua index 3269ba24..84181290 100644 --- a/mihomo-control/tests/group_order_test.lua +++ b/mihomo-control/tests/group_order_test.lua @@ -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")