mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 01:11:07 +02:00
362 lines
12 KiB
Lua
362 lines
12 KiB
Lua
-- GitHub release helpers for mod auto-update / other-versions.
|
|
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
|
|
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
|
|
-- (default 6 hours). The launcher owns UI and install.
|
|
|
|
local ModUpdate = {}
|
|
|
|
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
|
|
|
|
local function stripV(tag)
|
|
return (tostring(tag):gsub("^[vV]", ""))
|
|
end
|
|
|
|
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
|
|
function ModUpdate.pickZipAsset(assets, modId, version)
|
|
if type(assets) ~= "table" then return nil end
|
|
local prefer = nil
|
|
if modId and version then
|
|
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
|
|
end
|
|
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
|
|
if modId then idPrefix = tostring(modId):lower() end
|
|
for _, a in ipairs(assets) do
|
|
if type(a) == "table" and type(a.name) == "string" then
|
|
local name = a.name
|
|
if name:lower():match("%.zip$") then
|
|
local row = {
|
|
name = name,
|
|
url = a.browser_download_url,
|
|
size = tonumber(a.size),
|
|
}
|
|
if prefer and name == prefer then return row end
|
|
if idPrefix and not idPrefixZip
|
|
and name:lower():find(idPrefix, 1, true) == 1 then
|
|
idPrefixZip = row
|
|
end
|
|
if not anyZip then anyZip = row end
|
|
end
|
|
end
|
|
end
|
|
return idPrefixZip or anyZip
|
|
end
|
|
|
|
-- Byte-length cut that never lands mid UTF-8 codepoint.
|
|
local function utf8Cut(s, maxBytes)
|
|
if type(s) ~= "string" or maxBytes <= 0 then return "" end
|
|
if #s <= maxBytes then return s end
|
|
s = s:sub(1, maxBytes)
|
|
-- Drop trailing continuation bytes (10xxxxxx).
|
|
while #s > 0 do
|
|
local b = s:byte(#s)
|
|
if b < 0x80 or b >= 0xC0 then break end
|
|
s = s:sub(1, #s - 1)
|
|
end
|
|
-- Drop a lead byte whose continuation was truncated away.
|
|
if #s > 0 then
|
|
local b = s:byte(#s)
|
|
if b >= 0xC0 then
|
|
s = s:sub(1, #s - 1)
|
|
end
|
|
end
|
|
return s
|
|
end
|
|
|
|
-- Strip common markdown / HTML noise for the launcher changelog preview.
|
|
function ModUpdate.cleanBody(text, maxChars)
|
|
if type(text) ~= "string" or text == "" then return "" end
|
|
local s = text
|
|
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
|
|
s = s:gsub("<!%-%-.-%-%->", "")
|
|
s = s:gsub("<[^>]+>", "")
|
|
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images 
|
|
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
|
|
s = s:gsub("```[^\n]*\n(.-)```", "%1")
|
|
s = s:gsub("`([^`]+)`", "%1")
|
|
s = s:gsub("^#+%s*", "", 1)
|
|
s = s:gsub("\n#+%s*", "\n")
|
|
s = s:gsub("%*%*([^*]+)%*%*", "%1")
|
|
s = s:gsub("%*([^*]+)%*", "%1")
|
|
s = s:gsub("__([^_]+)__", "%1")
|
|
s = s:gsub("_([^_]+)_", "%1")
|
|
s = s:gsub("^\n+", ""):gsub("\n+$", "")
|
|
s = s:gsub("\n\n\n+", "\n\n")
|
|
maxChars = tonumber(maxChars) or 0
|
|
if maxChars > 0 and #s > maxChars then
|
|
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
|
|
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
|
end
|
|
return s
|
|
end
|
|
|
|
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
|
|
function ModUpdate.previewLine(text, maxChars)
|
|
local s = ModUpdate.cleanBody(text, 0)
|
|
if s == "" then return "" end
|
|
s = s:gsub("\n+", " "):gsub("%s+", " ")
|
|
s = s:match("^%s*(.-)%s*$") or s
|
|
maxChars = tonumber(maxChars) or 80
|
|
if #s > maxChars then
|
|
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
|
end
|
|
return s
|
|
end
|
|
|
|
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
|
function ModUpdate.parseRelease(doc, modId)
|
|
if type(doc) ~= "table" or not doc.tag_name then
|
|
return nil, "no tag_name in release"
|
|
end
|
|
local version = stripV(doc.tag_name)
|
|
if not version:match("^%d+%.%d+%.%d+") then
|
|
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
|
|
end
|
|
local triple = version:match("^(%d+%.%d+%.%d+)")
|
|
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
|
local body = type(doc.body) == "string" and doc.body or ""
|
|
return {
|
|
version = triple,
|
|
tag = tostring(doc.tag_name),
|
|
zip = zip,
|
|
prerelease = doc.prerelease == true,
|
|
name = type(doc.name) == "string" and doc.name or triple,
|
|
body = body,
|
|
}
|
|
end
|
|
|
|
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
|
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
|
function ModUpdate.parseReleases(jsonText, modId, Json)
|
|
local ok, result, err = pcall(function()
|
|
Json = Json or require("src.link.Json")
|
|
local doc, decodeErr = Json.decode(jsonText)
|
|
if type(doc) ~= "table" then
|
|
return nil, decodeErr or "releases json is not an array"
|
|
end
|
|
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
|
|
return nil, "GitHub: " .. doc.message
|
|
end
|
|
if doc.tag_name then
|
|
local one, oneErr = ModUpdate.parseRelease(doc, modId)
|
|
if not one then return nil, oneErr end
|
|
if not one.zip then return nil, "latest release has no .zip asset" end
|
|
return { one }
|
|
end
|
|
local out = {}
|
|
for _, entry in ipairs(doc) do
|
|
local rel = ModUpdate.parseRelease(entry, modId)
|
|
if rel and rel.zip then out[#out + 1] = rel end
|
|
end
|
|
return out
|
|
end)
|
|
if not ok then return nil, "could not parse releases: " .. tostring(result) end
|
|
return result, err
|
|
end
|
|
|
|
function ModUpdate.apiReleasesUrl(repo)
|
|
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
|
end
|
|
|
|
function ModUpdate.apiLatestUrl(repo)
|
|
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
|
|
end
|
|
|
|
function ModUpdate.isNewer(installed, candidate)
|
|
local Semver = require("src.mods.Semver")
|
|
if type(installed) ~= "string" or type(candidate) ~= "string" then
|
|
return false
|
|
end
|
|
local a, b = Semver.parse(installed), Semver.parse(candidate)
|
|
if not a or not b then return false end
|
|
return Semver.compare(candidate, installed) > 0
|
|
end
|
|
|
|
-- Newest non-prerelease, else newest overall.
|
|
function ModUpdate.pickBest(releases)
|
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
|
for _, rel in ipairs(releases) do
|
|
if not rel.prerelease then return rel end
|
|
end
|
|
return releases[1]
|
|
end
|
|
|
|
-- ------- cache (options.modUpdateCache[repo])
|
|
|
|
local function cacheStore()
|
|
local SaveData = require("src.core.SaveData")
|
|
local opts = SaveData.loadOptions()
|
|
opts.modUpdateCache = opts.modUpdateCache or {}
|
|
return opts
|
|
end
|
|
|
|
function ModUpdate.readCache(repo)
|
|
if type(repo) ~= "string" or repo == "" then return nil end
|
|
local ok, opts = pcall(function()
|
|
return require("src.core.SaveData").loadOptions()
|
|
end)
|
|
if not ok or type(opts) ~= "table" then return nil end
|
|
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
|
|
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
|
return nil
|
|
end
|
|
if type(entry.releases) ~= "table" then return nil end
|
|
return entry
|
|
end
|
|
|
|
function ModUpdate.cacheFresh(entry, now, ttl)
|
|
now = now or os.time()
|
|
ttl = ttl or ModUpdate.CACHE_TTL
|
|
return entry and type(entry.checkedAt) == "number"
|
|
and (now - entry.checkedAt) < ttl
|
|
end
|
|
|
|
function ModUpdate.writeCache(repo, releases)
|
|
if type(repo) ~= "string" or repo == "" then return false end
|
|
local ok = pcall(function()
|
|
local SaveData = require("src.core.SaveData")
|
|
local opts = cacheStore()
|
|
local best = ModUpdate.pickBest(releases)
|
|
-- Persist a lean copy: enough to paint the UI and reinstall without
|
|
-- re-fetching within the TTL.
|
|
local lean = {}
|
|
for i, rel in ipairs(releases or {}) do
|
|
lean[i] = {
|
|
version = rel.version,
|
|
tag = rel.tag,
|
|
name = rel.name,
|
|
prerelease = rel.prerelease == true,
|
|
body = type(rel.body) == "string" and rel.body or "",
|
|
zip = rel.zip and {
|
|
name = rel.zip.name,
|
|
url = rel.zip.url,
|
|
size = rel.zip.size,
|
|
} or nil,
|
|
}
|
|
end
|
|
opts.modUpdateCache[repo] = {
|
|
checkedAt = os.time(),
|
|
latest = best and best.version or nil,
|
|
releases = lean,
|
|
}
|
|
SaveData.saveOptions(opts)
|
|
end)
|
|
return ok
|
|
end
|
|
|
|
-- Status vs an installed version using a cache entry / release list.
|
|
-- Returns "available" | "current" | "unknown".
|
|
function ModUpdate.statusFor(installedVersion, releases)
|
|
local best = ModUpdate.pickBest(releases)
|
|
if not best then return "unknown", nil end
|
|
if ModUpdate.isNewer(installedVersion, best.version) then
|
|
return "available", best
|
|
end
|
|
return "current", best
|
|
end
|
|
|
|
-- ------- host I/O (curl)
|
|
|
|
local function shq(s)
|
|
s = tostring(s)
|
|
if love and love.system and love.system.getOS
|
|
and love.system.getOS() == "Windows" then
|
|
return '"' .. s:gsub('"', '') .. '"'
|
|
end
|
|
return "'" .. s:gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function curlCapture(url)
|
|
local HostShell = require("src.core.HostShell")
|
|
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
|
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
|
|
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
|
.. shq(url)
|
|
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
|
if not pipeOk or not pipe then return nil, "could not run curl" end
|
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
|
pcall(function() pipe:close() end)
|
|
if not readOk then return nil, "curl read failed: " .. tostring(out) end
|
|
if not out or out == "" then return nil, "empty response from GitHub" end
|
|
return out
|
|
end
|
|
|
|
function ModUpdate.haveCurl()
|
|
local HostShell = require("src.core.HostShell")
|
|
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
|
|
if not pipeOk or not pipe then return false end
|
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
|
pcall(function() pipe:close() end)
|
|
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
|
end
|
|
|
|
-- Fetch release list. opts.force bypasses the 6h cache.
|
|
-- Returns releases, err, meta where meta = { fromCache = bool }.
|
|
function ModUpdate.fetchReleases(repo, modId, opts)
|
|
opts = opts or {}
|
|
if type(repo) ~= "string" or repo == "" then
|
|
return nil, "missing github repo"
|
|
end
|
|
if not opts.force then
|
|
local cached = ModUpdate.readCache(repo)
|
|
if ModUpdate.cacheFresh(cached) then
|
|
return cached.releases, nil, { fromCache = true }
|
|
end
|
|
end
|
|
if not ModUpdate.haveCurl() then
|
|
-- Stale cache is better than nothing when offline
|
|
local cached = ModUpdate.readCache(repo)
|
|
if cached and cached.releases then
|
|
return cached.releases, nil, { fromCache = true, stale = true }
|
|
end
|
|
return nil, "curl is not available on this platform"
|
|
end
|
|
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
|
|
if not body then
|
|
local cached = ModUpdate.readCache(repo)
|
|
if cached and cached.releases then
|
|
return cached.releases, nil, { fromCache = true, stale = true }
|
|
end
|
|
return nil, curlErr
|
|
end
|
|
local list, parseErr = ModUpdate.parseReleases(body, modId)
|
|
if not list then return nil, parseErr end
|
|
ModUpdate.writeCache(repo, list)
|
|
return list, nil, { fromCache = false }
|
|
end
|
|
|
|
function ModUpdate.downloadZip(url, destName)
|
|
if type(url) ~= "string" or url == "" then
|
|
return nil, "missing download url"
|
|
end
|
|
if not (love and love.filesystem) then
|
|
return nil, "download needs LOVE"
|
|
end
|
|
if not ModUpdate.haveCurl() then
|
|
return nil, "curl is not available on this platform"
|
|
end
|
|
local HostShell = require("src.core.HostShell")
|
|
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
|
|
name = tostring(name):gsub("[/\\]", "_")
|
|
local saveOk, saveDir = pcall(function()
|
|
return love.filesystem.getSaveDirectory()
|
|
end)
|
|
if not saveOk or not saveDir or saveDir == "" then
|
|
return nil, "no save directory"
|
|
end
|
|
local abs = saveDir .. "/" .. name
|
|
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
|
|
.. shq(abs) .. " " .. shq(url)
|
|
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
|
if not pipeOk or not pipe then return nil, "could not start download" end
|
|
pcall(function() pipe:read("*a") end)
|
|
pcall(function() pipe:close() end)
|
|
local infoOk, info = pcall(love.filesystem.getInfo, name)
|
|
if not infoOk or not info or (info.size or 0) == 0 then
|
|
pcall(love.filesystem.remove, name)
|
|
return nil, "download failed"
|
|
end
|
|
return name
|
|
end
|
|
|
|
return ModUpdate
|