mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
mod manager now supports auto updates
This commit is contained in:
+76
-11
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader's default
|
||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||
-- missing entry means enabled, matching the loader -- except experimental
|
||||
-- mods, which stay off until the player opts in
|
||||
if mods[m.id] == false then
|
||||
-- stay off
|
||||
elseif mods[m.id] == true then
|
||||
enabledSet[m.id] = true
|
||||
elseif not m.experimental then
|
||||
enabledSet[m.id] = true
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
local raw = m.raw or {}
|
||||
local badge = tostring(raw.category or m.profile or "MOD"):upper()
|
||||
if m.experimental then badge = "EXPERIMENTAL" end
|
||||
out[#out + 1] = {
|
||||
id = m.id,
|
||||
name = m.name or m.id,
|
||||
version = m.version,
|
||||
-- category, then profile, then a generic fallback -- uppercased
|
||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -231,8 +241,15 @@ end
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
return {}
|
||||
end
|
||||
return result or {}
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
@@ -454,13 +471,22 @@ function LauncherMods.strays() return scanStrays(false) end
|
||||
-- on the player's behalf is not this function's call to make.
|
||||
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- installZip(source [, opts]) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||
function LauncherMods.installZip(source)
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
opts = opts or {}
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod install needs LOVE"
|
||||
end
|
||||
@@ -501,12 +527,24 @@ function LauncherMods.installZip(source)
|
||||
cleanup()
|
||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||
end
|
||||
if opts.expectId and manifest.id ~= opts.expectId then
|
||||
cleanup()
|
||||
return nil, ("zip is for '%s', expected '%s'")
|
||||
:format(manifest.id, opts.expectId)
|
||||
end
|
||||
|
||||
-- reject a duplicate before touching the mods tree
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
if not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||
-- would clear it, which would surprise an update)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
CacheFs.prefix = savedPrefix
|
||||
end
|
||||
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
@@ -529,6 +567,33 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
-- Returns true, version | nil, errString. Soft-fails: download / install /
|
||||
-- cleanup errors never throw into the launcher UI.
|
||||
function LauncherMods.installFromRelease(modId, release)
|
||||
local ok, result, err = pcall(function()
|
||||
if type(modId) ~= "string" or modId == "" then
|
||||
return nil, "missing mod id"
|
||||
end
|
||||
if type(release) ~= "table" or not release.zip or not release.zip.url then
|
||||
return nil, "release has no downloadable .zip"
|
||||
end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local tmpName = ("mod_update_%s_%s.zip"):format(
|
||||
tostring(modId), tostring(release.version or os.time()))
|
||||
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
|
||||
if not localPath then return nil, dlErr end
|
||||
local installed, res = LauncherMods.installZip(localPath, {
|
||||
replace = true, expectId = modId,
|
||||
})
|
||||
pcall(love.filesystem.remove, localPath)
|
||||
if not installed then return nil, res end
|
||||
return true, release.version or res
|
||||
end)
|
||||
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
|
||||
@@ -834,6 +834,18 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
-- options.mods entry normally means enabled, but experimental flips that.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local modsOpt = options.mods or {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
if not self.disabled[id] and modsOpt[id] == nil
|
||||
and mod.manifest.experimental then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
for id, mod in pairs(self.mods) do
|
||||
mod.enabled = not self.disabled[id]
|
||||
mod.state = mod.enabled and "pending" or "disabled"
|
||||
|
||||
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
action = function() self:goTo("permissions") end }
|
||||
end
|
||||
if m.github then
|
||||
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||
end
|
||||
if m.experimental then
|
||||
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
|
||||
end
|
||||
if m.error then
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
action = function() self:goTo("errors") end }
|
||||
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
|
||||
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
||||
self:enabledSet())
|
||||
end
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
local function proceed()
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
end
|
||||
end
|
||||
-- Experimental mods ask once on enable; disable is silent.
|
||||
if want and m.experimental then
|
||||
self:openConfirm({
|
||||
"EXPERIMENTAL MOD",
|
||||
"THIS MOD IS MARKED",
|
||||
"EXPERIMENTAL.",
|
||||
"ENABLE ANYWAY?",
|
||||
}, proceed)
|
||||
return
|
||||
end
|
||||
proceed()
|
||||
end
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
|
||||
+49
-2
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
if value == nil or value == "" then return nil end
|
||||
assert(type(value) == "string", "github must be a string")
|
||||
local trimmed = value:match("^%s*(.-)%s*$") or value
|
||||
if trimmed == "" then return nil end
|
||||
local owner, repo = trimmed:match(
|
||||
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
|
||||
if not owner then
|
||||
owner, repo = trimmed:match(
|
||||
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
|
||||
end
|
||||
if not owner then
|
||||
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||
end
|
||||
assert(owner and repo and owner ~= "" and repo ~= "",
|
||||
"github must be owner/repo or a github.com URL")
|
||||
repo = repo:gsub("%.git$", "")
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
|
||||
for _, entry in ipairs(list) do
|
||||
if not seen[entry] then
|
||||
seen[entry] = true
|
||||
out[#out + 1] = entry
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Manifest.validate(raw, path)
|
||||
assert(type(raw) == "table", "manifest must be an object")
|
||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||
@@ -86,6 +122,12 @@ function Manifest.validate(raw, path)
|
||||
assert(gameVersionOk, ("malformed game_version %q: %s")
|
||||
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
||||
|
||||
local github = Manifest.parseGithub(raw.github)
|
||||
|
||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
|
||||
-- overhauls and total conversions are assumed to move the link
|
||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||
local affectsLink = profile ~= "content"
|
||||
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
|
||||
return value
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = array(raw.conflicts),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
description = raw.description or "",
|
||||
github = github,
|
||||
experimental = experimental,
|
||||
profile = profile,
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user