Files
gen1recomp/src/update/Check.lua
T
2026-08-20 17:30:48 -04:00

340 lines
13 KiB
Lua

-- Async release-check and payload-download for the self-update flow.
--
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
-- on a background love.thread worker (src/update/check_worker.lua); this module
-- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation:
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" |
-- "download_full" | "quit" }
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run, state() reports
-- "error" (or the worker reports "needs_full" when there is no transport).
-- See the shared contract in the task brief for the status vocabulary.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
-- worker can reuse the exact same code path via love.filesystem.load.
local Check = {}
local Platform = require("src.core.Platform")
Check.REPO = "bryanthaboi/gen1recomp"
-- Full native packages are deliberately named by release target, not by the
-- generic .love payload. Keeping the mapping here makes the release parser,
-- worker and launcher agree on exactly which asset a platform may offer.
-- Switch owns its native OTA launcher and therefore never reaches this code.
local function fullAssetName(version, osName, arch, port)
if port == "rg34xxsp" then
return "gen1recomp-" .. version .. "-rg34xxsp-stockos64-mod.zip"
elseif port == "portmaster" then
return "gen1recomp-" .. version .. "-sbc-portmaster.zip"
elseif osName == "Android" then
return "gen1recomp-" .. version .. "-android.apk"
elseif osName == "iOS" then
return "gen1recomp++-" .. version .. "-ios.ipa"
elseif osName == "OS X" or osName == "macOS" then
return "gen1recomp-" .. version .. "-macos.zip"
elseif osName == "Windows" then
return "gen1recomp-" .. version .. "-windows.zip"
elseif osName == "UWP" then
return "gen1recomp-" .. version .. "-xbox-uwp.zip"
elseif osName == "NX" then
return "gen1recomp-" .. version .. "-switch.zip"
elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then
return "gen1recomp-" .. version .. "-linux-arm64.AppImage"
elseif osName == "Linux" then
return "gen1recomp-" .. version .. "-linux.zip"
end
return nil
end
function Check.fullAssetName(version, osName, arch, port)
if type(version) ~= "string" or not version:match("^%d+%.%d+%.%d+$") then
return nil
end
return fullAssetName(version, osName, arch, port)
end
local CMD = "update_check_cmd"
local STATE = "update_check_state"
-- ---------------------------------------------------------------------------
-- pure helpers (no love.*) -- also used inside the worker
-- ---------------------------------------------------------------------------
-- Find the release asset named exactly `name`, returning its download URL and
-- byte size (or nil when the release has no such asset).
function Check.pickAsset(assets, name)
if type(assets) ~= "table" then return nil end
for _, a in ipairs(assets) do
if type(a) == "table" and a.name == name then
return { url = a.browser_download_url, size = tonumber(a.size) }
end
end
return nil
end
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Decode a GitHub "releases/latest" response into just the fields the updater
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require.
function Check.parseRelease(jsonText, Json, target)
Json = Json or require("src.link.Json")
local notJson = Json.describeUnexpected(jsonText)
if notJson then return nil, notJson end
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "no tag_name in release json"
end
if not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+$") then
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end
local payloadName = "gen1recomp-" .. version .. ".love"
target = type(target) == "table" and target or {}
local fullName = fullAssetName(version, target.os, target.arch, target.port)
return {
version = version,
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
fullName = fullName,
full = fullName and Check.pickAsset(doc.assets, fullName) or nil,
-- GitHub release body: already fetched with the update check, shown by
-- the launcher's Patch notes footer button.
notes = type(doc.body) == "string" and doc.body or "",
}
end
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
-- `target` argument returns just that file's hash (or nil); otherwise returns
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
function Check.parseSums(text, target)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
if hash and file then
map[(file:gsub("^%./", ""))] = hash:lower()
end
end
if target ~= nil then return map[target] end
return map
end
-- ---------------------------------------------------------------------------
-- main-thread state machine
-- ---------------------------------------------------------------------------
function Check.releaseUrl()
return "https://github.com/" .. Check.REPO .. "/releases/latest"
end
local worker -- the love.thread, once started
local cmdCh, stateCh -- the two channels
local workerReady -- nil = untried, true = running, false = unavailable
local requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker
local function target()
local osName = love and love.system and love.system.getOS and love.system.getOS() or nil
local arch = jit and jit.arch or nil
local port = os.getenv("POKEPORT_PORTMASTER")
return { os = osName, arch = arch, port = port }
end
local function readPersistedFullRequirement()
if not (love and love.filesystem and love.filesystem.getInfo) then return nil end
local path = "updates/full-update.json"
if not love.filesystem.getInfo(path) then return nil end
local text = love.filesystem.read(path)
if type(text) ~= "string" then return nil end
local ok, Json = pcall(require, "src.link.Json")
if not ok or not Json then return nil end
local decodedOk, requirement = pcall(Json.decode, text)
if not decodedOk or type(requirement) ~= "table" then return nil end
if type(requirement.version) ~= "string" then return nil end
return requirement
end
local persistedRequirement = readPersistedFullRequirement()
if persistedRequirement then
cache = {
status = "needs_full",
latest = persistedRequirement.version,
reason = persistedRequirement.reason,
full = persistedRequirement.full,
}
end
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not Platform.networkValidated() then
workerReady = false
return false
end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
end
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
if not ok or not th then
workerReady = false
return false
end
cmdCh = love.thread.getChannel(CMD)
stateCh = love.thread.getChannel(STATE)
if not pcall(function() th:start() end) then
workerReady = false
return false
end
worker = th
workerReady = true
return true
end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
if type(msg) == "table" and type(msg.notes) ~= "string"
and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
msg = stateCh:pop()
end
end
if worker then
local err = worker:getError()
if err then
cache = { status = "error", error = tostring(err) }
end
end
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op unless
-- force=true is passed (e.g. from an explicit button press).
function Check.start(force)
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
end
requested = true
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check", target = target() })
end
-- Current snapshot: { status, latest, progress, error, notes, reason, full }.
-- status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full |
-- full_downloading | full_ready | error.
function Check.state()
drain()
return {
status = cache.status or "idle",
latest = cache.latest,
progress = cache.progress,
error = cache.error,
notes = cache.notes,
reason = cache.reason,
full = cache.full,
}
end
-- The full-update record is intentionally persistent. An offline launch still
-- tells the player why this native shell cannot run the downloaded release.
function Check.fullUpdateAction()
drain()
local st = Check.state()
if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end
local osName = love and love.system and love.system.getOS and love.system.getOS() or ""
if osName == "Android" and type(love.system.installApk) == "function"
and type(st.full) == "table" and type(st.full.url) == "string" then
if st.status == "full_ready" and type(st.full.path) == "string" then
return { label = "Install Android update", kind = "install" }
end
return { label = "Download Android update", kind = "download" }
end
if osName == "iOS" then
return { label = "Re-sideload app", url =
"https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" }
elseif osName == "UWP" then
return { label = "Open Xbox install guide", url = Check.releaseUrl() }
elseif type(st.full) == "table" and type(st.full.url) == "string" then
return { label = "Download full update", url = st.full.url }
end
return { label = "Open releases", url = Check.releaseUrl() }
end
function Check.downloadFull()
drain()
if not cmdCh or cache.status ~= "needs_full" then return false end
if not (cache.full and cache.full.url) then return false end
cache = { status = "full_downloading", latest = cache.latest, progress = 0,
reason = cache.reason, full = cache.full, notes = cache.notes }
cmdCh:push({ cmd = "download_full" })
return true
end
function Check.installFull()
drain()
if cache.status ~= "full_ready" then return false end
local path = cache.full and cache.full.path
if type(path) ~= "string" or path == "" then return false end
if not (love and love.system and type(love.system.installApk) == "function") then return false end
local ok, started = pcall(love.system.installApk, path)
return ok and started == true
end
function Check.performFullUpdate()
local action = Check.fullUpdateAction()
if not action then return false end
if action.kind == "download" then return Check.downloadFull() end
if action.kind == "install" then return Check.installFull() end
if action.url and love and love.system and love.system.openURL then
return pcall(love.system.openURL, action.url)
end
return false
end
-- Start downloading the payload announced by an "available" check. A no-op in
-- any other state (the worker still holds the release info from the check).
function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0,
notes = cache.notes }
cmdCh:push({ cmd = "download" })
end
-- End the worker thread. Its command loop sits in Channel:demand(), which
-- never returns on its own, and LOVE waits for every live love.thread before
-- the process exits (#339).
function Check.shutdown()
if cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, stateCh = nil, nil, nil
workerReady = false
end
return Check