mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
updater stuff
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user