mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
The new experience (#201)
* new launcher and save converts and pipeline * fixing bugs
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
-- The boot shell: the heart of the self-updater. A fused build, before it
|
||||
-- runs the game bundled inside it, looks in its save directory for a newer
|
||||
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
|
||||
-- runnable, mounts it over the bundled source and chainloads it -- so the
|
||||
-- binary shipped once can keep updating the Lua it runs without a reinstall.
|
||||
--
|
||||
-- Only a fused build self-updates. A dev / source checkout IS the game, so
|
||||
-- Boot.run is a no-op there.
|
||||
--
|
||||
-- Three pieces, deliberately layered so the risky part is small and the
|
||||
-- decision part is testable:
|
||||
-- * Boot.select -- pure: given probed candidates + the bundled version,
|
||||
-- decide what to run and what to delete. No love.*.
|
||||
-- * Boot.probePayload-- read one archive's advertised version, isolated.
|
||||
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
|
||||
-- (if a payload wins) mount + chainload with full
|
||||
-- rollback on any failure so the bundled game always
|
||||
-- boots.
|
||||
--
|
||||
-- Known limitation: the bundled love.run keeps driving the frame loop after a
|
||||
-- handoff (it has already returned its stepper to LÖVE; redefining the global
|
||||
-- love.run does nothing to the running one). A payload that must change
|
||||
-- love.run itself therefore requires a minShell bump so an older shell refuses
|
||||
-- to chainload it.
|
||||
|
||||
local Semver = require("src.update.Semver")
|
||||
|
||||
local Boot = {}
|
||||
|
||||
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
|
||||
local PAYLOAD_DIR = "updates"
|
||||
local PENDING = "updates/pending.txt"
|
||||
|
||||
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
|
||||
-- copy never collides with the running source's copy at "/".
|
||||
local PROBE_MOUNT = "__pokeport_probe"
|
||||
|
||||
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
|
||||
local function isPayloadName(name)
|
||||
return name:match("^gen1recomp%-.+%.love$") ~= nil
|
||||
end
|
||||
|
||||
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
|
||||
-- We snapshot these before a handoff and restore them if the handoff fails, so
|
||||
-- the exact bundled closures (with their intact upvalues) drive the game
|
||||
-- again. love.run is included: harmless to restore, and it is one of the
|
||||
-- globals a payload main.lua reassigns.
|
||||
local CALLBACK_NAMES = {
|
||||
"load", "update", "draw", "quit", "run",
|
||||
"keypressed", "keyreleased", "textinput",
|
||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||
"touchpressed", "touchmoved", "touchreleased",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||
"errorhandler", "threaderror", "lowmemory",
|
||||
}
|
||||
|
||||
local function snapshotCallbacks()
|
||||
local snap = {}
|
||||
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
|
||||
return snap
|
||||
end
|
||||
|
||||
local function restoreCallbacks(snap)
|
||||
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
|
||||
end
|
||||
|
||||
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
|
||||
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
|
||||
-- Version into package.loaded["src.core.Version"]; without this purge the next
|
||||
-- require would hand back the bundled copy instead of the payload's. Setting
|
||||
-- existing fields to nil during a pairs traversal is explicitly permitted.
|
||||
local function purgeBundledModules()
|
||||
for key in pairs(package.loaded) do
|
||||
if key:match("^src%.") or key == "main" or key == "conf" then
|
||||
package.loaded[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
|
||||
--
|
||||
-- Mount the archive at rel (a save-directory-relative path) on an isolated
|
||||
-- mountpoint, read its src/core/Version.lua by executing the source with
|
||||
-- loadstring (NEVER require -- we must not cache or run it as a module), then
|
||||
-- unmount. Version.lua is zero-require, so running its chunk is safe.
|
||||
function Boot.probePayload(rel)
|
||||
if not love.filesystem.mount(rel, PROBE_MOUNT) then
|
||||
return nil, "could not mount " .. tostring(rel)
|
||||
end
|
||||
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
|
||||
local ok, result = pcall(function()
|
||||
local src = love.filesystem.read(chunkPath)
|
||||
if not src then error("Version.lua missing", 0) end
|
||||
local chunk = loadstring(src, "@" .. chunkPath)
|
||||
if not chunk then error("Version.lua would not compile", 0) end
|
||||
return chunk()
|
||||
end)
|
||||
love.filesystem.unmount(rel)
|
||||
if not ok then return nil, tostring(result) end
|
||||
local v = result
|
||||
if type(v) ~= "table" or type(v.engine) ~= "string" then
|
||||
return nil, "payload has no usable Version table"
|
||||
end
|
||||
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
|
||||
--
|
||||
-- Pure (no love.*): decide which payload to run and which to delete.
|
||||
-- candidates is a list of { name = , engine = , minShell = }.
|
||||
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
|
||||
-- whose minShell <= bundledShell (a payload the running shell can host).
|
||||
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
|
||||
-- already ship), or superseded by the chosen one (not newer than chosen).
|
||||
-- A payload newer than the chosen one but unrunnable here (minShell too
|
||||
-- high) is kept: a future shell upgrade may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
local chosen
|
||||
for _, c in ipairs(candidates) do
|
||||
local newer = Semver.compare(c.engine, bundledEngine) > 0
|
||||
local runnable = (c.minShell or 1) <= bundledShell
|
||||
if newer and runnable then
|
||||
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
|
||||
chosen = c
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local toDelete = {}
|
||||
for _, c in ipairs(candidates) do
|
||||
if not (chosen and c.name == chosen.name) then
|
||||
local stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
end
|
||||
if stale then toDelete[#toDelete + 1] = c.name end
|
||||
end
|
||||
end
|
||||
|
||||
return chosen and chosen.name or nil, toDelete
|
||||
end
|
||||
|
||||
-- Mount the chosen payload and hand control to it. Returns true when the
|
||||
-- payload is live and has completed its own love.load; false (with full
|
||||
-- rollback) on any failure, so the caller runs the bundled game instead.
|
||||
local function chainload(name, args)
|
||||
local rel = PAYLOAD_DIR .. "/" .. name
|
||||
|
||||
-- Crash marker: if we die between here and clearing it, the next boot's
|
||||
-- crash guard distrusts this payload and deletes it.
|
||||
love.filesystem.write(PENDING, name)
|
||||
|
||||
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
|
||||
-- over the fused source for every subsequent require / love.filesystem read.
|
||||
if not love.filesystem.mount(rel, "/", false) then
|
||||
love.filesystem.remove(PENDING)
|
||||
return false
|
||||
end
|
||||
|
||||
local snapshot = snapshotCallbacks()
|
||||
purgeBundledModules()
|
||||
_G.POKEPORT_PAYLOAD_MOUNTED = true
|
||||
|
||||
-- Chainload: run the payload's main.lua (redefines the love callbacks from
|
||||
-- the NEW code), then call its love.load. The new love.load calls Boot.run
|
||||
-- again, which no-ops via the flag set above.
|
||||
local ok, err = pcall(function()
|
||||
local chunk = assert(love.filesystem.load("main.lua"))
|
||||
chunk()
|
||||
love.load(args)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
-- Handoff failed after mounting. Unwind everything so the bundled game
|
||||
-- boots cleanly: clear the flag, unmount the payload, purge any payload
|
||||
-- modules it cached (so bundled requires reload from source), restore the
|
||||
-- bundled love callbacks with their intact upvalues, and drop the marker.
|
||||
-- Delete the payload too: it failed deterministically once, so leaving it
|
||||
-- would re-select and re-fail it on every boot forever.
|
||||
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
|
||||
_G.POKEPORT_PAYLOAD_MOUNTED = nil
|
||||
pcall(love.filesystem.unmount, rel)
|
||||
purgeBundledModules()
|
||||
restoreCallbacks(snapshot)
|
||||
love.filesystem.remove(rel)
|
||||
love.filesystem.remove(PENDING)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Success: the payload owns the game now. Drop the marker and tell the
|
||||
-- caller to stop so the bundled love.load does not run on top of it.
|
||||
love.filesystem.remove(PENDING)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Everything after the fused / flag guards, wrapped so an unexpected error in
|
||||
-- enumeration or selection can never crash the boot.
|
||||
local function runInner(args)
|
||||
-- Crash guard first: a pending.txt naming a payload means a previous boot
|
||||
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
|
||||
-- then continue (we may still pick an older valid payload, or fall through
|
||||
-- to the bundled game).
|
||||
local pending = love.filesystem.read(PENDING)
|
||||
if pending then
|
||||
pending = pending:gsub("%s+$", "")
|
||||
if pending ~= "" then
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
|
||||
end
|
||||
love.filesystem.remove(PENDING)
|
||||
end
|
||||
|
||||
-- Enumerate and probe every payload in updates/.
|
||||
local candidates = {}
|
||||
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
|
||||
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
|
||||
if isPayloadName(entry) then
|
||||
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
|
||||
if info then
|
||||
candidates[#candidates + 1] = {
|
||||
name = entry,
|
||||
engine = info.engine,
|
||||
minShell = info.minShell,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local Version = require("src.core.Version")
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
|
||||
|
||||
for _, victim in ipairs(toDelete) do
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
|
||||
end
|
||||
|
||||
if not chosen then return false end
|
||||
return chainload(chosen, args)
|
||||
end
|
||||
|
||||
-- Boot.run(args) -> boolean
|
||||
--
|
||||
-- The first line of love.load. True means a payload was mounted and
|
||||
-- chainloaded and the caller must return immediately; false means boot the
|
||||
-- bundled game as normal.
|
||||
function Boot.run(args)
|
||||
-- Dev / source checkouts never self-update.
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then
|
||||
return false
|
||||
end
|
||||
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
|
||||
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
|
||||
|
||||
local ok, result = pcall(runInner, args)
|
||||
if not ok then
|
||||
-- An error escaped before any handoff mount (chainload cleans up after
|
||||
-- itself), so state is still clean. Never crash the boot.
|
||||
return false
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
return Boot
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Async release-check and payload-download for the self-update flow.
|
||||
--
|
||||
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
|
||||
-- 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_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.
|
||||
--
|
||||
-- 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
|
||||
-- worker can reuse the exact same code path via love.filesystem.load.
|
||||
|
||||
local Check = {}
|
||||
|
||||
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
|
||||
|
||||
local CMD = "update_check_cmd"
|
||||
local STATE = "update_check_state"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- pure helpers (no love.*) -- also used inside the worker
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Find the release asset named exactly `name`, returning its download URL and
|
||||
-- byte size (or nil when the release has no such asset).
|
||||
function Check.pickAsset(assets, name)
|
||||
if type(assets) ~= "table" then return nil end
|
||||
for _, a in ipairs(assets) do
|
||||
if type(a) == "table" and a.name == name then
|
||||
return { url = a.browser_download_url, size = tonumber(a.size) }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function stripV(tag)
|
||||
return (tostring(tag):gsub("^[vV]", ""))
|
||||
end
|
||||
|
||||
-- Decode a GitHub "releases/latest" response into just the fields the updater
|
||||
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
|
||||
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
|
||||
-- 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)
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" or not doc.tag_name then
|
||||
return nil, "no tag_name in release json"
|
||||
end
|
||||
local version = stripV(doc.tag_name)
|
||||
if not version:match("^%d+%.%d+%.%d+$") then
|
||||
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
|
||||
end
|
||||
local payloadName = "gen1recomp-" .. version .. ".love"
|
||||
return {
|
||||
version = version,
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||
}
|
||||
end
|
||||
|
||||
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
|
||||
-- `target` argument returns just that file's hash (or nil); otherwise returns
|
||||
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
|
||||
function Check.parseSums(text, target)
|
||||
local map = {}
|
||||
for line in tostring(text):gmatch("[^\r\n]+") do
|
||||
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
|
||||
if hash and file then
|
||||
map[(file:gsub("^%./", ""))] = hash:lower()
|
||||
end
|
||||
end
|
||||
if target ~= nil then return map[target] end
|
||||
return map
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- main-thread state machine
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function Check.releaseUrl()
|
||||
return "https://github.com/" .. Check.REPO .. "/releases/latest"
|
||||
end
|
||||
|
||||
local worker -- the love.thread, once started
|
||||
local cmdCh, stateCh -- the two channels
|
||||
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 ensureWorker()
|
||||
if workerReady ~= nil then return workerReady end
|
||||
if not (love and love.thread and love.thread.newThread) then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
|
||||
if not ok or not th then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
cmdCh = love.thread.getChannel(CMD)
|
||||
stateCh = love.thread.getChannel(STATE)
|
||||
if not pcall(function() th:start() end) then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
worker = th
|
||||
workerReady = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pull every pending snapshot off the state channel (keeping the newest) and
|
||||
-- surface a worker crash as a soft error the UI can hide on.
|
||||
local function drain()
|
||||
if stateCh then
|
||||
local msg = stateCh:pop()
|
||||
while msg do
|
||||
cache = msg
|
||||
msg = stateCh:pop()
|
||||
end
|
||||
end
|
||||
if worker then
|
||||
local err = worker:getError()
|
||||
if err then
|
||||
cache = { status = "error", error = tostring(err) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op.
|
||||
function Check.start()
|
||||
drain()
|
||||
if cache.status == "checking" or cache.status == "downloading" then return end
|
||||
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not ensureWorker() then
|
||||
cache = { status = "error", error = "background threads unavailable" }
|
||||
return
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking" }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
return {
|
||||
status = cache.status or "idle",
|
||||
latest = cache.latest,
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
}
|
||||
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()
|
||||
drain()
|
||||
if not cmdCh then return end
|
||||
if cache.status ~= "available" then return end
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0 }
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
return Check
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater.
|
||||
-- Every part of the updater (Boot, Check, the release picker) agrees on this
|
||||
-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua
|
||||
-- tests can exercise it and Boot can use it during the earliest boot step.
|
||||
--
|
||||
-- We only need the numeric core (major.minor.patch): the engine field is a
|
||||
-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working
|
||||
-- tree. Pre-release / build metadata is intentionally not supported -- a
|
||||
-- "-dev" or any other suffix makes parse fail, which is the safe answer for
|
||||
-- the updater (a dev checkout never counts as a real release to chainload).
|
||||
|
||||
local Semver = {}
|
||||
|
||||
-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into
|
||||
-- { major = n, minor = n, patch = n }. Returns nil for anything else --
|
||||
-- extra components, non-numeric parts, or a trailing suffix like "-dev".
|
||||
function Semver.parse(s)
|
||||
if type(s) ~= "string" then return nil end
|
||||
local body = s:match("^v?(.+)$")
|
||||
if not body then return nil end
|
||||
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
|
||||
if not maj then return nil end
|
||||
return {
|
||||
major = tonumber(maj),
|
||||
minor = tonumber(min),
|
||||
patch = tonumber(pat),
|
||||
}
|
||||
end
|
||||
|
||||
-- Coerce an argument that is either an already-parsed table or a version
|
||||
-- string into a parsed table (or nil).
|
||||
local function coerce(v)
|
||||
if type(v) == "table" then return v end
|
||||
return Semver.parse(v)
|
||||
end
|
||||
|
||||
-- Compare two versions, each a parsed table or an X.Y.Z string.
|
||||
-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side
|
||||
-- sorts as the lowest possible version so a bogus value never wins a "newer"
|
||||
-- test; two unparseable sides compare equal.
|
||||
function Semver.compare(a, b)
|
||||
local pa, pb = coerce(a), coerce(b)
|
||||
if not pa and not pb then return 0 end
|
||||
if not pa then return -1 end
|
||||
if not pb then return 1 end
|
||||
for _, field in ipairs({ "major", "minor", "patch" }) do
|
||||
if pa[field] < pb[field] then return -1 end
|
||||
if pa[field] > pb[field] then return 1 end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
return Semver
|
||||
@@ -0,0 +1,343 @@
|
||||
-- Background worker for the self-update flow (driven by src/update/Check.lua).
|
||||
--
|
||||
-- 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_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.
|
||||
--
|
||||
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
|
||||
-- modules are pulled in with love.filesystem.load exactly like
|
||||
-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we
|
||||
-- load them defensively and degrade (a local semver fallback, a permissive
|
||||
-- gate) if they are not present yet.
|
||||
|
||||
require("love.thread")
|
||||
require("love.filesystem")
|
||||
require("love.data")
|
||||
require("love.timer")
|
||||
require("love.system")
|
||||
|
||||
local function loadModule(path)
|
||||
local ok, chunk = pcall(love.filesystem.load, path)
|
||||
if not ok or type(chunk) ~= "function" then return nil end
|
||||
local ok2, mod = pcall(chunk)
|
||||
if not ok2 then return nil end
|
||||
return mod
|
||||
end
|
||||
|
||||
local Json = loadModule("src/link/Json.lua")
|
||||
local Check = loadModule("src/update/Check.lua")
|
||||
local Version = loadModule("src/core/Version.lua")
|
||||
local Semver = loadModule("src/update/Semver.lua")
|
||||
-- Boot's top-level require("src.update.Semver") cannot resolve in this thread
|
||||
-- (no src.* searcher), which would leave Boot nil and the minShell gate
|
||||
-- permanently permissive. Seed the loaded table first so it resolves.
|
||||
if Semver then package.loaded["src.update.Semver"] = Semver end
|
||||
local Boot = loadModule("src/update/Boot.lua")
|
||||
|
||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||
local stateCh = love.thread.getChannel("update_check_state")
|
||||
|
||||
local function post(t) stateCh:push(t) end
|
||||
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
local isWindows = osName == "Windows"
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
|
||||
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/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 / curl
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if isWindows then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
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 ok, pipe = pcall(io.popen, cmd)
|
||||
if not ok or not pipe then return nil end
|
||||
local out = pipe:read("*a")
|
||||
pipe:close()
|
||||
if not out or out == "" then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
local function haveCurl()
|
||||
local ok, pipe = pcall(io.popen, "curl --version")
|
||||
if not ok or not pipe then return false end
|
||||
local out = pipe:read("*a")
|
||||
pipe:close()
|
||||
return out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- version compare (Semver per contract item 5, with a local fallback)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function parseTriple(s)
|
||||
s = (tostring(s):gsub("^[vV]", ""))
|
||||
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)")
|
||||
if not a then return nil end
|
||||
return { tonumber(a), tonumber(b), tonumber(c) }
|
||||
end
|
||||
|
||||
-- -1 | 0 | 1 for a<b | a==b | a>b
|
||||
local function compareVersions(a, b)
|
||||
if Semver and Semver.compare then
|
||||
local ok, r = pcall(Semver.compare, a, b)
|
||||
if ok and r ~= nil then return r end
|
||||
end
|
||||
local pa, pb = parseTriple(a), parseTriple(b)
|
||||
if not pa or not pb then return 0 end
|
||||
for i = 1, 3 do
|
||||
if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- verification and the shell gate
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function sha256hex(data)
|
||||
local digest = love.data.hash("sha256", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest)
|
||||
end
|
||||
|
||||
-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`.
|
||||
local function verifyPayload(rel, payloadName, sumsText)
|
||||
local want = Check.parseSums(sumsText, payloadName)
|
||||
if not want then return false, "no checksum for " .. payloadName end
|
||||
local data = love.filesystem.read(rel)
|
||||
if not data then return false, "cannot read downloaded payload" end
|
||||
if sha256hex(data):lower() ~= want:lower() then
|
||||
return false, "checksum mismatch"
|
||||
end
|
||||
return true
|
||||
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.
|
||||
local function gatePasses(rel)
|
||||
if not (Boot and Boot.probePayload) then return true end
|
||||
local info = Boot.probePayload(rel)
|
||||
if not info then return true end
|
||||
local shell = (Version and Version.shell) or 1
|
||||
if info.minShell and info.minShell > shell then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function doCheck()
|
||||
post({ status = "checking" })
|
||||
|
||||
if not haveCurl() then
|
||||
post({ status = "error", error = "curl not available" })
|
||||
return
|
||||
end
|
||||
|
||||
local body = curlCapture(API_URL)
|
||||
if not body then
|
||||
post({ status = "error", error = "release check failed" })
|
||||
return
|
||||
end
|
||||
|
||||
local rel, perr = Check.parseRelease(body, Json)
|
||||
if not rel then
|
||||
post({ status = "error", error = perr or "bad release json" })
|
||||
return
|
||||
end
|
||||
pending = rel
|
||||
|
||||
-- Unstamped dev build: the working tree always looks "newer", so never
|
||||
-- pester the developer with an update (contract item, Check design).
|
||||
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
|
||||
if currentEngine == "0.0.0-dev" then
|
||||
post({ status = "uptodate", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||
post({ status = "uptodate", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
-- 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 })
|
||||
return
|
||||
end
|
||||
|
||||
-- Already downloaded on a previous run? Verify and gate it rather than
|
||||
-- pulling the bytes again.
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
if love.filesystem.getInfo(finalRel) then
|
||||
local sums = curlCapture(rel.sums.url)
|
||||
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
||||
if gatePasses(finalRel) == false then
|
||||
love.filesystem.remove(finalRel)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
return
|
||||
end
|
||||
post({ status = "ready", latest = rel.version })
|
||||
return
|
||||
end
|
||||
-- stale / corrupt: drop it and offer a fresh download
|
||||
love.filesystem.remove(finalRel)
|
||||
end
|
||||
|
||||
post({ status = "available", latest = rel.version })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- download
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it
|
||||
-- exits. Returns without waiting so the caller can poll the growing file for
|
||||
-- progress. We deliberately do not capture curl's exit code: an incomplete or
|
||||
-- failed transfer simply fails the checksum below, which is the real gate.
|
||||
local function launchDownload(url, partAbs, doneAbs)
|
||||
if isWindows then
|
||||
-- a tiny batch file sidesteps cmd.exe's nested-quote madness
|
||||
local batRel = "updates/dl.bat"
|
||||
love.filesystem.write(batRel,
|
||||
"@echo off\r\n"
|
||||
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
|
||||
.. partAbs .. "\" \"" .. url .. "\"\r\n"
|
||||
.. "type nul > \"" .. doneAbs .. "\"\r\n")
|
||||
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
|
||||
else
|
||||
-- ( ... ) & backgrounds the whole group so os.execute returns at once
|
||||
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
|
||||
.. shq(partAbs) .. " " .. shq(url)
|
||||
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
|
||||
end
|
||||
end
|
||||
|
||||
local function doDownload()
|
||||
if not (pending and pending.payload and pending.payload.url) then
|
||||
post({ status = "error", error = "nothing to download" })
|
||||
return
|
||||
end
|
||||
local rel = pending
|
||||
post({ status = "downloading", latest = rel.version, progress = 0 })
|
||||
|
||||
love.filesystem.createDirectory("updates")
|
||||
local partRel = "updates/" .. rel.payloadName .. ".part"
|
||||
local doneRel = "updates/" .. rel.payloadName .. ".done"
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
love.filesystem.remove(partRel)
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part"
|
||||
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
|
||||
local size = rel.payload.size or 0
|
||||
|
||||
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
|
||||
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
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local sums = curlCapture(rel.sums and rel.sums.url or "")
|
||||
if not sums then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "checksum fetch failed" })
|
||||
return
|
||||
end
|
||||
|
||||
local ok, verr = verifyPayload(partRel, rel.payloadName, sums)
|
||||
if not ok then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = verr or "verification failed" })
|
||||
return
|
||||
end
|
||||
|
||||
if gatePasses(partRel) == false then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
-- finalize: rename the verified .part to its real name (fall back to a
|
||||
-- love.filesystem copy if os.rename is unavailable on this platform)
|
||||
if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then
|
||||
local data = love.filesystem.read(partRel)
|
||||
if not data then
|
||||
post({ status = "error", error = "finalize failed" })
|
||||
return
|
||||
end
|
||||
love.filesystem.write(finalRel, data)
|
||||
love.filesystem.remove(partRel)
|
||||
end
|
||||
|
||||
post({ status = "ready", latest = rel.version })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- command loop
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
while true do
|
||||
local cmd = cmdCh:demand() -- blocks until the main thread pushes work
|
||||
if type(cmd) == "table" then
|
||||
if cmd.cmd == "quit" then
|
||||
break
|
||||
elseif cmd.cmd == "check" then
|
||||
local ok, err = pcall(doCheck)
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user