mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
b692474e24
A mod index can now publish three optional per-entry fields -- downloads (total across every release), first_release and last_release (ISO days) -- which the FIND MODS listing shows in the same gold line the MODS tab uses. The fields are additive by design: feeds that carry them stay readable by every build that predates them (schema_version stays 1), and feeds that do not render exactly as before. ModUpdate.statsLine builds the shared line; the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
437 lines
15 KiB
Lua
437 lines
15 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 Strings = require("src.core.Strings")
|
|
|
|
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,
|
|
-- name, body, downloads }. `downloads` is the sum of every asset's
|
|
-- download_count, GitHub's own measure of a release's downloads.
|
|
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 ""
|
|
local downloads = 0
|
|
if type(doc.assets) == "table" then
|
|
for _, a in ipairs(doc.assets) do
|
|
local d = type(a) == "table" and tonumber(a.download_count)
|
|
if d and d > 0 then downloads = downloads + d end
|
|
end
|
|
end
|
|
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,
|
|
downloads = downloads,
|
|
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
|
|
}
|
|
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=100"
|
|
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
|
|
|
|
-- Sum of per-release download counts. Returns nil when no release carries
|
|
-- the field (a cache entry written before downloads existed), else
|
|
-- { total = <sum>, releases = <release count> } so a caller can tell a
|
|
-- real "0 downloads" apart from "no data yet".
|
|
function ModUpdate.totalDownloads(releases)
|
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
|
local total, hasData = 0, false
|
|
for _, rel in ipairs(releases) do
|
|
local d = type(rel) == "table" and tonumber(rel.downloads)
|
|
if d then
|
|
hasData = true
|
|
total = total + d
|
|
end
|
|
end
|
|
if not hasData then return nil end
|
|
return { total = total, releases = #releases }
|
|
end
|
|
|
|
-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in
|
|
-- calendar order). Returns nil when no release carries a date -- same
|
|
-- "no data yet" rule as totalDownloads.
|
|
function ModUpdate.releaseDates(releases)
|
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
|
local first, latest, has = nil, nil, false
|
|
for _, rel in ipairs(releases) do
|
|
local p = type(rel) == "table" and rel.published
|
|
if type(p) == "string" and p ~= "" then
|
|
has = true
|
|
if not first or p < first then first = p end
|
|
if not latest or p > latest then latest = p end
|
|
end
|
|
end
|
|
if not has then return nil end
|
|
return { first = first, latest = latest }
|
|
end
|
|
|
|
-- Thousands-separated count for the launcher ("12,345"), plain for small
|
|
-- numbers. Never throws; garbage in, "0" out.
|
|
function ModUpdate.formatCount(n)
|
|
n = tonumber(n)
|
|
if not n or n ~= n or n < 0 then return "0" end
|
|
local s = tostring(math.floor(n))
|
|
s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
|
|
return (s:gsub("^,", ""))
|
|
end
|
|
|
|
-- The one-line stats string both launcher panels show: "1,234 downloads
|
|
-- across all releases - Released 2024-05-31 - Updated 2026-07-01".
|
|
-- Each part is optional; nil everywhere means nil, so a row with no data
|
|
-- shows no line rather than a wrong "0".
|
|
function ModUpdate.statsLine(total, first, latest)
|
|
local parts = {}
|
|
if total ~= nil then
|
|
parts[#parts + 1] = Strings("%s downloads across all releases",
|
|
ModUpdate.formatCount(total))
|
|
end
|
|
if first or latest then
|
|
parts[#parts + 1] = Strings("Released %s - Updated %s",
|
|
first or "?", latest or "?")
|
|
end
|
|
if #parts == 0 then return nil end
|
|
return table.concat(parts, " - ")
|
|
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
|
|
|
|
-- A cache entry written before download counts existed carries no
|
|
-- `downloads` on any release. It is provably stale -- parseRelease always
|
|
-- sets the field now -- so treat it as expired: the next fetch rewrites
|
|
-- the entry in the current format, one refetch per repo, and the launcher's
|
|
-- download line stops hiding behind an old cache.
|
|
function ModUpdate.cacheUsable(cached)
|
|
if type(cached) ~= "table" or type(cached.releases) ~= "table" then
|
|
return false
|
|
end
|
|
if #cached.releases == 0 then return true end
|
|
for _, rel in ipairs(cached.releases) do
|
|
if tonumber(rel.downloads) then return true end
|
|
end
|
|
return false
|
|
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 "",
|
|
downloads = tonumber(rel.downloads) or 0,
|
|
published = rel.published,
|
|
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 (HostShell's transport: curl, or the Android JNI bridge)
|
|
|
|
-- GitHub's API wants a User-Agent and answers the versioned Accept.
|
|
local function apiFetch(url)
|
|
local HostShell = require("src.core.HostShell")
|
|
return HostShell.httpGet(url, "gen1recomp-mod-updater",
|
|
"application/vnd.github+json")
|
|
end
|
|
|
|
-- Still just the curl probe it always was. "Can we fetch at all" is
|
|
-- HostShell.canFetch, which also knows about the Android bridge (#597); every
|
|
-- gate below asks that instead.
|
|
function ModUpdate.haveCurl()
|
|
local HostShell = require("src.core.HostShell")
|
|
return HostShell.haveCurl()
|
|
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) and ModUpdate.cacheUsable(cached) then
|
|
return cached.releases, nil, { fromCache = true }
|
|
end
|
|
end
|
|
local HostShell = require("src.core.HostShell")
|
|
if not HostShell.canFetch() 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, "no network transport on this platform"
|
|
end
|
|
local body, curlErr = apiFetch(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
|
|
local HostShell = require("src.core.HostShell")
|
|
if not HostShell.canFetch() then
|
|
return nil, "no network transport on this platform"
|
|
end
|
|
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
|
|
-- Either transport can fail quietly; the getInfo size check below is what
|
|
-- actually decides whether we got a file (#597).
|
|
HostShell.httpDownload(url, abs, "gen1recomp-mod-updater")
|
|
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
|