mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-29 15:08:50 -04:00
a542ed90ba
and updated the behavior of the patch notes also fixed manual update checking added test to make sure no prs or build tasks are able to pass if the luajit limits ar exceeded.
208 lines
7.4 KiB
Lua
208 lines
7.4 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" | "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"
|
|
|
|
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)
|
|
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"
|
|
return {
|
|
version = version,
|
|
payloadName = payloadName,
|
|
payload = Check.pickAsset(doc.assets, payloadName),
|
|
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
|
-- 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 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" })
|
|
end
|
|
|
|
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
|
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
|
function Check.state()
|
|
drain()
|
|
return {
|
|
status = cache.status or "idle",
|
|
latest = cache.latest,
|
|
progress = cache.progress,
|
|
error = cache.error,
|
|
notes = cache.notes,
|
|
}
|
|
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
|