feat(linux): ship raw x86 AppImage, Flatpak bundle, and dual-env curl

Drop the pointless zip wrapper around the x86_64 AppImage, harden HostShell
so bundled curl keeps APPDIR libs while host curl scrubs LD_LIBRARY_PATH /
Steam LD_PRELOAD, and add a Flatpak channel with --device=all, bundled
curl, and AppStream releases metadata. Portable mode now probes writability
with a unique temp file and falls back soft on RO mounts / Flatpak.
This commit is contained in:
1jamie
2026-08-25 15:28:44 -05:00
parent 51908faabf
commit 0e8c93ec75
19 changed files with 724 additions and 53 deletions
+114 -24
View File
@@ -4,18 +4,79 @@ local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
-- 64-bit child, so ld.so prints an error into that child's output.
-- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
-- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
-- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
-- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
-- into a 64-bit child.
function HostShell.envPrefix()
return HostShell.curlEnvPrefix("host")
end
-- kind "bundled" = AppDir / Flatpak curl (keep LD_LIBRARY_PATH, scrub preload).
-- kind "host" = system curl (scrub both).
function HostShell.curlEnvPrefix(kind)
local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
if kind ~= "bundled" and os.getenv("APPIMAGE") then
unset = unset .. "-u LD_LIBRARY_PATH "
end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end
return "env " .. unset
end
local curlResolved = nil -- { path=, kind= } once per Lua state
local function pathIsExecutable(path)
if type(path) ~= "string" or path == "" then return false end
local f = io.open(path, "rb")
if not f then return false end
f:close()
-- Best-effort: existence is enough; exec bit is checked by the spawn.
return true
end
-- Prefer Flatpak /app/bin/curl, then $APPDIR/{usr/,}bin/curl, else host "curl".
function HostShell.resolveCurl()
if curlResolved ~= nil then return curlResolved.path, curlResolved.kind end
local candidates = {}
if os.getenv("FLATPAK_ID") then
candidates[#candidates + 1] = { "/app/bin/curl", "bundled" }
end
local appdir = os.getenv("APPDIR")
if type(appdir) == "string" and appdir ~= "" then
candidates[#candidates + 1] = { appdir .. "/usr/bin/curl", "bundled" }
candidates[#candidates + 1] = { appdir .. "/bin/curl", "bundled" }
end
for _, c in ipairs(candidates) do
if pathIsExecutable(c[1]) then
curlResolved = { path = c[1], kind = c[2] }
return curlResolved.path, curlResolved.kind
end
end
curlResolved = { path = "curl", kind = "host" }
return curlResolved.path, curlResolved.kind
end
-- Quoted curl argv0 for a shell command, plus the matching env prefix.
function HostShell.curlInvocation()
local path, kind = HostShell.resolveCurl()
return HostShell.curlEnvPrefix(kind) .. HostShell.quote(path), kind
end
-- Diagnostics for support / About screens.
function HostShell.curlDiagnostics()
local path, kind = HostShell.resolveCurl()
return {
path = path,
kind = kind,
appimage = os.getenv("APPIMAGE") ~= nil,
appdir = os.getenv("APPDIR"),
flatpakId = os.getenv("FLATPAK_ID"),
haveCurl = HostShell.haveCurl(),
}
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
@@ -126,12 +187,18 @@ local function withPopenLock(fn)
if not okAtomic then fn() end
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
-- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip,
-- or curlEnvPrefix(kind) when spawning a resolved curl binary).
function HostShell.popen(command, mode, opts)
HostShell.releasePointerGrab()
local prefix = HostShell.envPrefix()
if type(opts) == "table" and opts.envPrefix ~= nil then
prefix = opts.envPrefix
end
local pipe
withPopenLock(function()
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
local ok, p = pcall(io.popen, prefix .. command, mode or "r")
pipe = (ok and p) or nil
end)
return pipe
@@ -193,6 +260,12 @@ function HostShell.restart()
local appimage = os.getenv("APPIMAGE")
if not appimage then
-- Flatpak and plain desktop: love.event.quit("restart") uses
-- execv(/proc/self/exe). Inside bwrap that path is valid, but PhysFS
-- locks on a mounted .love payload can survive the exec boundary and
-- hang the next Boot.mount. Callers that just wrote updates/*.love
-- should already have closed archive handles; Boot.run still owns
-- crash-guard rollback if a mid-handoff restart dies.
love.event.quit("restart")
return
end
@@ -341,7 +414,10 @@ local curlAvailable = nil
function HostShell.haveCurl()
if curlAvailable ~= nil then return curlAvailable end
local pipe = HostShell.popen("curl --version")
local path, kind = HostShell.resolveCurl()
local pipe = HostShell.popen(
HostShell.quote(path) .. " --version", "r",
{ envPrefix = HostShell.curlEnvPrefix(kind) })
if not pipe then curlAvailable = false return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -349,6 +425,12 @@ function HostShell.haveCurl()
return curlAvailable
end
local function curlCmd(flags)
local path, kind = HostShell.resolveCurl()
return HostShell.quote(path) .. " " .. flags,
{ envPrefix = HostShell.curlEnvPrefix(kind) }
end
-- An older mobile build reports nil here and falls back to the "no transport"
-- error the callers already show.
local function haveBridge()
@@ -409,9 +491,11 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
local head, popts = curlCmd(
("-fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
@@ -423,7 +507,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not start download" end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -466,16 +550,18 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -562,9 +648,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
-- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then
@@ -574,7 +662,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
@@ -751,8 +839,10 @@ function HostShell.httpRequest(url, opts)
pcall(os.remove, headerPath)
end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
local cmd = head
.. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then
@@ -761,7 +851,7 @@ function HostShell.httpRequest(url, opts)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then
cleanup()
return nil, "could not run curl"
+36 -2
View File
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
end
-- The game folder carrying portable.txt, or false. First candidate holding
-- the marker wins.
-- the marker *and* a writable probe wins. Flatpak installs always use the
-- sandbox XDG save dir (portable USB mode does not apply). A read-only
-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save
-- directory without throwing -- probe I/O is fully guarded.
local function dirIsWritable(dir)
if type(dir) ~= "string" or dir == "" then return false end
local ok, writable = pcall(function()
local name = string.format(".write_probe_%d_%d.tmp",
os.time() % 100000000, math.random(0, 999999))
local path = dir .. SEP .. name
local f, err = io.open(path, "wb")
if not f then return false end
local wrote = f:write("ok")
f:close()
pcall(os.remove, path)
return wrote ~= nil
end)
return ok and writable == true
end
local function detectPortable()
if portableChecked then return portableBase end
portableChecked = true
portableBase = false
-- Flatpak: never honor portable.txt (sandbox home is the persistence root).
if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then
return portableBase
end
for _, base in ipairs(SaveData.gameFolders()) do
if pathExists(base .. SEP .. PORTABLE_MARKER) then
local markerOk = false
pcall(function()
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
end)
if markerOk and dirIsWritable(base) then
portableBase = base
break
end
@@ -187,6 +214,13 @@ local function detectPortable()
return portableBase
end
-- Soft-reset cache so tests can re-run detect after env changes.
function SaveData._resetPortableCacheForTests()
portableChecked = false
portableBase = false
portableFsCache = nil
end
function SaveData.isPortable()
return detectPortable() ~= false
end
+11 -1
View File
@@ -43,10 +43,13 @@ local function fullAssetName(version, osName, arch, port)
return "gen1recomp-" .. version .. "-xbox-uwp.zip"
elseif osName == "NX" then
return "gen1recomp-" .. version .. "-switch.zip"
elseif osName == "Linux" and (port == "flatpak"
or (type(os.getenv) == "function" and os.getenv("FLATPAK_ID"))) then
return "gen1recomp-" .. version .. "-linux.flatpak"
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"
return "gen1recomp-" .. version .. "-linux-x86_64.AppImage"
end
return nil
end
@@ -288,6 +291,13 @@ function Check.fullUpdateAction()
"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 osName == "Linux" and type(st.full) == "table"
and type(st.full.url) == "string" then
local name = type(st.full.name) == "string" and st.full.name or ""
if name:find("%.flatpak$") or os.getenv("FLATPAK_ID") then
return { label = "Download Flatpak update", url = st.full.url }
end
return { label = "Download AppImage update", url = st.full.url }
elseif type(st.full) == "table" and type(st.full.url) == "string" then
return { label = "Download full update", url = st.full.url }
end
+7 -2
View File
@@ -310,8 +310,13 @@ local function launchDownload(url, partAbs, doneAbs)
.. "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("( " .. HostShell.envPrefix() .. "curl -fsSL --connect-timeout 15 --max-time 900 -o "
-- ( ... ) & backgrounds the whole group so os.execute returns at once.
-- Use the same dual-env curl resolution as HostShell.http*: bundled
-- AppDir/Flatpak curl keeps APPDIR libs; host curl scrubs them.
local curlPath, curlKind = HostShell.resolveCurl()
os.execute("( " .. HostShell.curlEnvPrefix(curlKind)
.. HostShell.quote(curlPath)
.. " -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end