Add Switch OTA launcher with unified SD zip and quiet UI.

Ships a dual-NRO native launcher that checks GitHub Releases, updates both NROs from gen1recomp-*-switch.zip with matching NACP versions, and stays silent unless an update needs confirm.
This commit is contained in:
Andrew Quenehen
2026-08-05 01:48:39 -03:00
parent f0ed2efe07
commit cafa6f3d61
25 changed files with 2505 additions and 26 deletions
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Build the native Switch OTA launcher NRO (DEVKITPRO or Docker).
#
# Usage: scripts/switch/build_ota_launcher.sh [OUT_NRO] [VERSION]
#
# Default OUT_NRO: dist/switch/gen1recomp-launcher.nro
# VERSION (optional X.Y.Z) is written into the NACP so hbmenu shows the
# same release as the fused game; defaults to 0.0.0 when omitted.
# Host-only protocol check: always runs `make host-test` first.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LAUNCHER_DIR="$ROOT/native/switch-ota-launcher"
OUT_NRO="${1:-$ROOT/dist/switch/gen1recomp-launcher.nro}"
APP_VERSION="${2:-${GEN1_LAUNCHER_VERSION:-0.0.0}}"
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
[ -d "$LAUNCHER_DIR" ] || fail "missing $LAUNCHER_DIR"
[ -f "$LAUNCHER_DIR/Makefile" ] || fail "missing Makefile"
say "host-test ota_protocol (no DEVKITPRO required)"
make -C "$LAUNCHER_DIR" host-test
mkdir -p "$(dirname "$OUT_NRO")"
if [ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ]; then
say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)"
make -C "$LAUNCHER_DIR" clean || true
make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
say "wrote $OUT_NRO"
exit 0
fi
resolve_dkp_image() {
if [ -n "${GEN1_DKP_IMAGE:-}" ]; then
printf '%s' "$GEN1_DKP_IMAGE"
return 0
fi
[ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE"
local line
line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)"
[ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE"
printf '%s' "$line"
}
if command -v docker >/dev/null 2>&1; then
image="$(resolve_dkp_image)"
say "building launcher NRO via Docker ($image, NACP $APP_VERSION)"
docker run --rm \
-v "$ROOT:/work" \
-w /work/native/switch-ota-launcher \
-e "APP_VERSION=$APP_VERSION" \
"$image" \
bash -lc 'pacman -Sy --noconfirm switch-curl switch-mbedtls switch-zlib switch-zziplib >/dev/null 2>&1 || true; make clean; make all APP_VERSION="$APP_VERSION"'
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
say "wrote $OUT_NRO"
exit 0
fi
fail "$(cat <<EOF
Cannot build Switch OTA launcher NRO: DEVKITPRO unset and Docker unavailable.
Host protocol tests already passed (make host-test).
Install DEVKITPRO switch-dev + switch-curl, or Docker, then re-run:
scripts/switch/build_ota_launcher.sh
See native/switch-ota-launcher/README.md and docs/switch-build.md.
EOF
)"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Install DEVKITPRO Switch packages needed for the native OTA launcher.
# Run this in Terminal.app / iTerm (needs your macOS admin password):
#
# bash scripts/switch/install_devkitpro_deps.sh
#
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "==> re-running with sudo…"
exec sudo -E bash "$0" "$@"
fi
export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}"
export PATH="/usr/local/bin:$DEVKITPRO/pacman/bin:$DEVKITPRO/tools/bin:$PATH"
if [ -f /etc/profile.d/devkit-env.sh ]; then
# shellcheck disable=SC1091
. /etc/profile.d/devkit-env.sh
fi
if ! command -v dkp-pacman >/dev/null 2>&1; then
echo "error: dkp-pacman not found. Install the pkg from:" >&2
echo " https://github.com/devkitPro/pacman/releases/latest" >&2
exit 1
fi
echo "==> DEVKITPRO=$DEVKITPRO"
# Named packages only (avoid interactive switch-dev group member prompt).
# ZIP reading uses switch-zziplib (dkp has no minizip port for Switch).
dkp-pacman -Sy --noconfirm --needed \
switch-curl \
switch-mbedtls \
switch-zlib \
switch-zziplib
echo "==> installed packages:"
dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true
# Ensure zsh/bash sessions see DEVKITPRO
MARKER="# gen1recomp-devkitpro"
for rc in /Users/*/.zshrc /Users/*/.bash_profile; do
[ -f "$rc" ] || continue
if ! grep -q "$MARKER" "$rc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo "export DEVKITPRO=/opt/devkitpro"
echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"'
} >> "$rc"
echo "==> appended DEVKITPRO exports to $rc"
fi
done
echo "==> done. Open a new shell, then:"
echo " export DEVKITPRO=/opt/devkitpro"
echo " scripts/switch/build_ota_launcher.sh"
+16
View File
@@ -0,0 +1,16 @@
# Switch OTA launcher packaging manifest
#
# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu
# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the
# update check. Self-update of the launcher itself is out of scope for v1.
#
# OTA download uses the same SD zip as install (unified asset).
#
# Paths relative to microSD root:
ENTRY_NRO=switch/gen1recomp/gen1recomp.nro
GAME_NRO=switch/gen1recomp/gen1recomp-game.nro
SAVE_DIR=switch/gen1recomp/pokemon-love2d
OTA_ASSET_GLOB=gen1recomp-*-switch.zip
REQUIRE_SHA256SUMS=1
OTA_ENABLED=1
+28 -7
View File
@@ -2,16 +2,18 @@
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
#
# Usage:
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]
#
# Layout inside the zip (SD root):
# switch/gen1recomp/gen1recomp.nro
# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game)
# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set)
# switch/gen1recomp/version.txt
# switch/gen1recomp/INSTALL.txt
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
#
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
# install and only overwrites the NRO + these text placeholders — keep
# install and only overwrites the NRO(s) + these text placeholders — keep
# pokemon-love2d/ to preserve progress.
set -euo pipefail
@@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
NRO_PATH="${1:-}"
VERSION="${2:-}"
OUT_ZIP="${3:-}"
LAUNCHER_NRO="${4:-}"
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|| fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]"
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH"
if [ -n "$LAUNCHER_NRO" ]; then
[ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO"
fi
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
@@ -45,7 +51,15 @@ trap cleanup EXIT
APP_DIR="$STAGE/switch/gen1recomp"
SAVE_ROOT="$APP_DIR/pokemon-love2d"
mkdir -p "$APP_DIR"
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
if [ -n "$LAUNCHER_NRO" ]; then
LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")"
cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro"
cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro"
else
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
fi
printf '%s\n' "$VERSION" > "$APP_DIR/version.txt"
cat > "$APP_DIR/INSTALL.txt" <<EOF
gen1recomp Switch (v${VERSION})
@@ -55,7 +69,7 @@ First install or update (same steps):
1. Extract this zip at the root of your microSD (merge folders if asked).
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
your saves, imported ROMs, mods, and options. Re-extracting only
replaces gen1recomp.nro and these help files.
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into:
switch/gen1recomp/pokemon-love2d/imports/
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \
|| fail "zip missing switch/gen1recomp/version.txt"
if [ -n "$LAUNCHER_NRO" ]; then
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)"
fi
REQUIRED=(
"switch/gen1recomp/INSTALL.txt"
"switch/gen1recomp/version.txt"
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
+93 -2
View File
@@ -51,16 +51,17 @@ MISSING=""
printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch"
printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose"
printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused"
printf '%s' "$HELP_LC" | grep -q 'ota' || MISSING="${MISSING} ota"
printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download"
printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals"
if [ -z "$MISSING" ]; then
ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)"
ok "build_switch.sh --help mentions fetch, loose, fused, ota (+ auto-download/non-goals)"
else
bad "build_switch.sh --help missing:$MISSING"
fi
# ---------------------------------------------------------------------------
# 3. XOR --loose --fused exits non-zero
# 3. XOR --loose --fused / --ota exits non-zero
# ---------------------------------------------------------------------------
XOR_RC=0
"$ROOT/scripts/build_switch.sh" --loose --fused >/dev/null 2>&1 || XOR_RC=$?
@@ -69,6 +70,13 @@ if [ "$XOR_RC" -ne 0 ]; then
else
bad "build_switch.sh --loose --fused should exit non-zero"
fi
XOR_OTA_RC=0
"$ROOT/scripts/build_switch.sh" --fused --ota >/dev/null 2>&1 || XOR_OTA_RC=$?
if [ "$XOR_OTA_RC" -ne 0 ]; then
ok "build_switch.sh --fused --ota exits non-zero ($XOR_OTA_RC)"
else
bad "build_switch.sh --fused --ota should exit non-zero"
fi
# ---------------------------------------------------------------------------
# 4. assemble_loose without pin → stderr contains --fetch
@@ -228,6 +236,7 @@ ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
PACK_MISSING=""
for rel in \
"switch/gen1recomp/gen1recomp.nro" \
"switch/gen1recomp/version.txt" \
"switch/gen1recomp/INSTALL.txt" \
"switch/gen1recomp/pokemon-love2d/imports/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \
@@ -311,6 +320,88 @@ else
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
fi
# ---------------------------------------------------------------------------
# 7. Dual-NRO OTA layout (launcher + game) when 4th arg set
# ---------------------------------------------------------------------------
FAKE_LAUNCHER="$STAGING/fake-launcher.nro"
FAKE_GAME="$STAGING/fake-game.nro"
printf 'fake-launcher\n' > "$FAKE_LAUNCHER"
printf 'fake-game\n' > "$FAKE_GAME"
OTA_ZIP="$STAGING/gen1recomp-0.0.2-ota-layout-switch.zip"
OTA_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_GAME" "0.0.2" "$OTA_ZIP" "$FAKE_LAUNCHER" \
>"$STAGING/ota-pack.out" 2>"$STAGING/ota-pack.err" || OTA_RC=$?
OTA_LIST="$(unzip -Z1 "$OTA_ZIP" 2>/dev/null || true)"
if [ "$OTA_RC" -eq 0 ] \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp.nro' \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/version.txt'
then
EXTRACT_OTA="$STAGING/extract-ota"
rm -rf "$EXTRACT_OTA"
mkdir -p "$EXTRACT_OTA"
unzip -q "$OTA_ZIP" -d "$EXTRACT_OTA"
DUAL_OK=1
cmp -s "$FAKE_LAUNCHER" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp.nro" || DUAL_OK=0
cmp -s "$FAKE_GAME" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp-game.nro" || DUAL_OK=0
[ "$(cat "$EXTRACT_OTA/switch/gen1recomp/version.txt")" = "0.0.2" ] || DUAL_OK=0
if [ "$DUAL_OK" -eq 1 ]; then
ok "pack_sd_zip.sh dual-NRO OTA layout (launcher + game + version.txt)"
else
bad "pack_sd_zip.sh dual-NRO bytes/version mismatch"
fi
else
bad "pack_sd_zip.sh dual-NRO layout failed (rc=$OTA_RC err=$(cat "$STAGING/ota-pack.err"))"
fi
MANIFEST="$ROOT/scripts/switch/ota_launcher.manifest"
if [ -f "$MANIFEST" ] && grep -q '^OTA_ENABLED=1$' "$MANIFEST" \
&& grep -q 'ENTRY_NRO=switch/gen1recomp/gen1recomp.nro' "$MANIFEST" \
&& grep -q 'GAME_NRO=switch/gen1recomp/gen1recomp-game.nro' "$MANIFEST"
then
ok "ota_launcher.manifest requires dual-NRO when OTA_ENABLED=1"
else
bad "ota_launcher.manifest missing OTA dual-NRO requirements"
fi
if [ -f "$ROOT/scripts/switch/build_ota_launcher.sh" ] \
&& [ -f "$ROOT/native/switch-ota-launcher/Makefile" ]
then
ok "native OTA launcher sources + build_ota_launcher.sh present"
else
bad "missing native OTA launcher tree or build script"
fi
# ---------------------------------------------------------------------------
# 8. Unified OTA asset = dual-NRO SD zip (no separate OTA-only archive)
# ---------------------------------------------------------------------------
LEGACY_OTA_PACKER="$ROOT/scripts/switch/pack_ota_zip.sh"
if [ ! -f "$LEGACY_OTA_PACKER" ] \
&& ! grep -Eq 'pack_ota_zip\.sh|switch-ota\.zip' "$ROOT/scripts/build_switch.sh" \
&& grep -q 'OTA_ASSET_GLOB=gen1recomp-\*-switch.zip' "$MANIFEST"
then
ok "unified OTA download is the same SD zip (legacy OTA-only packer gone)"
else
bad "legacy separate OTA-only packer / asset still present"
fi
if grep -q 'Quiet by default\|stays quiet\|Quiet' "$ROOT/native/switch-ota-launcher/src/main.c" \
&& grep -q 'ui_begin' "$ROOT/native/switch-ota-launcher/src/main.c"
then
ok "launcher UI is quiet by default (console only when needed)"
else
bad "launcher missing quiet-by-default UI gate"
fi
if [ -f "$ROOT/native/switch-ota-launcher/src/ota_unzip.c" ] \
&& grep -q 'zzip/zzip.h' "$ROOT/native/switch-ota-launcher/src/ota_unzip.c" \
&& grep -q 'ota_unzip_extract_file' "$ROOT/native/switch-ota-launcher/src/main.c"
then
ok "launcher wires zziplib ota_unzip_extract_file"
else
bad "launcher missing zziplib unzip wiring"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------