refactor: implement HostShell transport abstraction for multi-platform update fetching and downloads so the "check for updates" button works on mobile os

This commit is contained in:
1jamie
2026-08-15 12:45:31 -05:00
parent e24410f0fb
commit cec1f196be
3 changed files with 81 additions and 70 deletions
+13 -5
View File
@@ -126,11 +126,19 @@ bundled game, in that case.
already driving the frame. A payload that must change `love.run` itself already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior. than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua` - **Android and iOS use the native download bridge, not curl.** Neither
shells out to curl for both the release check and the download; curl is platform ships curl, so the old `check_worker.lua` path (shell out to curl)
absent on Android, so `Check` degrades to `status = "error"` there (the always landed on `error` and the launcher chip's "Check for updates" tap
launcher UI hides on that status) and the player is directed to the was a no-op. The worker now talks through `HostShell`, the same transport
releases page via `Check.releaseUrl()` instead. as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
APK or IPA can therefore check GitHub and fetch the `.love` payload
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when - **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the `love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source `"0.0.0-dev"` placeholder that always reports up to date, so a source
+3 -3
View File
@@ -8,9 +8,9 @@
-- "update_check_state" worker -> main: { status, latest, progress, error } -- "update_check_state" worker -> main: { status, latest, progress, error }
-- --
-- Nothing here ever blocks or throws into the game loop: when love.thread is -- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android), -- absent (the headless test stub) or the worker cannot run, state() reports
-- state() simply reports "error" and the UI hides itself. See the shared -- "error" (or the worker reports "needs_full" when there is no transport).
-- contract in the task brief for the status vocabulary and the file layout. -- See the shared contract in the task brief for the status vocabulary.
-- --
-- The release-JSON extraction and the sums parsing are exported as pure -- 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 -- functions (no love.* calls) so plain-Lua tests can cover them, and so the
+65 -62
View File
@@ -5,11 +5,10 @@
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } -- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error } -- "update_check_state" out: { status, latest, progress, error }
-- --
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+ -- Transport is HostShell: curl via io.popen on desktop, the JNI
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error, -- love.system.httpDownload bridge on Android (same path the mod catalog
-- or a hung download degrades to a "error"/"needs_full" state rather than -- already uses). A missing transport, an HTTP error, or a hung download
-- blocking or crashing the game. On Android curl is absent and the check -- degrades to "error"/"needs_full" rather than blocking or crashing the game.
-- soft-fails to "error", which the UI hides.
-- --
-- Fresh love threads do not carry the "src.*" package searcher, so sibling -- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like -- modules are pulled in with love.filesystem.load exactly like
@@ -63,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la
local pending = nil local pending = nil
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- shell / curl -- shell / fetch
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
local UA = "gen1recomp-updater"
local GH_ACCEPT = "application/vnd.github+json"
local function shq(s) local function shq(s)
s = tostring(s) s = tostring(s)
if isWindows then if isWindows then
@@ -74,31 +76,17 @@ local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'" return "'" .. s:gsub("'", "'\\''") .. "'"
end end
-- run curl and return its response body (text), or nil on any failure. Used -- Small text resources (release JSON, sums file) through HostShell so Android
-- for the small text resources (release JSON, sums file); -f makes curl exit -- hits the JNI bridge instead of a curl binary that is never on the device.
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure. local function fetchText(url, accept)
local function curlCapture(url) if not HostShell then return nil end
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " local body = HostShell.httpGet(url, UA, accept)
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " " if type(body) ~= "string" or body == "" then return nil end
.. "-H " .. shq("Accept: application/vnd.github+json") .. " " return body
.. shq(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil end
local out = pipe:read("*a")
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
-- free a FILE while another thread's popen walks the stream list, which
-- deadlocks that thread permanently (see HostShell's popen notes).
HostShell.pclose(pipe)
if not out or out == "" then return nil end
return out
end end
local function haveCurl() local function canFetch()
local pipe = HostShell.popen("curl --version") return HostShell and HostShell.canFetch()
if not pipe then return false end
local out = pipe:read("*a")
HostShell.pclose(pipe)
return out ~= nil and out:find("curl", 1, true) ~= nil
end end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -170,12 +158,14 @@ end
local function doCheck() local function doCheck()
post({ status = "checking" }) post({ status = "checking" })
if not haveCurl() then if not canFetch() then
post({ status = "error", error = "curl not available" }) -- No curl and no JNI bridge: the chip becomes "Open releases" so a tap
-- still does something instead of retrying a check that cannot succeed.
post({ status = "needs_full" })
return return
end end
local body = curlCapture(API_URL) local body = fetchText(API_URL, GH_ACCEPT)
if not body then if not body then
post({ status = "error", error = "release check failed" }) post({ status = "error", error = "release check failed" })
return return
@@ -212,7 +202,7 @@ local function doCheck()
-- pulling the bytes again. -- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url) local sums = fetchText(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel) love.filesystem.remove(finalRel)
@@ -279,39 +269,52 @@ local function doDownload()
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done" local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0 local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs) if HostShell and HostShell.haveCurl() then
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a -- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly -- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0 local waited, lastSize, lastChange = 0, -1, 0
while true do while true do
-- A queued quit means the window already closed. Bail so the join in -- A queued quit means the window already closed. Bail so the join in
-- Check.shutdown does not hold the dead window's process (and, on -- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The -- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl -- quit stays on the channel for the command loop; the detached curl
-- times out on its own and the next launch's doCheck verifies and -- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed. -- re-offers whatever landed.
local peeked = cmdCh:peek() local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel) local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0 local cur = (pinfo and pinfo.size) or 0
if size > 0 then if size > 0 then
local p = cur / size local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p }) post({ status = "downloading", latest = rel.version, progress = p })
else else
post({ status = "downloading", latest = rel.version }) post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end end
if cur ~= lastSize then lastSize, lastChange = cur, waited end love.filesystem.remove(doneRel)
if waited - lastChange > 60 then break end -- 60s with no growth: give up else
if waited > 960 then break end -- absolute ceiling -- Android JNI bridge: blocking write, same as fetch_worker. Progress
love.timer.sleep(0.25) -- cannot be sampled from inside httpDownload.
waited = waited + 0.25 local ok = HostShell and HostShell.httpDownload(
rel.payload.url, partAbs, UA, nil, 900)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = "download failed" })
return
end
post({ status = "downloading", latest = rel.version, progress = 0.999 })
end end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "") local sums = fetchText(rel.sums and rel.sums.url or "")
if not sums then if not sums then
love.filesystem.remove(partRel) love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" }) post({ status = "error", error = "checksum fetch failed" })