mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
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:
+13
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" })
|
||||||
|
|||||||
Reference in New Issue
Block a user