updater stuff

This commit is contained in:
bryanthaboi
2026-08-20 17:30:48 -04:00
parent dbecc345e3
commit ada0d8abe1
22 changed files with 596 additions and 56 deletions
+15 -3
View File
@@ -1316,15 +1316,27 @@ function LauncherView._updateControl(imp)
elseif status == "downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Updating %d%%", pct), nil, false
elseif status == "full_downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Downloading app %d%%", pct), nil, false
elseif status == "available" then
return status, st.latest and (Strings("Update v") .. st.latest)
or Strings("Update"), function() pcall(imp.Check.download) end, true
elseif status == "ready" then
return status, Strings("Restart to update"),
function() require("src.core.HostShell").restart() end, true
elseif status == "needs_full" then
return status, Strings("Open releases"),
function() love.system.openURL(imp.Check.releaseUrl()) end, true
elseif status == "needs_full" or status == "full_ready" then
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
local label = action and action.label or "Open releases"
local url = action and action.url or imp.Check.releaseUrl()
return status, Strings(label),
function()
if action and action.kind and imp.Check.performFullUpdate then
pcall(imp.Check.performFullUpdate)
else
love.system.openURL(url)
end
end, true
end
-- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"),
+137 -5
View File
@@ -4,7 +4,8 @@
-- 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_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
@@ -21,6 +22,42 @@ 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"
@@ -50,7 +87,7 @@ end
-- 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)
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
@@ -66,11 +103,15 @@ function Check.parseRelease(jsonText, Json)
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 "",
@@ -106,6 +147,37 @@ 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
@@ -167,11 +239,13 @@ function Check.start(force)
end
requested = true
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" })
cmdCh:push({ cmd = "check", target = target() })
end
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
-- 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 {
@@ -180,9 +254,67 @@ function Check.state()
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()
+112 -15
View File
@@ -2,7 +2,8 @@
--
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
-- touches the render thread. Talks over two channels:
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_cmd" in: { cmd = "check" | "download" |
-- "download_full" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is HostShell: curl via io.popen on desktop, the JNI
@@ -44,6 +45,11 @@ local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
-- The release chosen by the last check. Declare this before post() so status
-- messages consistently preserve its release notes instead of accidentally
-- reading a global named `pending`.
local pending = nil
local function post(t)
if pending and type(t) == "table" and t.notes == nil then
t.notes = pending.notes
@@ -57,10 +63,6 @@ local saveDir = love.filesystem.getSaveDirectory()
local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
-- the release picked by the last "check"; kept between commands so "download"
-- knows the payload url/size/name without re-fetching
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / fetch
-- ---------------------------------------------------------------------------
@@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText)
return true
end
local function verifyFullPackage(rel, assetName, sumsText)
return verifyPayload(rel, assetName, sumsText)
end
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
@@ -147,8 +153,34 @@ local function gatePasses(rel)
if not info then return true end
local shell = (Version and Version.shell) or 1
local payloadHost = (Version and Version.payloadHost) or "love"
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
return not (info.minShell and info.minShell > shell)
if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end
if info.minShell and info.minShell > shell then return false, "min_shell" end
if Boot.canHost and not Boot.canHost(info, shell, payloadHost) then return false, "shell_gate" end
return true
end
local function persistFullRequirement(rel, reason)
if not (rel and rel.version and Json) then return end
local full = rel.full
local record = {
version = rel.version,
reason = reason or "full_package_required",
full = full and { name = rel.fullName, url = full.url, size = full.size } or nil,
}
pcall(function()
love.filesystem.createDirectory("updates")
love.filesystem.write("updates/full-update.json", Json.encode(record))
end)
end
local function postFullRequirement(rel, reason)
persistFullRequirement(rel, reason)
post({ status = "needs_full", latest = rel and rel.version, reason = reason,
full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil })
end
local function clearFullRequirement()
pcall(function() love.filesystem.remove("updates/full-update.json") end)
end
local function cacheNotes(ver, notes)
@@ -177,7 +209,7 @@ end
-- check
-- ---------------------------------------------------------------------------
local function doCheck()
local function doCheck(target)
post({ status = "checking" })
if not canFetch() then
@@ -193,7 +225,7 @@ local function doCheck()
return
end
local rel, perr = Check.parseRelease(body, Json)
local rel, perr = Check.parseRelease(body, Json, target)
if not rel then
post({ status = "error", error = perr or "bad release json" })
return
@@ -212,6 +244,9 @@ local function doCheck()
end
if compareVersions(rel.version, currentEngine) <= 0 then
-- We are now running a native shell at least as new as GitHub's latest
-- release, so a former minShell/payloadHost prompt no longer applies.
clearFullRequirement()
post({ status = "uptodate", latest = rel.version })
return
end
@@ -219,7 +254,7 @@ local function doCheck()
-- A newer release, but without the .love payload or its sums we cannot do an
-- in-place update: send the user to the full installers.
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
post({ status = "needs_full", latest = rel.version })
postFullRequirement(rel, "payload_missing")
return
end
@@ -229,9 +264,10 @@ local function doCheck()
if love.filesystem.getInfo(finalRel) then
local sums = fetchText(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
local allowed, reason = gatePasses(finalRel)
if allowed == false then
love.filesystem.remove(finalRel)
post({ status = "needs_full", latest = rel.version })
postFullRequirement(rel, reason)
return
end
post({ status = "ready", latest = rel.version })
@@ -353,9 +389,10 @@ local function doDownload()
return
end
if gatePasses(partRel) == false then
local allowed, reason = gatePasses(partRel)
if allowed == false then
love.filesystem.remove(partRel)
post({ status = "needs_full", latest = rel.version })
postFullRequirement(rel, reason)
return
end
@@ -374,6 +411,63 @@ local function doDownload()
post({ status = "ready", latest = rel.version })
end
-- Full native-package download. At present Android consumes the verified file
-- through its Package Installer bridge. Other platforms retain the same
-- release metadata and fall back to their platform-specific external update
-- channel rather than attempting to overwrite a running executable.
local function doDownloadFull()
if not (pending and pending.full and pending.full.url and pending.fullName
and pending.sums and pending.sums.url) then
post({ status = "error", error = "full package is unavailable" })
return
end
local rel = pending
local asset = rel.full
local name = rel.fullName
love.filesystem.createDirectory("updates")
local partRel = "updates/" .. name .. ".part"
local doneRel = "updates/" .. name
local partAbs = saveDir .. "/" .. partRel
local doneAbs = saveDir .. "/" .. doneRel
love.filesystem.remove(partRel)
love.filesystem.remove(doneRel)
post({ status = "full_downloading", latest = rel.version, progress = 0,
reason = "full_package_required", full = { name = name, url = asset.url, size = asset.size } })
local ok = HostShell and HostShell.httpDownload(asset.url, partAbs, UA, nil, 900)
if not ok then
love.filesystem.remove(partRel)
postFullRequirement(rel, "full_download_failed")
return
end
local sums = fetchText(rel.sums.url)
if not sums then
love.filesystem.remove(partRel)
postFullRequirement(rel, "full_checksum_fetch_failed")
return
end
local valid, err = verifyFullPackage(partRel, name, sums)
if not valid then
love.filesystem.remove(partRel)
post({ status = "error", error = err or "full package verification failed" })
return
end
if not os.rename(partAbs, doneAbs) then
local data = love.filesystem.read(partRel)
if not data then
post({ status = "error", error = "full package finalize failed" })
return
end
love.filesystem.write(doneRel, data)
love.filesystem.remove(partRel)
end
persistFullRequirement(rel, "full_package_required")
post({ status = "full_ready", latest = rel.version, reason = "full_package_required",
full = { name = name, url = asset.url, size = asset.size, path = doneAbs } })
end
-- ---------------------------------------------------------------------------
-- command loop
-- ---------------------------------------------------------------------------
@@ -384,11 +478,14 @@ while true do
if cmd.cmd == "quit" then
break
elseif cmd.cmd == "check" then
local ok, err = pcall(doCheck)
local ok, err = pcall(doCheck, cmd.target)
if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download" then
local ok, err = pcall(doDownload)
if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download_full" then
local ok, err = pcall(doDownloadFull)
if not ok then post({ status = "error", error = tostring(err) }) end
end
end
end