From cec1f196be159cd745c75fdf06a2e97581e2dd30 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 15 Aug 2026 12:45:31 -0500 Subject: [PATCH] refactor: implement HostShell transport abstraction for multi-platform update fetching and downloads so the "check for updates" button works on mobile os --- docs/updater.md | 18 +++-- src/update/Check.lua | 6 +- src/update/check_worker.lua | 127 ++++++++++++++++++------------------ 3 files changed, 81 insertions(+), 70 deletions(-) diff --git a/docs/updater.md b/docs/updater.md index f117c898..7815874a 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -126,11 +126,19 @@ bundled game, in that case. 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 than running with half its intended behavior. -- **Android has no in-app download transport yet.** `check_worker.lua` - shells out to curl for both the release check and the download; curl is - absent on Android, so `Check` degrades to `status = "error"` there (the - launcher UI hides on that status) and the player is directed to the - releases page via `Check.releaseUrl()` instead. +- **Android and iOS use the native download bridge, not curl.** Neither + platform ships curl, so the old `check_worker.lua` path (shell out to curl) + always landed on `error` and the launcher chip's "Check for updates" tap + was a no-op. The worker now talks through `HostShell`, the same transport + 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 `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 diff --git a/src/update/Check.lua b/src/update/Check.lua index 728789aa..8e655733 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -8,9 +8,9 @@ -- "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 (no curl, Android), --- state() simply reports "error" and the UI hides itself. See the shared --- contract in the task brief for the status vocabulary and the file layout. +-- 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 diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index d60a349b..7d592ec6 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -5,11 +5,10 @@ -- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } -- "update_check_state" out: { status, latest, progress, error } -- --- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+ --- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error, --- or a hung download degrades to a "error"/"needs_full" state rather than --- blocking or crashing the game. On Android curl is absent and the check --- soft-fails to "error", which the UI hides. +-- Transport is HostShell: curl via io.popen on desktop, the JNI +-- love.system.httpDownload bridge on Android (same path the mod catalog +-- already uses). A missing transport, an HTTP error, or a hung download +-- degrades to "error"/"needs_full" rather than blocking or crashing the game. -- -- Fresh love threads do not carry the "src.*" package searcher, so sibling -- 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 -- --------------------------------------------------------------------------- --- shell / curl +-- shell / fetch -- --------------------------------------------------------------------------- +local UA = "gen1recomp-updater" +local GH_ACCEPT = "application/vnd.github+json" + local function shq(s) s = tostring(s) if isWindows then @@ -74,31 +76,17 @@ local function shq(s) return "'" .. s:gsub("'", "'\\''") .. "'" end --- run curl and return its response body (text), or nil on any failure. Used --- for the small text resources (release JSON, sums file); -f makes curl exit --- non-zero and emit nothing on an HTTP error, so an empty read is a failure. -local function curlCapture(url) - local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " - .. "-H " .. shq("User-Agent: gen1recomp-updater") .. " " - .. "-H " .. shq("Accept: application/vnd.github+json") .. " " - .. 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 +-- Small text resources (release JSON, sums file) through HostShell so Android +-- hits the JNI bridge instead of a curl binary that is never on the device. +local function fetchText(url, accept) + if not HostShell then return nil end + local body = HostShell.httpGet(url, UA, accept) + if type(body) ~= "string" or body == "" then return nil end + return body end -local function haveCurl() - local pipe = HostShell.popen("curl --version") - 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 +local function canFetch() + return HostShell and HostShell.canFetch() end -- --------------------------------------------------------------------------- @@ -170,12 +158,14 @@ end local function doCheck() post({ status = "checking" }) - if not haveCurl() then - post({ status = "error", error = "curl not available" }) + if not canFetch() then + -- 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 end - local body = curlCapture(API_URL) + local body = fetchText(API_URL, GH_ACCEPT) if not body then post({ status = "error", error = "release check failed" }) return @@ -212,7 +202,7 @@ local function doCheck() -- pulling the bytes again. local finalRel = "updates/" .. rel.payloadName 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 gatePasses(finalRel) == false then love.filesystem.remove(finalRel) @@ -279,39 +269,52 @@ local function doDownload() local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done" 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 - -- stalled or run-away transfer breaks out and lets verification fail cleanly - local waited, lastSize, lastChange = 0, -1, 0 - while true do - -- 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 - -- Windows, its folder) open for up to the whole transfer (#727). The - -- 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 - -- re-offers whatever landed. - local peeked = cmdCh:peek() - if type(peeked) == "table" and peeked.cmd == "quit" then return end - if love.filesystem.getInfo(doneRel) then break end - local pinfo = love.filesystem.getInfo(partRel) - local cur = (pinfo and pinfo.size) or 0 - if size > 0 then - local p = cur / size - if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" - post({ status = "downloading", latest = rel.version, progress = p }) - else - post({ status = "downloading", latest = rel.version }) + -- 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 + local waited, lastSize, lastChange = 0, -1, 0 + while true do + -- 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 + -- Windows, its folder) open for up to the whole transfer (#727). The + -- 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 + -- re-offers whatever landed. + local peeked = cmdCh:peek() + if type(peeked) == "table" and peeked.cmd == "quit" then return end + if love.filesystem.getInfo(doneRel) then break end + local pinfo = love.filesystem.getInfo(partRel) + local cur = (pinfo and pinfo.size) or 0 + if size > 0 then + local p = cur / size + if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" + post({ status = "downloading", latest = rel.version, progress = p }) + else + 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 - 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 + love.filesystem.remove(doneRel) + else + -- Android JNI bridge: blocking write, same as fetch_worker. Progress + -- cannot be sampled from inside httpDownload. + 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 - 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 love.filesystem.remove(partRel) post({ status = "error", error = "checksum fetch failed" })