mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-12 03:14:48 +08:00
645 lines
17 KiB
Lua
645 lines
17 KiB
Lua
#!/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("<entry") ~= nil
|
|
end)
|
|
end
|
|
|
|
local function parse_commit_feed(raw, max_count)
|
|
local commits = {}
|
|
if raw and raw ~= "" then
|
|
local n = 0
|
|
for entry in raw:gmatch("<entry>(.-)</entry>") do
|
|
n = n + 1
|
|
if n > max_count then break end
|
|
local sha = entry:match("<id>[^<]*/([0-9a-f]+)</id>")
|
|
if sha then
|
|
local date = entry:match("<updated>(.-)</updated>") or ""
|
|
local title = entry:match("<title>(.-)</title>") 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
|