mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Merge branch 'dev' into feat/switch-nx
Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods (#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L display chords still work. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -265,6 +265,21 @@ function LauncherMods.setEnabled(id, enabled)
|
||||
return true
|
||||
end
|
||||
|
||||
-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons
|
||||
-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and
|
||||
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
||||
-- call, so looping setEnabled over a big mods folder is one disk write per mod
|
||||
-- and leaves a half-applied state behind if one of them fails.
|
||||
function LauncherMods.setAllEnabled(ids, enabled)
|
||||
local options = SaveData.loadOptions()
|
||||
options.mods = options.mods or {}
|
||||
for _, id in ipairs(ids or {}) do
|
||||
options.mods[id] = enabled and true or false
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- install (love.filesystem)
|
||||
|
||||
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
|
||||
|
||||
@@ -11,6 +11,7 @@ local Version = require("src.core.Version")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Strings = require("src.core.Strings")
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
|
||||
local ManagerState = {}
|
||||
ManagerState.__index = ManagerState
|
||||
@@ -180,6 +181,16 @@ end
|
||||
|
||||
function ManagerState:enter()
|
||||
self:refresh()
|
||||
-- #593: the setup that existed before profiles shipped becomes PROFILE 1
|
||||
-- the first time the manager opens, so switching away from it is always a
|
||||
-- round trip. Seeded here rather than at boot because profiles are only
|
||||
-- ever read on this screen.
|
||||
local seeded = ModProfile.ensureFirst(self:optionsTable(),
|
||||
self.status.available, self:modOptionsTable())
|
||||
if seeded then
|
||||
self:persistOptions()
|
||||
self:refresh()
|
||||
end
|
||||
if Runtime.safeMode then
|
||||
self.banner = "SAFE MODE - ALL MODS OFF"
|
||||
end
|
||||
@@ -213,6 +224,12 @@ function ManagerState:optionsTable()
|
||||
return (save and save.options) or {}
|
||||
end
|
||||
|
||||
-- Per-mod option values as persisted (options.modOptions; setOption below is
|
||||
-- the only writer). A profile captures this alongside the enable set.
|
||||
function ManagerState:modOptionsTable()
|
||||
return self:optionsTable().modOptions or {}
|
||||
end
|
||||
|
||||
function ManagerState:manifestMap()
|
||||
return self.byId or {}
|
||||
end
|
||||
@@ -283,6 +300,10 @@ function ManagerState:profileRows()
|
||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||
end
|
||||
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
|
||||
-- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist
|
||||
-- in the save dir, IMPORT reads back every file dropped in that folder.
|
||||
rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") }
|
||||
rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") }
|
||||
rows[#rows + 1] = { adhoc = true,
|
||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||
return rows
|
||||
@@ -478,6 +499,10 @@ function ManagerState:activate()
|
||||
self:applyProfile(row.profile)
|
||||
elseif row.saveAs then
|
||||
self:saveCurrentAs()
|
||||
elseif row.exportProfile then
|
||||
self:exportActiveProfile()
|
||||
elseif row.importProfile then
|
||||
self:importProfiles()
|
||||
elseif row.adhoc then
|
||||
self:optionsTable().activeProfile = nil
|
||||
self:notify("AD-HOC SET ACTIVE")
|
||||
@@ -712,9 +737,26 @@ function ManagerState:applyProfile(p)
|
||||
end
|
||||
end
|
||||
self:commitToggle(combined)
|
||||
-- #593: a profile is the whole setup, not just the enable set. Options go
|
||||
-- through setOption so loader.modOptions and mod.options_changed stay in
|
||||
-- step; save slots move only to slots that version already registered
|
||||
-- (SaveData.setActiveSlot would otherwise conjure one).
|
||||
for modId, bucket in pairs(p.options or {}) do
|
||||
if self.byId[modId] then
|
||||
for key, value in pairs(bucket) do self:setOption(modId, key, value) end
|
||||
end
|
||||
end
|
||||
for _, move in ipairs(ModProfile.slotMoves(p)) do
|
||||
require("src.core.SaveData").setActiveSlot(move[1], move[2])
|
||||
end
|
||||
self:optionsTable().activeProfile = p.name
|
||||
self:persistOptions()
|
||||
self:notify("PROFILE STAGED")
|
||||
local missing = ModProfile.missingIds(p, self.byId)
|
||||
if #missing > 0 then
|
||||
self:notify(#missing .. " MODS MISSING")
|
||||
else
|
||||
self:notify("PROFILE STAGED")
|
||||
end
|
||||
end
|
||||
|
||||
function ManagerState:saveCurrentAs()
|
||||
@@ -725,16 +767,15 @@ function ManagerState:saveCurrentAs()
|
||||
onDone = function(name)
|
||||
local opts = self:optionsTable()
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
local enabled = {}
|
||||
for _, m in ipairs(self.status.available or {}) do
|
||||
enabled[m.id] = m.enabled and true or false
|
||||
end
|
||||
local snap = ModProfile.capture(self.status.available,
|
||||
self:modOptionsTable())
|
||||
local existing = self:findProfile(name)
|
||||
if existing then
|
||||
existing.enabled = enabled
|
||||
existing.enabled, existing.options, existing.slots =
|
||||
snap.enabled, snap.options, snap.slots
|
||||
else
|
||||
opts.modProfiles[#opts.modProfiles + 1] =
|
||||
{ name = name, enabled = enabled }
|
||||
snap.name = name
|
||||
opts.modProfiles[#opts.modProfiles + 1] = snap
|
||||
end
|
||||
opts.activeProfile = name
|
||||
self:persistOptions()
|
||||
@@ -743,6 +784,33 @@ function ManagerState:saveCurrentAs()
|
||||
}))
|
||||
end
|
||||
|
||||
-- #593 sharing. Export drops the active profile (or the focused one) in the
|
||||
-- save directory's profiles/ folder as <NAME>.g1rmodlist; import reads every
|
||||
-- file already there. Deliberately folder-based rather than a native file
|
||||
-- picker: the same shape SaveFileIO uses for exports/, and it works on the
|
||||
-- mobile builds where no picker exists.
|
||||
function ManagerState:exportActiveProfile()
|
||||
local row = self:focusedRow()
|
||||
local p = (row and row.profile)
|
||||
or self:findProfile(self:optionsTable().activeProfile)
|
||||
if not p then return self:notify("NO PROFILE") end
|
||||
local ok = ModProfile.export(p)
|
||||
self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED")
|
||||
end
|
||||
|
||||
function ManagerState:importProfiles()
|
||||
local opts = self:optionsTable()
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
local n = 0
|
||||
for _, path in ipairs(ModProfile.files()) do
|
||||
if ModProfile.import(path, opts.modProfiles) then n = n + 1 end
|
||||
end
|
||||
if n == 0 then return self:notify("NO MODLISTS") end
|
||||
self:persistOptions()
|
||||
self:snapCursor()
|
||||
self:notify(n .. " IMPORTED")
|
||||
end
|
||||
|
||||
function ManagerState:renameProfile(p)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
|
||||
+15
-2
@@ -178,9 +178,21 @@ function Manifest.validate(raw, path)
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
|
||||
-- #501: a translation declares itself here. Neither the ROM's dialogue
|
||||
-- nor the engine's own strings are hashed into the link surface
|
||||
-- (src/link/Fingerprint.lua header), so an English install and a Spanish
|
||||
-- one run the same lockstep simulation, exactly as two regional carts on
|
||||
-- a real cable did. The flag is only the author's claim;
|
||||
-- Handshake.onlineBlockers checks it against the ops the mod actually
|
||||
-- appended before online play trusts it.
|
||||
assert(raw.language == nil or type(raw.language) == "boolean",
|
||||
"language must be a boolean")
|
||||
local language = raw.language == true
|
||||
|
||||
-- overhauls and total conversions are assumed to move the link
|
||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||
local affectsLink = profile ~= "content"
|
||||
-- fingerprint unless the manifest says otherwise; content packs and
|
||||
-- declared translations are not
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
@@ -211,6 +223,7 @@ function Manifest.validate(raw, path)
|
||||
github = github,
|
||||
experimental = experimental,
|
||||
profile = profile,
|
||||
language = language,
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
permissionSet = permissionSet,
|
||||
|
||||
+8
-25
@@ -520,36 +520,19 @@ function ModIndex.writeCache(feed, index)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows" then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
|
||||
|
||||
-- Plain GET returning the body. No GitHub Accept header: the feed and the
|
||||
-- description markdown are static files on Pages, and the public feed is
|
||||
-- explicitly unauthenticated.
|
||||
-- explicitly unauthenticated. The transport itself lives in
|
||||
-- src/core/HostShell.lua because Android has no curl and has to fetch through
|
||||
-- love.system.httpDownload instead (#597).
|
||||
function ModIndex.httpGet(url)
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
if not ModUpdate.haveCurl() then
|
||||
return nil, "curl is not available on this platform"
|
||||
end
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
|
||||
.. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
if not readOk then return nil, "fetch failed: " .. tostring(out) end
|
||||
if not out or out == "" then return nil, "empty response from " .. url end
|
||||
return out
|
||||
if not HostShell.canFetch() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
return HostShell.httpGet(url, "gen1recomp-mod-index")
|
||||
end
|
||||
|
||||
-- fetch(source, opts) -> index, err, meta
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
-- Mod profiles (#593): a named snapshot of one whole setup -- which mods are
|
||||
-- enabled, each mod's own options, and which save slot every game version
|
||||
-- plays -- plus the shareable .g1rmodlist file the manager writes and reads.
|
||||
-- Pure data and filesystem: src/mods/ManagerState.lua owns the UI and the
|
||||
-- staged toggles, and is the only caller.
|
||||
--
|
||||
-- Records live in options.modProfiles (src/core/SaveData.lua defaultOptions),
|
||||
-- so a profile survives New Game exactly like the mod enable-state does:
|
||||
-- { name = "PROFILE 1",
|
||||
-- enabled = { [modId] = true|false }, -- missing means enabled
|
||||
-- options = { [modId] = { key = value } },-- mirrors options.modOptions
|
||||
-- slots = { red = "slot1", blue = "slot2", yellow = nil } }
|
||||
-- Exports are SaveSerializer-encoded (data-only parse, an imported file can
|
||||
-- never execute code) under profiles/<NAME>.g1rmodlist in the save dir, the
|
||||
-- same shape SaveFileIO uses for exports/ (src/import/SaveFileIO.lua).
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local ModProfile = {}
|
||||
|
||||
ModProfile.EXT = ".g1rmodlist"
|
||||
ModProfile.DIR = "profiles"
|
||||
ModProfile.FORMAT = "g1rmodlist"
|
||||
ModProfile.FORMAT_VERSION = 1
|
||||
|
||||
-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has
|
||||
-- to encode the same way twice for a diff to mean anything
|
||||
local VERSION_ORDER = { "red", "blue", "yellow" }
|
||||
|
||||
local function fsOr(fs)
|
||||
return fs or (love and love.filesystem) or nil
|
||||
end
|
||||
|
||||
-- Capture the live setup. `available` is ManagerState.status.available (the
|
||||
-- loader's status manifests, m.enabled = the desired set including staged
|
||||
-- flips); `modOptions` is options.modOptions.
|
||||
function ModProfile.capture(available, modOptions)
|
||||
local enabled, options = {}, {}
|
||||
for _, m in ipairs(available or {}) do
|
||||
enabled[m.id] = m.enabled and true or false
|
||||
local bucket = modOptions and modOptions[m.id]
|
||||
if type(bucket) == "table" then
|
||||
local copy = {}
|
||||
for k, v in pairs(bucket) do
|
||||
local t = type(v)
|
||||
if t == "string" or t == "number" or t == "boolean" then copy[k] = v end
|
||||
end
|
||||
options[m.id] = copy
|
||||
end
|
||||
end
|
||||
local slots = {}
|
||||
for _, id in ipairs(VERSION_ORDER) do
|
||||
local ok, slot = pcall(SaveData.activeSlot, id)
|
||||
if ok and slot then slots[id] = slot end
|
||||
end
|
||||
return { enabled = enabled, options = options, slots = slots }
|
||||
end
|
||||
|
||||
-- The slot moves a profile may actually make: a slot id has to be registered
|
||||
-- for that version already, because SaveData.setActiveSlot registers an
|
||||
-- unknown id and would conjure a phantom slot the player never made.
|
||||
function ModProfile.slotMoves(p)
|
||||
local moves = {}
|
||||
for _, version in ipairs(VERSION_ORDER) do
|
||||
local want = p.slots and p.slots[version]
|
||||
if want then
|
||||
local ok, list = pcall(SaveData.listSlots, version)
|
||||
if ok then
|
||||
for _, row in ipairs(list or {}) do
|
||||
if row.id == want then moves[#moves + 1] = { version, want } end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return moves
|
||||
end
|
||||
|
||||
-- ids the profile wants enabled that are not installed here. Import cannot
|
||||
-- fetch them (community-index install is deferred), so the manager reports
|
||||
-- them rather than silently playing a different setup than the sharer's.
|
||||
function ModProfile.missingIds(p, byId)
|
||||
local out = {}
|
||||
for id, on in pairs(p.enabled or {}) do
|
||||
if on and not (byId or {})[id] then out[#out + 1] = id end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
function ModProfile.encode(p)
|
||||
return SaveSerializer.encode({
|
||||
format = ModProfile.FORMAT,
|
||||
formatVersion = ModProfile.FORMAT_VERSION,
|
||||
profile = { name = p.name, enabled = p.enabled,
|
||||
options = p.options, slots = p.slots },
|
||||
})
|
||||
end
|
||||
|
||||
-- Shape-validate rather than trust: a shared file is untrusted input, and the
|
||||
-- manager indexes p.enabled/p.options straight into its row models.
|
||||
function ModProfile.decode(body)
|
||||
if type(body) ~= "string" then return nil, "EMPTY FILE" end
|
||||
local ok, data = pcall(SaveSerializer.decode, body)
|
||||
if not ok or type(data) ~= "table" then return nil, "BAD FILE" end
|
||||
if data.format ~= ModProfile.FORMAT then return nil, "NOT A MODLIST" end
|
||||
local raw = data.profile
|
||||
if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then
|
||||
return nil, "BAD FILE"
|
||||
end
|
||||
local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} }
|
||||
for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do
|
||||
if type(id) == "string" then p.enabled[id] = on and true or false end
|
||||
end
|
||||
for id, bucket in pairs(type(raw.options) == "table" and raw.options or {}) do
|
||||
if type(id) == "string" and type(bucket) == "table" then
|
||||
local copy = {}
|
||||
for k, v in pairs(bucket) do
|
||||
local t = type(v)
|
||||
if type(k) == "string" and (t == "string" or t == "number" or t == "boolean") then
|
||||
copy[k] = v
|
||||
end
|
||||
end
|
||||
p.options[id] = copy
|
||||
end
|
||||
end
|
||||
for version, slot in pairs(type(raw.slots) == "table" and raw.slots or {}) do
|
||||
if GameVersion.VERSIONS[version] and type(slot) == "string" then
|
||||
p.slots[version] = slot
|
||||
end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- profiles/<NAME>.g1rmodlist; letters, digits and dashes only so a shared
|
||||
-- name can never escape the folder
|
||||
local function fileFor(name)
|
||||
return ModProfile.DIR .. "/" ..
|
||||
tostring(name):upper():gsub("[^%w%-]", "_") .. ModProfile.EXT
|
||||
end
|
||||
ModProfile.fileFor = fileFor
|
||||
|
||||
function ModProfile.export(p, fs)
|
||||
fs = fsOr(fs)
|
||||
if not (fs and fs.write) then return false, "NO FILESYSTEM" end
|
||||
if fs.createDirectory then fs.createDirectory(ModProfile.DIR) end
|
||||
local rel = fileFor(p.name)
|
||||
local ok, err = fs.write(rel, ModProfile.encode(p))
|
||||
if not ok then return false, tostring(err) end
|
||||
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||
if base ~= "" then return true, base .. "/" .. rel end
|
||||
return true, rel
|
||||
end
|
||||
|
||||
-- every .g1rmodlist sitting in profiles/, sorted, as relative paths
|
||||
function ModProfile.files(fs)
|
||||
fs = fsOr(fs)
|
||||
local out = {}
|
||||
if not (fs and fs.getDirectoryItems) then return out end
|
||||
if fs.getInfo and not fs.getInfo(ModProfile.DIR) then return out end
|
||||
for _, name in ipairs(fs.getDirectoryItems(ModProfile.DIR) or {}) do
|
||||
if name:sub(-#ModProfile.EXT) == ModProfile.EXT then
|
||||
out[#out + 1] = ModProfile.DIR .. "/" .. name
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Read one export and land it in `profiles` (options.modProfiles) under a
|
||||
-- non-colliding name, so importing twice never overwrites what the player
|
||||
-- already tuned. Returns the stored record, or nil + a short reason.
|
||||
function ModProfile.import(path, profiles, fs)
|
||||
fs = fsOr(fs)
|
||||
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
|
||||
local p, err = ModProfile.decode(fs.read(path))
|
||||
if not p then return nil, err end
|
||||
local taken = {}
|
||||
for _, existing in ipairs(profiles) do taken[existing.name] = true end
|
||||
local name, n = p.name, 1
|
||||
while taken[name] do
|
||||
n = n + 1
|
||||
name = (p.name:sub(1, 8) .. n)
|
||||
end
|
||||
p.name = name
|
||||
profiles[#profiles + 1] = p
|
||||
return p
|
||||
end
|
||||
|
||||
-- First run after this update: the setup the player already has becomes
|
||||
-- PROFILE 1, then they can add more (#593). Seeded once and remembered by
|
||||
-- options.modProfilesSeeded, so deleting every profile does not resurrect it.
|
||||
function ModProfile.ensureFirst(opts, available, modOptions)
|
||||
if opts.modProfilesSeeded then return nil end
|
||||
opts.modProfilesSeeded = true
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
if #opts.modProfiles > 0 then return nil end
|
||||
local p = ModProfile.capture(available, modOptions)
|
||||
p.name = "PROFILE 1"
|
||||
opts.modProfiles[1] = p
|
||||
opts.activeProfile = p.name
|
||||
return p
|
||||
end
|
||||
|
||||
return ModProfile
|
||||
+19
-39
@@ -254,39 +254,21 @@ function ModUpdate.statusFor(installedVersion, releases)
|
||||
return "current", best
|
||||
end
|
||||
|
||||
-- ------- host I/O (curl)
|
||||
-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows" then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function curlCapture(url)
|
||||
-- GitHub's API wants a User-Agent and answers the versioned Accept.
|
||||
local function apiFetch(url)
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
|
||||
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||
.. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
if not readOk then return nil, "curl read failed: " .. tostring(out) end
|
||||
if not out or out == "" then return nil, "empty response from GitHub" end
|
||||
return out
|
||||
return HostShell.httpGet(url, "gen1recomp-mod-updater",
|
||||
"application/vnd.github+json")
|
||||
end
|
||||
|
||||
-- Still just the curl probe it always was. "Can we fetch at all" is
|
||||
-- HostShell.canFetch, which also knows about the Android bridge (#597); every
|
||||
-- gate below asks that instead.
|
||||
function ModUpdate.haveCurl()
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
|
||||
if not pipeOk or not pipe then return false end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
return HostShell.haveCurl()
|
||||
end
|
||||
|
||||
-- Fetch release list. opts.force bypasses the 6h cache.
|
||||
@@ -302,15 +284,16 @@ function ModUpdate.fetchReleases(repo, modId, opts)
|
||||
return cached.releases, nil, { fromCache = true }
|
||||
end
|
||||
end
|
||||
if not ModUpdate.haveCurl() then
|
||||
local HostShell = require("src.core.HostShell")
|
||||
if not HostShell.canFetch() then
|
||||
-- Stale cache is better than nothing when offline
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if cached and cached.releases then
|
||||
return cached.releases, nil, { fromCache = true, stale = true }
|
||||
end
|
||||
return nil, "curl is not available on this platform"
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
|
||||
local body, curlErr = apiFetch(ModUpdate.apiReleasesUrl(repo))
|
||||
if not body then
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if cached and cached.releases then
|
||||
@@ -331,10 +314,10 @@ function ModUpdate.downloadZip(url, destName)
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "download needs LOVE"
|
||||
end
|
||||
if not ModUpdate.haveCurl() then
|
||||
return nil, "curl is not available on this platform"
|
||||
end
|
||||
local HostShell = require("src.core.HostShell")
|
||||
if not HostShell.canFetch() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
|
||||
name = tostring(name):gsub("[/\\]", "_")
|
||||
local saveOk, saveDir = pcall(function()
|
||||
@@ -344,12 +327,9 @@ function ModUpdate.downloadZip(url, destName)
|
||||
return nil, "no save directory"
|
||||
end
|
||||
local abs = saveDir .. "/" .. name
|
||||
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
|
||||
.. shq(abs) .. " " .. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not start download" end
|
||||
pcall(function() pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
-- Either transport can fail quietly; the getInfo size check below is what
|
||||
-- actually decides whether we got a file (#597).
|
||||
HostShell.httpDownload(url, abs, "gen1recomp-mod-updater")
|
||||
local infoOk, info = pcall(love.filesystem.getInfo, name)
|
||||
if not infoOk or not info or (info.size or 0) == 0 then
|
||||
pcall(love.filesystem.remove, name)
|
||||
|
||||
Reference in New Issue
Block a user