#!/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("