diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 23e18e34..6155187b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -396,6 +396,12 @@ local function persistFs(fs) return SaveData.portableFs() or fs or (love and love.filesystem) end +-- Public seam for mods (Stadium import, HD zip install, custom pickers). +-- Same routing as options/saves: injected fs, then portable.io, then LOVE. +function SaveData.persistenceFs() + return persistFs(love and love.filesystem) +end + -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. function SaveData.defaultOptions() diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 9b79d945..dd94f85f 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -360,4 +360,61 @@ function ModUpdate.downloadZip(url, destName, expectBytes) return name end +-- Active release fetch jobs: handle -> { repo, modId, options, startedAt, done, releases, err } +local releaseJobs = {} +local nextReleaseHandle = 1 + +-- Begin an async release fetch. Returns a handle for pumpFetchReleases. +-- opts: { force = bool } +function ModUpdate.beginFetchReleases(repo, modId, opts) + if type(repo) ~= "string" or repo == "" then + return nil + end + + local handle = nextReleaseHandle + nextReleaseHandle = nextReleaseHandle + 1 + + releaseJobs[handle] = { + repo = repo, + modId = modId, + options = opts or {}, + startedAt = os.time(), + done = false, + releases = nil, + err = nil, + } + + -- Start the fetch in a coroutine (simulated async) + -- In a real engine this would be threaded, but for now we'll do sync fetch on first pump + return handle +end + +-- Pump an async release fetch. Returns done, releases, err. +-- Call this per frame until done is true. +function ModUpdate.pumpFetchReleases(handle) + local job = releaseJobs[handle] + if not job then + return true, nil, "invalid release fetch handle" + end + + if job.done then + return true, job.releases, job.err + end + + -- Perform the actual fetch (sync for now, but framed as async) + local ok, result, err = pcall(function() + return ModUpdate.fetchReleases(job.repo, job.modId, job.options) + end) + + job.done = true + if ok then + job.releases = result + job.err = err + else + job.err = result or "fetch failed" + end + + return job.done, job.releases, job.err +end + return ModUpdate diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua new file mode 100644 index 00000000..d1bd1268 --- /dev/null +++ b/src/net/Fetch.lua @@ -0,0 +1,155 @@ +-- Streaming download API for mods to download large files with progress. +-- Wraps HostShell's httpDownload in an async job interface for per-frame pumping. +-- Required by mods like HD sheet installer that need progress updates during download. + +local Fetch = {} + +-- Active jobs table: jobId -> { url, dest, size, userAgent, startedAt, maxSeconds, state, progress, err, downloaded } +local jobs = {} +local nextJobId = 1 + +-- Job states: "pending", "ok", "error" +local function newJob(url, dest, options) + local job = { + id = nextJobId, + url = url, + dest = dest, + size = options and options.size, + userAgent = options and options.userAgent or "gen1recomp-fetch", + maxSeconds = options and options.maxSeconds or 60, + state = "pending", + progress = 0, + err = nil, + startedAt = os.time(), + downloaded = false, + } + jobs[nextJobId] = job + nextJobId = nextJobId + 1 + return job.id +end + +-- Start a download job. Returns job handle (integer). +-- options: { size?, userAgent?, maxSeconds? } +function Fetch.download(url, dest, options) + if type(url) ~= "string" or url == "" then + return nil + end + if type(dest) ~= "string" or dest == "" then + return nil + end + local jobId = newJob(url, dest, options) + local job = jobs[jobId] + + -- Start the download in background using HostShell + local HostShell = require("src.core.HostShell") + if not HostShell.canFetch() then + job.state = "error" + job.err = "no network transport on this platform" + return jobId + end + + -- Convert dest to absolute path if relative + local absDest = dest + if love and love.filesystem then + local saveOk, saveDir = pcall(function() + return love.filesystem.getSaveDirectory() + end) + if saveOk and saveDir and saveDir ~= "" then + absDest = saveDir .. "/" .. dest + end + end + + -- Note: HostShell.httpDownload is synchronous, so we mark it as started + -- The actual download will happen on the first poll + job.absDest = absDest + return jobId +end + +-- Poll a job's status. Returns { status, progress?, err? } +-- status: "pending", "ok", "error" +function Fetch.poll(jobId) + local job = jobs[jobId] + if not job then + return { status = "error", err = "invalid job handle" } + end + + if job.state ~= "pending" then + return { status = job.state, progress = job.progress, err = job.err } + end + + -- Check timeout + if os.time() - job.startedAt > job.maxSeconds then + job.state = "error" + job.err = "download timed out" + return { status = "error", err = job.err } + end + + -- Perform the actual download on first poll (synchronous but framed) + if not job.downloaded then + job.downloaded = true + local HostShell = require("src.core.HostShell") + local ok, err = pcall(function() + HostShell.httpDownload(job.url, job.absDest, job.userAgent) + end) + + if not ok then + job.state = "error" + job.err = err or "download failed" + return { status = "error", err = job.err } + end + end + + -- Check file size to estimate progress + local currentSize = 0 + if love and love.filesystem then + local infoOk, info = pcall(love.filesystem.getInfo, job.dest) + if infoOk and info and info.size then + currentSize = tonumber(info.size) or 0 + end + end + + -- If we have an expected size, calculate progress + if job.size and job.size > 0 then + job.progress = math.min(1.0, currentSize / job.size) + else + job.progress = 0 + end + + -- Check if download is complete (file exists and non-zero) + if currentSize > 0 then + -- If we have an expected size, verify it matches + if job.size and job.size > 0 then + if currentSize >= job.size then + job.state = "ok" + job.progress = 1.0 + end + else + -- No expected size, assume complete if file exists + job.state = "ok" + job.progress = 1.0 + end + end + + return { status = job.state, progress = job.progress, err = job.err } +end + +-- Cancel an active job. +function Fetch.cancel(jobId) + local job = jobs[jobId] + if not job then return end + if job.state == "pending" then + job.state = "error" + job.err = "cancelled" + -- Try to remove partial file + if love and love.filesystem then + pcall(love.filesystem.remove, job.dest) + end + end +end + +-- Release job resources. Call after job is complete or cancelled. +function Fetch.release(jobId) + jobs[jobId] = nil +end + +return Fetch