#!/usr/bin/lua require "nixio" require "luci.model.uci" local fs = require "luci.openclash" local json = require "luci.jsonc" local M = {} local VERSION_CACHE_FILE = "/tmp/openclash_version_history.json" local CDN_CACHE_FILE = "/tmp/openclash_cdn_info.json" local function trim(s) if not s then return "" end return (s:gsub("^%s+", ""):gsub("%s+$", "")) end function M.is_valid_version(s) if not s or s == "" then return false end s = trim(s) if s == "" then return false end if s:match("^<") then return false end return true end function M.is_oix_mode() local oix_token = fs.uci_get_config("config", "oix_token") or "" return oix_token ~= "" end function try_read(fd, maxlen) local pfds = { { fd = fd, events = nixio.poll_flags("in", "hup", "err") } } local nfds = nixio.poll(pfds, 0) if nfds and nfds > 0 then local buf = fd:read(maxlen or 4096) if buf and #buf > 0 then return buf end end return nil end local function read_json(path) if not fs.access(path) then return nil end local raw = fs.readfile(path) if not raw or raw == "" then return nil end local ok, parsed = pcall(json.parse, raw) if ok and parsed and type(parsed) == "table" then return parsed end return nil end local function write_json(path, data) local tmp = path .. ".tmp" fs.writefile(tmp, json.stringify(data)) os.rename(tmp, path) end local function read_version_cache() return read_json(VERSION_CACHE_FILE) end local function update_version_cache(updater) local parsed = read_version_cache() or {} updater(parsed) write_json(VERSION_CACHE_FILE, parsed) end local DEFAULT_CDN_LIST = { "https://ghfast.top/", "https://github.dpik.top/", "https://gh-proxy.com/", "https://git.yylx.win/" } local function cdn_list() local list = fs.cdn_list() if #list > 0 then return list end return DEFAULT_CDN_LIST end local function raw_url(path) return "https://raw.githubusercontent.com/vernesong/OpenClash/" .. path end local function build_fetch_urls(mod, path) if mod == "0" or mod == "" then local urls = { raw_url(path) } for _, cdn in ipairs(cdn_list()) do urls[#urls + 1] = cdn .. raw_url(path) end return urls end if mod == "https://cdn.jsdelivr.net/" or mod == "https://fastly.jsdelivr.net/" or mod == "https://testingcf.jsdelivr.net/" then return { mod .. "gh/vernesong/OpenClash@" .. path } end return { mod .. raw_url(path) } end local function fork_curl(url, timeout) local fdi, fdo = nixio.pipe() if not fdi or not fdo then return nil end local cmd = string.format('curl -sL -m %d "%s" 2>/dev/null', timeout or 5, url) local child = nixio.fork() if child > 0 then fdo:close() return { pid = child, fdi = fdi, buf = "", done = false } elseif child == 0 then nixio.dup(fdo, nixio.stdout) fdi:close() fdo:close() nixio.exec("/bin/sh", "-c", cmd) else if fdi then fdi:close() end if fdo then fdo:close() end return nil end end local MAX_URL_BATCH = 3 local function try_fetch(urls, validator) if not urls or #urls == 0 then return "" end local timeout = (#urls > 1) and 5 or 10 local delay = 50000000 local max_wait = 100 local jobs = {} local active = 0 local idx = 1 local function launch_batch() local n = 0 while idx <= #urls and n < MAX_URL_BATCH do local job = fork_curl(urls[idx], timeout) if job then jobs[#jobs + 1] = job active = active + 1 end idx = idx + 1 n = n + 1 end end launch_batch() for _ = 1, max_wait do local winner = "" for _, job in ipairs(jobs) do if not job.done then local ok_r, buf = pcall(try_read, job.fdi, 4096) if ok_r and buf then job.buf = job.buf .. buf end local ok_w, wpid = pcall(nixio.waitpid, job.pid, "nohang") if ok_w and wpid then while true do local ok_b, b = pcall(try_read, job.fdi, 4096) if not ok_b or not b then break end job.buf = job.buf .. b end pcall(job.fdi.close, job.fdi) job.done = true active = active - 1 if winner == "" and job.buf ~= "" and (not validator or validator(job.buf)) then winner = job.buf end end end end if winner ~= "" then for _, job in ipairs(jobs) do if not job.done then pcall(job.fdi.close, job.fdi) nixio.kill(job.pid, 9) end end return trim(winner) end if active == 0 then if idx <= #urls then launch_batch() else break end end nixio.nanosleep(0, delay) delay = math.min(delay * 2, 200000000) end for _, job in ipairs(jobs) do if not job.done then pcall(job.fdi.close, job.fdi) nixio.kill(job.pid, 9) end end return "" end function M.prepare_oix_cdn_data(force) if not M.is_oix_mode() then return false, "", "" end local parsed = read_version_cache() if parsed and parsed.oix and parsed.oix.cached_at then local age = os.time() - parsed.oix.cached_at local ttl = parsed.oix.cache_ttl or 300 if age < 5 or (not force and age < ttl) then return true, parsed.oix.ver or "", parsed.oix.error or "" end end local function fork_oix_version(url) local fdi, fdo = nixio.pipe() if not fdi or not fdo then return nil end local cmd = string.format('curl -sL -m 3 --connect-timeout 3 "%s" 2>/dev/null', url) local child = nixio.fork() if child > 0 then fdo:close() return { pid = child, fdi = fdi, buf = "" } elseif child == 0 then nixio.dup(fdo, nixio.stdout) fdi:close() fdo:close() nixio.exec("/bin/sh", "-c", cmd) else if fdi then fdi:close() end if fdo then fdo:close() end return nil end end local jobs = {} local dl_job = fork_oix_version("https://dl.dler.io/mihomo-oix/version.txt?tag=Pre-Alpha") if dl_job then jobs[#jobs + 1] = dl_job end local gh_job = fork_oix_version("https://github.com/vernesong/mihomo-oix/releases/download/Pre-Alpha/version.txt") if gh_job then jobs[#jobs + 1] = gh_job end if #jobs == 0 then return true, "", "error" end local ver = "" local delay = 50000000 local max_wait = 40 for _ = 1, max_wait do for _, job in ipairs(jobs) do if not job.done then local ok_r, buf = pcall(try_read, job.fdi, 4096) if ok_r and buf then job.buf = job.buf .. buf end local ok_w, wpid = pcall(nixio.waitpid, job.pid, "nohang") if ok_w and wpid then while true do local ok_b, b = pcall(try_read, job.fdi, 4096) if not ok_b or not b then break end job.buf = job.buf .. b end pcall(job.fdi.close, job.fdi) job.done = true end end end for _, job in ipairs(jobs) do if job.done and ver == "" then local candidate = trim(job.buf:gsub("[\n\r]+", "")) if M.is_valid_version(candidate) then ver = candidate end end end if ver ~= "" then for _, job in ipairs(jobs) do if not job.done then pcall(job.fdi.close, job.fdi) nixio.kill(job.pid, 9) end end update_version_cache(function(p) p.oix = { ver = ver, error = "", cache_ttl = 300, cached_at = os.time() } end) return true, ver, "" end local all_done = true for _, job in ipairs(jobs) do if not job.done then all_done = false break end end if all_done then break end nixio.nanosleep(0, delay) delay = math.min(delay * 2, 200000000) end for _, job in ipairs(jobs) do if not job.done then pcall(job.fdi.close, job.fdi) nixio.kill(job.pid, 9) end end update_version_cache(function(p) p.oix = { ver = "", error = "error", cache_ttl = 5, cached_at = os.time() } end) return true, "", "error" end local function fork_file_fetch(sha, file_path, mod) local urls = build_fetch_urls(mod, sha .. "/" .. file_path) if #urls == 0 then return nil end return { children = {}, idx = 1, buf = "", done = false, launched = false, sha = sha, urls = urls } end local function launch_file_batch(job) local timeout = (#job.urls > 1) and 5 or 10 local n = 0 while job.idx <= #job.urls and n < MAX_URL_BATCH do local child = fork_curl(job.urls[job.idx], timeout) if child then job.children[#job.children + 1] = child end job.idx = job.idx + 1 n = n + 1 end end local function collect_fork_results(jobs, max_jobs) local results = {} local active = 0 local delay = 50000000 local max_iter = 250 for _ = 1, max_iter do while active < max_jobs do local launched = false for _, job in ipairs(jobs) do if not job.launched then job.launched = true active = active + 1 launched = true launch_file_batch(job) if #job.children == 0 then job.done = true active = active - 1 end break end end if not launched then break end end for _, job in ipairs(jobs) do if job.launched and not job.done then local winner = "" local all_children_done = true for _, child in ipairs(job.children) do if not child.done then all_children_done = false local ok_r, buf = pcall(try_read, child.fdi, 4096) if ok_r and buf then child.buf = child.buf .. buf end local ok_w, wpid = pcall(nixio.waitpid, child.pid, "nohang") if ok_w and wpid then while true do local ok_b, b = pcall(try_read, child.fdi, 4096) if not ok_b or not b then break end child.buf = child.buf .. b end pcall(child.fdi.close, child.fdi) child.done = true if winner == "" and child.buf ~= "" then winner = child.buf end end end end if winner ~= "" then for _, child in ipairs(job.children) do if not child.done then pcall(child.fdi.close, child.fdi) nixio.kill(child.pid, 9) end end job.done = true active = active - 1 results[job.sha] = trim(winner) elseif all_children_done then if job.idx <= #job.urls then launch_file_batch(job) else job.done = true active = active - 1 end end end end local all_done = true for _, job in ipairs(jobs) do if not job.done then all_done = false break end end if all_done then break end nixio.nanosleep(0, delay) delay = math.min(delay * 2, 200000000) end for _, job in ipairs(jobs) do if not job.done then if job.children then for _, child in ipairs(job.children) do if not child.done then pcall(child.fdi.close, child.fdi) nixio.kill(child.pid, 9) end end end job.done = true end end return results end local function html_unescape(s) if not s then return "" end s = s:gsub("&", "&") s = s:gsub("<", "<") s = s:gsub(">", ">") s = s:gsub(""", '"') s = s:gsub("'", "'") s = s:gsub("'", "'") return s end local function build_feed_urls(mod, path) local feed = "https://github.com/vernesong/OpenClash/commits/" .. path .. ".atom" if mod == "0" or mod == "" then local urls = { feed } for _, cdn in ipairs(cdn_list()) do urls[#urls + 1] = cdn .. feed end return urls end if mod == "https://cdn.jsdelivr.net/" or mod == "https://fastly.jsdelivr.net/" or mod == "https://testingcf.jsdelivr.net/" then return { feed } end return { mod .. feed, feed } end local function fetch_commit_feed(mod, path) return try_fetch(build_feed_urls(mod, path), function(buf) return buf:match("(.-)") do n = n + 1 if n > max_count then break end local sha = entry:match("[^<]*/([0-9a-f]+)") if sha then local date = entry:match("(.-)") or "" local title = entry:match("(.-)") or "" title = trim(title) commits[#commits + 1] = { sha = sha, date = date, message = html_unescape(title) } end end end return commits end function M.fetch_version_history(branch, force, cdn, latest_only) local result = { plugin = {}, core_meta = {}, core_smart = {}, latest = nil, error = nil, oix_ver = "" } local cur_oix = M.is_oix_mode() local skip_core = cur_oix local github_address_mod = fs.uci_get_config("config", "github_address_mod") or "0" if cdn and cdn ~= "" then github_address_mod = cdn if github_address_mod:match("raw%.githubusercontent%.com") then github_address_mod = "0" end end if not force then local parsed = read_version_cache() if parsed and parsed[branch] and parsed[branch].cached_at then local blk = parsed[branch] local ttl = blk.cache_ttl or 300 if os.time() - blk.cached_at < ttl and blk.oix == cur_oix then if latest_only then local lt = blk.latest if lt and lt.plugin and lt.plugin ~= "" and (cur_oix or (lt.core_meta and lt.core_meta ~= "")) then return blk end elseif blk.plugin and #blk.plugin > 0 then return blk end end end end if latest_only then local plugin_latest = "" local core_meta_latest = "" local core_smart_latest = "" local plugin_raw = try_fetch(build_fetch_urls(github_address_mod, "package/" .. branch .. "/version")) if plugin_raw and plugin_raw ~= "" then plugin_latest = trim(plugin_raw:match("^[^\n\r]*") or "") end if not cur_oix then local core_raw = try_fetch(build_fetch_urls(github_address_mod, "core/" .. branch .. "/core_version")) if core_raw and core_raw ~= "" then core_meta_latest = trim(core_raw:match("^[^\n\r]*") or "") local after = core_raw:match("[\n\r]+(.*)") if after then core_smart_latest = trim(after:match("^[^\n\r]*") or "") end end end result.latest = { plugin = plugin_latest, core_meta = core_meta_latest, core_smart = core_smart_latest } if plugin_latest == "" then result.error = "network_error" end if cur_oix then M.prepare_oix_cdn_data(force) end result.cache_ttl = (plugin_latest == "") and 5 or 300 result.cached_at = os.time() result.oix = cur_oix update_version_cache(function(parsed) local blk = parsed[branch] or {} blk.latest = result.latest blk.error = result.error blk.cache_ttl = result.cache_ttl blk.cached_at = result.cached_at blk.oix = cur_oix blk.plugin = nil blk.core_meta = nil blk.core_smart = nil blk.oix_ver = nil parsed[branch] = blk end) return result end local plugin_feed = fetch_commit_feed(github_address_mod, "package/" .. branch .. "/version") local plugin_commits = parse_commit_feed(plugin_feed, 5) if #plugin_commits > 0 then local file_jobs = {} for _, c in ipairs(plugin_commits) do local job = fork_file_fetch(c.sha, branch .. "/version", github_address_mod) if job then file_jobs[#file_jobs + 1] = job end end local file_results = collect_fork_results(file_jobs, 2) for _, c in ipairs(plugin_commits) do local raw = file_results[c.sha] local ver if raw and raw ~= "" then ver = trim(raw:match("^[^\n\r]*") or "") if not M.is_valid_version(ver) then ver = nil end end result.plugin[#result.plugin + 1] = { version = ver, date = c.date, sha = c.sha, message = c.message } end end if not skip_core then local core_feed = fetch_commit_feed(github_address_mod, "core/" .. branch .. "/core_version") local core_commits = parse_commit_feed(core_feed, 5) if #core_commits > 0 then local file_jobs = {} for _, c in ipairs(core_commits) do local job = fork_file_fetch(c.sha, branch .. "/core_version", github_address_mod) if job then file_jobs[#file_jobs + 1] = job end end local file_results = collect_fork_results(file_jobs, 2) for _, c in ipairs(core_commits) do local content = file_results[c.sha] if content and content ~= "" then local meta_ver = content:match("^[^\n\r]*") local after_first = content:match("[\n\r]+(.*)") local smart_ver = after_first and after_first:match("^[^\n\r]*") or nil if meta_ver then meta_ver = trim(meta_ver) if M.is_valid_version(meta_ver) then result.core_meta[#result.core_meta + 1] = { version = meta_ver, date = c.date, sha = c.sha } end end if smart_ver then smart_ver = trim(smart_ver) if M.is_valid_version(smart_ver) then result.core_smart[#result.core_smart + 1] = { version = smart_ver, date = c.date, sha = c.sha } end end end end end end result.latest = { plugin = (result.plugin[1] and result.plugin[1].version) or "", core_meta = (result.core_meta[1] and result.core_meta[1].version) or "", core_smart = (result.core_smart[1] and result.core_smart[1].version) or "" } if cur_oix then local _, oix_ver = M.prepare_oix_cdn_data(force) if oix_ver and oix_ver ~= "" then result.oix_ver = oix_ver end end local cache_ttl = 300 if #result.plugin == 0 then if not plugin_feed or plugin_feed == "" then result.error = "network_error" cache_ttl = 5 else result.error = "parse_error" cache_ttl = 5 end end result.cache_ttl = cache_ttl result.cached_at = os.time() result.oix = cur_oix update_version_cache(function(parsed) parsed[branch] = result end) return result end if arg and arg[0] and arg[0]:match("openclash_version%.lua$") then local branch = fs.uci_get_config("config", "release_branch") or "master" M.fetch_version_history(branch, arg[2], arg[1], arg[3] ~= "0") end return M