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
+12 -3
View File
@@ -509,16 +509,24 @@ jobs:
mkdir -p "$outdir"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
# x86_64 desktop Linux: raw AppImage (no zip wrapper).
x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage"
[ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; }
cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable
# AppImage rather than a zip so `chmod +x && ./it` just works.
# AppImage so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak"
[ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from scripts/build_flatpak.sh)"; exit 1; }
cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak"
apk="$(find dist/android/release -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
@@ -656,8 +664,9 @@ jobs:
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-linux.flatpak"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
+14 -5
View File
@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
## Linux on arm64 (Raspberry Pi)
## Linux desktop (AppImage / Flatpak)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
- `gen1recomp-*-linux-x86_64.AppImage`
- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs)
- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md))
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
```sh
chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage
```
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md).
LÖVE publishes no aarch64 binary of any kind, so the arm64 artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
+58
View File
@@ -0,0 +1,58 @@
# Linux AppImage packaging
Releases ship raw AppImages (no zip wrapper):
- `gen1recomp-<version>-linux-x86_64.AppImage`
- `gen1recomp-<version>-linux-arm64.AppImage`
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
Flatpak users should prefer the `.flatpak` bundle (see
[linux-flatpak.md](linux-flatpak.md)); it avoids host glibc / FUSE / curl
mismatches on immutable desktops.
## Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` |
| Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl |
| Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails |
| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file |
| Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart |
## Network transport (HostShell)
Desktop Linux fetches go through host or bundled `curl`:
1. Flatpak `/app/bin/curl` (bundled, keep sandbox libs)
2. `$APPDIR/usr/bin/curl` or `$APPDIR/bin/curl` (keep `$APPDIR` on `LD_LIBRARY_PATH`)
3. Host `curl` (`env -u LD_LIBRARY_PATH`, and always `-u LD_PRELOAD`)
## Portable mode
Drop `portable.txt` beside the `.AppImage` to keep saves next to the binary.
The launcher probes writability with a unique `.write_probe_<time>_<rand>.tmp`
file. Read-only parents fail soft and use `love.filesystem` XDG saves instead.
Flatpak ignores `portable.txt`.
## Auto-update
In-place updates download `gen1recomp-X.Y.Z.love` into the save directory.
Full shell bumps open the matching AppImage (or Flatpak) download URL — there
is no silent in-place AppImage replace yet.
## Building
```sh
# x86_64 (macOS or Linux host with squashfs-tools)
scripts/build.sh linux --version X.Y.Z
# arm64 (aarch64 host + docker/podman)
scripts/build_linux_arm64.sh --version X.Y.Z
```
See [linux-arm64-build.md](linux-arm64-build.md) for the arm64 builder.
+3 -2
View File
@@ -1,10 +1,11 @@
# Linux arm64 (aarch64) AppImage
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. It targets 64-bit ARM
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
aarch64 handhelds that run a full distro.
aarch64 handhelds that run a full distro. See also
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
> The Anbernic RG34XXSP has its own PortMaster-style pack
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
+46
View File
@@ -0,0 +1,46 @@
# Linux Flatpak
Releases may ship `gen1recomp-<version>-linux.flatpak` (x86_64 bundle).
## Install
```sh
flatpak install --user ./gen1recomp-<version>-linux.flatpak
flatpak run com.theboisclub.gen1recomp
```
Or open the `.flatpak` in Discover / GNOME Software. AppStream metainfo
includes a `<releases>` entry so those UIs can show the version.
## Permissions
The manifest requests:
- network (updater, mods, save sync)
- X11 / Wayland / DRI / PulseAudio
- `--device=all` (SDL2 gamepads, rumble, gyro via evdev/hidraw)
- `--filesystem=home` (ROM import)
Curl is bundled at `/app/bin/curl` so the game does not need host curl.
## Updates
Lua/engine payloads still use the in-app `.love` updater. A native shell bump
points at the new `.flatpak` asset (`Download Flatpak update`).
After a `.love` payload download, restart uses `love.event.quit("restart")`
(`execv` of `/proc/self/exe` inside bwrap). If a restart hangs after an
update, fully quit and relaunch — PhysFS can retain a lock on the previous
payload across a bad handoff.
## Building
```sh
scripts/build_flatpak.sh --version X.Y.Z
# → dist/flatpak/gen1recomp-X.Y.Z-linux.flatpak
```
Requires `flatpak` + `flatpak-builder` and the Freedesktop 24.08 runtime from
Flathub (installed automatically on first build).
Flathub store submission is out of scope for the GitHub bundle channel.
+3 -2
View File
@@ -63,8 +63,9 @@ mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
`-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
PortMaster archives) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1/2 recompilation
Exec=gen1recomp
Icon=com.theboisclub.gen1recomp
Terminal=false
Categories=Game;
StartupWMClass=love
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.theboisclub.gen1recomp</id>
<name>gen1recomp</name>
<summary>Pokémon Gen 1/2 recompilation</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer_name>The Bois Club</developer_name>
<description>
<p>
A LÖVE-based recompilation of Pokémon Gen 1 and Gen 2 with a launcher,
ROM importer, mods, and optional cloud save sync. Requires your own
legal cartridge dumps.
</p>
</description>
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
<screenshots>
<screenshot type="default">
<caption>Launcher</caption>
<image>https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png</image>
</screenshot>
</screenshots>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.0.0" date="1970-01-01"/>
</releases>
</component>
+61
View File
@@ -0,0 +1,61 @@
# Template consumed by scripts/build_flatpak.sh (placeholders substituted).
# Do not pass this file directly to flatpak-builder.
app-id: com.theboisclub.gen1recomp
runtime: org.freedesktop.Platform
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
command: gen1recomp
finish-args:
- --share=network
- --share=ipc
- --socket=x11
- --socket=wayland
- --device=dri
- --socket=pulseaudio
# Full input device access so SDL2 sees udev/evdev gamepads + rumble/gyro
- --device=all
- --filesystem=home
modules:
- name: curl
buildsystem: simple
build-commands:
- tar -xJf curl-static.tar.xz
- install -Dm755 curl /app/bin/curl
sources:
- type: file
url: https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz
sha256: e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029
dest-filename: curl-static.tar.xz
- name: love
buildsystem: simple
build-commands:
- chmod +x love-x86_64.AppImage
- ./love-x86_64.AppImage --appimage-extract
- mkdir -p /app/lib/love /app/bin /app/share/gen1recomp
- cp -a squashfs-root/bin/love /app/bin/love
- cp -a squashfs-root/lib/. /app/lib/love/
- |
cat > /app/bin/gen1recomp <<'EOF'
#!/bin/sh
export LD_LIBRARY_PATH="/app/lib/love:${LD_LIBRARY_PATH:-}"
exec /app/bin/love /app/share/gen1recomp/game.love "$@"
EOF
- chmod +x /app/bin/gen1recomp
- install -Dm644 game.love /app/share/gen1recomp/game.love
- install -Dm644 com.theboisclub.gen1recomp.desktop /app/share/applications/com.theboisclub.gen1recomp.desktop
- install -Dm644 com.theboisclub.gen1recomp.metainfo.xml /app/share/metainfo/com.theboisclub.gen1recomp.metainfo.xml
- install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/com.theboisclub.gen1recomp.png
sources:
- type: file
url: https://github.com/love2d/love/releases/download/11.5/love-11.5-x86_64.AppImage
sha256: 65a673406431eff7167a15a032bf7a2e4ba50108e091eb7b176465831f9b5e00
dest-filename: love-x86_64.AppImage
- type: file
path: game.love
- type: file
path: com.theboisclub.gen1recomp.metainfo.xml
- type: file
path: com.theboisclub.gen1recomp.desktop
- type: file
path: icon.png
+32 -12
View File
@@ -11,7 +11,7 @@
#
# Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
@@ -457,15 +457,31 @@ EOF
# file-manager thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
if command -v sips >/dev/null 2>&1; then
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
elif command -v convert >/dev/null 2>&1; then
convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png"
else
fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC"
fi
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
# sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix).
if sed --version >/dev/null 2>&1; then
sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
else
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
fi
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"'
if sed --version >/dev/null 2>&1; then
sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
else
sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
fi
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
@@ -474,16 +490,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
mkdir -p "$DIST/linux"
local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
rm -f "$out_bin" "$out_bin.sha256"
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
rm -f "$zip_out"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
say "Linux build: $zip_out"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
> "$out_bin.sha256"
else
printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
> "$out_bin.sha256"
fi
say "Linux build: $out_bin"
}
# --------------------------------------------------------------- Android
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Build a single-file Flatpak bundle for x86_64 desktop Linux.
#
# Usage: scripts/build_flatpak.sh [--version X.Y.Z] [--game-love PATH]
#
# Output:
# dist/flatpak/gen1recomp-<version>-linux.flatpak
#
# Requires: flatpak, flatpak-builder, and network on first run (Freedesktop
# runtime + LÖVE AppImage download).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP_ID="com.theboisclub.gen1recomp"
VERSION="0.0.0"
GAME_LOVE_IN=""
DIST="$ROOT/dist/flatpak"
BUILD_DIR="$ROOT/.bazinga/flatpak-build"
REPO_DIR="$ROOT/.bazinga/flatpak-repo"
STATE_DIR="$ROOT/.bazinga/flatpak-state"
CACHE="$ROOT/.bazinga/flatpak-cache"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
--game-love) GAME_LOVE_IN="$2"; shift ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
command -v flatpak >/dev/null || fail "flatpak not found"
command -v flatpak-builder >/dev/null || fail "flatpak-builder not found"
mkdir -p "$DIST" "$BUILD_DIR" "$REPO_DIR" "$STATE_DIR" "$CACHE"
LOVE_FILE="$CACHE/game.love"
if [ -n "$GAME_LOVE_IN" ]; then
[ -f "$GAME_LOVE_IN" ] || fail "game.love not found: $GAME_LOVE_IN"
if [ "$(readlink -f "$GAME_LOVE_IN")" != "$(readlink -f "$LOVE_FILE")" ]; then
cp "$GAME_LOVE_IN" "$LOVE_FILE"
fi
else
say "packing game.love"
"$ROOT/scripts/pack_love.sh" --output "$LOVE_FILE" --version "$VERSION"
fi
# Stage local sources next to the generated manifest (flatpak path: is relative).
cp "$ROOT/flatpak/$APP_ID.desktop" "$CACHE/$APP_ID.desktop"
# AppStream rejects icons larger than the declared hicolor size.
if command -v magick >/dev/null 2>&1; then
magick "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
elif command -v convert >/dev/null 2>&1; then
convert "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
else
fail "need ImageMagick (magick/convert) to resize the Flatpak icon to 512x512"
fi
DATE="$(date -u +%Y-%m-%d)"
python3 - "$ROOT/flatpak/$APP_ID.metainfo.xml" "$CACHE/$APP_ID.metainfo.xml" "$VERSION" "$DATE" <<'PY'
import sys, pathlib, re
src, dst, ver, date = sys.argv[1:5]
text = pathlib.Path(src).read_text(encoding="utf-8")
release = f' <release version="{ver}" date="{date}"/>'
text = re.sub(
r"<releases>.*?</releases>",
"<releases>\n" + release + "\n </releases>",
text,
count=1,
flags=re.S,
)
pathlib.Path(dst).write_text(text, encoding="utf-8")
PY
cp "$ROOT/flatpak/$APP_ID.yml" "$CACHE/$APP_ID.yml"
say "installing Freedesktop runtime (no-op if present)"
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true
flatpak --user install -y flathub \
org.freedesktop.Platform//24.08 \
org.freedesktop.Sdk//24.08 \
|| warn "runtime install reported an error; flatpak-builder may still reuse cache"
say "building Flatpak ($APP_ID $VERSION)"
# Run from CACHE so path: sources resolve.
(
cd "$CACHE"
flatpak-builder --user --force-clean \
--state-dir="$STATE_DIR" \
--repo="$REPO_DIR" \
"$BUILD_DIR" "$APP_ID.yml"
)
OUT="$DIST/gen1recomp-${VERSION}-linux.flatpak"
rm -f "$OUT"
say "exporting bundle $OUT"
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
chmod 644 "$OUT"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$(sha256sum "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
else
printf '%s %s\n' "$(shasum -a 256 "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
fi
say "Flatpak build: $OUT ($(du -h "$OUT" | cut -f1))"
say "sha256: $(cut -d' ' -f1 "$OUT.sha256")"
+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
+25
View File
@@ -0,0 +1,25 @@
-- Flatpak finish-args / packaging contract.
-- luajit tests/engine/flatpak_manifest_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local f = assert(io.open("flatpak/com.theboisclub.gen1recomp.yml", "rb"))
local yml = f:read("*a")
f:close()
check(yml:find("%-%-device=all", 1, false) or yml:find("--device=all", 1, true),
"Flatpak finish-args include --device=all for gamepads")
check(yml:find("--share=network", 1, true), "Flatpak shares network")
check(yml:find("--filesystem=home", 1, true), "Flatpak allows home for ROM import")
check(yml:find("com.theboisclub.gen1recomp", 1, true), "Flatpak app-id present")
local meta = assert(io.open("flatpak/com.theboisclub.gen1recomp.metainfo.xml", "rb"))
local xml = meta:read("*a")
meta:close()
check(xml:find("<releases>", 1, true), "AppStream metainfo includes releases")
check(xml:find("<release ", 1, true), "AppStream metainfo includes a release entry")
print("ok")
+76
View File
@@ -0,0 +1,76 @@
-- Dual curl env selection for AppImage / Flatpak / host.
-- luajit tests/engine/hostshell_curl_env_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
-- Isolate getenv for the HostShell module load.
local env = {
APPIMAGE = "/tmp/fake.AppImage",
APPDIR = "/tmp/fake-appdir",
LD_LIBRARY_PATH = "/tmp/fake-appdir/lib",
LD_PRELOAD = "/tmp/steam_overlay.so",
}
local realGetenv = os.getenv
function os.getenv(k)
if env[k] ~= nil then return env[k] end
return realGetenv(k)
end
-- Clear any prior HostShell package so resolveCurl memoization is fresh.
package.loaded["src.core.HostShell"] = nil
local HostShell = require("src.core.HostShell")
eq(HostShell.curlEnvPrefix("host"),
"env -u LD_LIBRARY_PATH -u LD_PRELOAD ",
"host curl scrubs AppImage libs and Steam preload")
eq(HostShell.curlEnvPrefix("bundled"),
"env -u LD_PRELOAD ",
"bundled curl keeps LD_LIBRARY_PATH, still scrubs Steam preload")
-- Without APPIMAGE, host prefix only scrubs preload when set.
env.APPIMAGE = nil
eq(HostShell.curlEnvPrefix("host"),
"env -u LD_PRELOAD ",
"non-AppImage host curl still scrubs LD_PRELOAD")
-- Bundled path preferred when APPDIR curl exists.
env.APPIMAGE = "/tmp/fake.AppImage"
env.FLATPAK_ID = nil
local fakeBin = "/tmp/fake-appdir/usr/bin"
os.execute('mkdir -p "' .. fakeBin .. '"')
local curlPath = fakeBin .. "/curl"
local f = assert(io.open(curlPath, "wb"))
f:write("#!/bin/sh\necho curl\n")
f:close()
os.execute('chmod +x "' .. curlPath .. '"')
package.loaded["src.core.HostShell"] = nil
-- Reset memo by reloading; resolveCurl caches in module locals.
HostShell = require("src.core.HostShell")
local path, kind = HostShell.resolveCurl()
eq(kind, "bundled", "APPDIR curl selected as bundled")
eq(path, curlPath, "APPDIR usr/bin/curl path used")
-- Flatpak wins over APPDIR.
env.FLATPAK_ID = "com.theboisclub.gen1recomp"
os.execute('mkdir -p /tmp/fake-flatpak-app/bin')
-- We cannot create /app/bin without root; instead stub by temporarily
-- pointing resolveCurl via FLATPAK_ID alone when /app/bin/curl missing
-- falls through to APPDIR — document that Flatpak ships /app/bin/curl.
package.loaded["src.core.HostShell"] = nil
HostShell = require("src.core.HostShell")
path, kind = HostShell.resolveCurl()
-- Without a real /app/bin/curl, APPDIR still wins; that is fine for this
-- host-side unit test. Flatpak packaging installs /app/bin/curl.
check(kind == "bundled" or kind == "host", "resolveCurl returns a known kind")
local diag = HostShell.curlDiagnostics()
check(type(diag) == "table", "curlDiagnostics returns a table")
check(diag.path ~= nil, "diagnostics include path")
os.getenv = realGetenv
os.remove(curlPath)
print("ok")
@@ -0,0 +1,65 @@
-- Portable writability probe + Flatpak skip.
-- luajit tests/engine/portable_writable_probe_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local tmp = os.getenv("TMPDIR") or "/tmp"
local base = tmp .. "/gen1recomp-portable-probe-" .. tostring(os.time())
os.execute('mkdir -p "' .. base .. '"')
local marker = assert(io.open(base .. "/portable.txt", "wb"))
marker:write("1\n")
marker:close()
-- Minimal LOVE stubs so SaveData.gameFolders returns our base.
_G.love = {
filesystem = {
getSource = function() return base .. "/game" end,
getSourceBaseDirectory = function() return base end,
},
system = {
getOS = function() return "Linux" end,
},
}
local env = {}
local realGetenv = os.getenv
function os.getenv(k)
if env[k] ~= nil then return env[k] end
return realGetenv(k)
end
package.loaded["src.core.SaveData"] = nil
local SaveData = require("src.core.SaveData")
check(SaveData.isPortable() == true, "writable portable parent activates portable mode")
eq(SaveData.portableBaseDir(), base, "portable base is the probed folder")
SaveData._resetPortableCacheForTests()
env.FLATPAK_ID = "com.theboisclub.gen1recomp"
check(SaveData.isPortable() == false, "Flatpak ignores portable.txt")
eq(SaveData.portableBaseDir(), nil, "Flatpak has no portable base")
-- Read-only parent: create a dir we cannot write (best-effort).
SaveData._resetPortableCacheForTests()
env.FLATPAK_ID = nil
local ro = base .. "-ro"
os.execute('mkdir -p "' .. ro .. '" && touch "' .. ro .. '/portable.txt" && chmod 555 "' .. ro .. '"')
_G.love.filesystem.getSourceBaseDirectory = function() return ro end
_G.love.filesystem.getSource = function() return ro .. "/game" end
SaveData._resetPortableCacheForTests()
-- chmod 555 may still allow owner write on some FS; accept either outcome
-- but must not throw.
local ok, portable = pcall(function() return SaveData.isPortable() end)
check(ok, "RO portable probe must not throw")
if portable then
check(SaveData.portableBaseDir() ~= nil, "if still writable, portable stays on")
else
eq(SaveData.portableBaseDir(), nil, "unwritable portable parent falls back")
end
os.getenv = realGetenv
os.execute('chmod 755 "' .. ro .. '" 2>/dev/null; rm -rf "' .. base .. '" "' .. ro .. '"')
print("ok")
+4
View File
@@ -43,6 +43,10 @@ eq(Check.fullAssetName("1.4.2", "Android", "arm64"),
"gen1recomp-1.4.2-android.apk", "Android full asset mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"),
"gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "x64"),
"gen1recomp-1.4.2-linux-x86_64.AppImage", "Linux x86_64 AppImage mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "x64", "flatpak"),
"gen1recomp-1.4.2-linux.flatpak", "Linux Flatpak package mapping")
eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
"gen1recomp++-1.4.2-ios.ipa", "iOS package mapping")
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,