diff --git a/.gitignore b/.gitignore index c7ab5ab3..9ec4ba6b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,14 @@ mobile/ios/build/ # Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh /dist/ +# Native Switch OTA launcher build outputs +native/switch-ota-launcher/build/ +native/switch-ota-launcher/build-host/ +native/switch-ota-launcher/*.nro +native/switch-ota-launcher/*.nacp +native/switch-ota-launcher/*.elf +native/switch-ota-launcher/*.map + # Legacy manual convenience-copy location (superseded by /dist/android/) mobile/dist/ diff --git a/docs/switch-build.md b/docs/switch-build.md index 74b33567..2cc3d59a 100644 --- a/docs/switch-build.md +++ b/docs/switch-build.md @@ -34,6 +34,42 @@ WSL — not cmd.exe or PowerShell (AD-008). **Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused builds can fall back to the pinned image when native tools are missing. +### Native OTA launcher (optional but required for in-console updates) + +In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). Source: +`native/switch-ota-launcher/`. Host protocol tests (no toolchain): + +```sh +make -C native/switch-ota-launcher host-test +# or +scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker +``` + +Extra packages beyond `switch-dev`: + +```sh +sudo dkp-pacman -S --noconfirm --needed switch-curl switch-mbedtls switch-zlib switch-zziplib +# or: bash scripts/switch/install_devkitpro_deps.sh +``` + +Packaging dual-NRO SD zip (launcher entry + fused game): + +```sh +scripts/switch/pack_sd_zip.sh dist/switch/game.nro VERSION out.zip dist/switch/gen1recomp-launcher.nro +``` + +One-shot OTA release build (fused game + launcher + dual-NRO SD zip; +the same `*-switch.zip` is the OTA download asset): + +```sh +# once: bash scripts/switch/install_devkitpro_deps.sh +export DEVKITPRO=/opt/devkitpro +scripts/build_switch.sh --fetch --ota --version X.Y.Z +``` + +See `native/switch-ota-launcher/README.md` and +`scripts/switch/ota_launcher.manifest`. + ### Windows (Git Bash / MSYS2 / WSL) 1. Use a bash environment: diff --git a/docs/switch-development.md b/docs/switch-development.md index 8e00e072..e253f5b1 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -32,7 +32,7 @@ the transfer runbook). | Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome | | Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet | | Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract | -| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) | +| Network features on NX | LÖVE self-update / remote mod download **disabled** (`networkValidated == false`). In-console OTA is the **native OTA launcher** only (see below) | | Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage | ### What landed @@ -57,6 +57,14 @@ the transfer runbook). - Switch Lite and fuller Pro Controller / third-party pad matrices - Applet Mode remains unsupported by design (title override required) - `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md)) +- **Native OTA launcher:** in-console updates use a DEVKITPRO + **native OTA launcher** (libnx + switch-curl) before the LÖVE game NRO — + **not** the LÖVE self-updater (`src/update/Check.lua`). Contract: + `src/update/SwitchOta.lua`; packaging gate: + `scripts/switch/ota_launcher.manifest`. The download asset is the same + `gen1recomp-*-switch.zip` as install. UI stays quiet unless an update + needs confirm. Player-facing flow: [switch-install.md](switch-install.md) + § Updating. Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in [switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke diff --git a/docs/switch-install.md b/docs/switch-install.md index e173dfc2..788d7a08 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -29,12 +29,17 @@ help from [booshankles](https://github.com/booshankles). Extract the zip at the **root** of the microSD so you get: ```text -sdmc:/switch/gen1recomp/gen1recomp.nro +sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry) +sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game (when OTA layout ships) +sdmc:/switch/gen1recomp/version.txt sdmc:/switch/gen1recomp/pokemon-love2d/imports/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/... ``` +Older single-NRO zips only had `gen1recomp.nro` (the fused game). New OTA-ready +zips use the dual-NRO layout above — open `gen1recomp` in hbmenu (the launcher). + Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**. Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for @@ -42,10 +47,35 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md). ### Updating -Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small -help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and -options live under `pokemon-love2d/` — **do not delete that folder** when -updating, or you will lose progress. +#### Native OTA launcher (in-console) + +On Switch, over-the-air updates are handled by the **native OTA launcher** +(DEVKITPRO / libnx + curl), **not** by the LÖVE self-updater +(`src/update/Check.lua`). The hbmenu entry is the launcher NRO +(`gen1recomp.nro`). If a newer release exists it may download the same +`gen1recomp-*-switch.zip` used for install, verify SHA-256 from +`sha256sums.txt`, replace **both** `gen1recomp-game.nro` and +`gen1recomp.nro` (so the NACP version matches the release for hbmenu / +Sphaira), then hand off via `envSetNextLoad`. When you are already up to +date (or offline), the launcher stays quiet and opens the game with no +console flash. Saves under `pokemon-love2d/` are never touched. Protocol +contract: `src/update/SwitchOta.lua`. + +The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`). + +**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into +the installed forwarder at creation time. After an OTA (or zip) update, the +`.nro` on the microSD already has the new version — but the HOME shortcut +keeps the old badge until you **reinstall the forwarder once** in Sphaira +(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira / +hbmenu always shows the live file version. + +#### Manual zip (fallback) + +Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the +NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves, +imported ROMs, mods, and options live under `pokemon-love2d/` — **do not +delete that folder** when updating, or you will lose progress. ## 3. Launch with title override diff --git a/docs/updater.md b/docs/updater.md index b0de76c8..6a7c6625 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -124,3 +124,9 @@ bundled game, in that case. `love.filesystem.isFused()` is false, and a working tree's `engine` is the `"0.0.0-dev"` placeholder that always reports up to date, so a source checkout is always "the game" itself; updating it means pulling the repo. +- **Nintendo Switch does not use this LÖVE self-updater.** On NX, + `Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never + download `.love` payloads. In-console OTA is the **native OTA launcher** + (DEVKITPRO) documented in [switch-install.md](switch-install.md) and + [switch-development.md](switch-development.md); protocol contract + `src/update/SwitchOta.lua`. Manual zip install remains the fallback. diff --git a/native/switch-ota-launcher/Makefile b/native/switch-ota-launcher/Makefile new file mode 100644 index 00000000..9509a180 --- /dev/null +++ b/native/switch-ota-launcher/Makefile @@ -0,0 +1,165 @@ +#--------------------------------------------------------------------------------- +# gen1recomp Switch native OTA launcher +# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib +# Host protocol tests: make host-test (no DEVKITPRO needed) +#--------------------------------------------------------------------------------- +.SUFFIXES: + +#--------------------------------------------------------------------------------- +# Host-only path when DEVKITPRO is unset +#--------------------------------------------------------------------------------- +ifeq ($(strip $(DEVKITPRO)),) + +.PHONY: host-test clean-host +host-test: + @mkdir -p build-host + cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \ + src/ota_protocol.c host/test_ota_protocol.c + ./build-host/test_ota_protocol + +clean-host: + rm -rf build-host + +%: + @echo "DEVKITPRO not set — only 'make host-test' is available." + @echo "Run: bash scripts/switch/install_devkitpro_deps.sh" + @false + +else + +#--------------------------------------------------------------------------------- +TOPDIR ?= $(CURDIR) +include $(DEVKITPRO)/libnx/switch_rules + +#--------------------------------------------------------------------------------- +TARGET := gen1recomp +BUILD := build +SOURCES := src +DATA := data +INCLUDES := include + +APP_TITLE := gen1recomp +APP_AUTHOR := bryanthaboi, port by andrewqsantos +# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z +APP_VERSION ?= 0.0.0 + +# Prefer project Switch icon if present +ICON := $(TOPDIR)/../../assets/switch/icon.jpg + +#--------------------------------------------------------------------------------- +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE + +CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES) +CFLAGS += $(INCLUDE) -D__SWITCH__ + +CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions + +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) + +LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx + +LIBDIRS := $(PORTLIBS) $(LIBNX) + +#--------------------------------------------------------------------------------- +ifneq ($(BUILD),$(notdir $(CURDIR))) +#--------------------------------------------------------------------------------- + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) + +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \ + $(foreach dir,$(DATA),$(CURDIR)/$(dir)) + +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) +BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*))) + +#--------------------------------------------------------------------------------- +# use CXX for linking C++ projects, CC for standard C +#--------------------------------------------------------------------------------- +ifeq ($(strip $(CPPFILES)),) + export LD := $(CC) +else + export LD := $(CXX) +endif +#--------------------------------------------------------------------------------- + +export OFILES_BIN := $(addsuffix .o,$(BINFILES)) +export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) +export OFILES := $(OFILES_BIN) $(OFILES_SRC) + +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) + +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +ifeq ($(strip $(ICON)),) + icons := $(wildcard *.jpg) + ifneq (,$(findstring $(TARGET).jpg,$(icons))) + export APP_ICON := $(TOPDIR)/$(TARGET).jpg + else + ifneq (,$(findstring icon.jpg,$(icons))) + export APP_ICON := $(TOPDIR)/icon.jpg + endif + endif +else + ifeq ($(wildcard $(ICON)),) + # fall back to libnx default if project icon missing + export APP_ICON := $(LIBNX)/default_icon.jpg + else + export APP_ICON := $(ICON) + endif +endif + +ifeq ($(strip $(NO_ICON)),) + export NROFLAGS += --icon=$(APP_ICON) +endif + +ifeq ($(strip $(NO_NACP)),) + export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp +endif + +.PHONY: $(BUILD) clean all host-test + +#--------------------------------------------------------------------------------- +all: $(BUILD) + +$(BUILD): + @[ -d $@ ] || mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +#--------------------------------------------------------------------------------- +clean: + @echo clean ... + @rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host + +host-test: + @mkdir -p build-host + cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \ + src/ota_protocol.c host/test_ota_protocol.c + ./build-host/test_ota_protocol + +#--------------------------------------------------------------------------------- +else +.PHONY: all + +DEPENDS := $(OFILES:.o=.d) + +#--------------------------------------------------------------------------------- +all: $(OUTPUT).nro + +$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp + +$(OUTPUT).elf: $(OFILES) + +-include $(DEPENDS) + +#--------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------- +endif diff --git a/native/switch-ota-launcher/README.md b/native/switch-ota-launcher/README.md new file mode 100644 index 00000000..a99db455 --- /dev/null +++ b/native/switch-ota-launcher/README.md @@ -0,0 +1,66 @@ +# Native Switch OTA launcher + +In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu +entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no console flash +when you are already up to date or offline). Only if a newer release exists +does it prompt to download the same `gen1recomp-*-switch.zip` used for install, +verify SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` +and `gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then hand off +with `envSetNextLoad`. + +The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX. +Protocol contract (also in Lua): `src/update/SwitchOta.lua`. +NACP icon: `assets/switch/icon.jpg`. + +## Layout on microSD + +```text +sdmc:/switch/gen1recomp/gen1recomp.nro ← this launcher +sdmc:/switch/gen1recomp/gen1recomp-game.nro ← fused LÖVE game +sdmc:/switch/gen1recomp/version.txt ← installed X.Y.Z +sdmc:/switch/gen1recomp/pokemon-love2d/ ← saves (never touched by OTA) +``` + +## Host tests (no DEVKITPRO) + +```bash +cd native/switch-ota-launcher +make host-test +``` + +## Switch build (DEVKITPRO) + +Needs `DEVKITPRO` with packages roughly: + +```bash +(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib +# or: bash scripts/switch/install_devkitpro_deps.sh +``` + +```bash +export DEVKITPRO=/opt/devkitpro # typical +cd native/switch-ota-launcher +make +# → gen1recomp.nro +``` + +Or from repo root: + +```bash +scripts/switch/build_ota_launcher.sh +``` + +Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`). + +## Packaging + +`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both +NROs into the SD zip. That zip is also the OTA download asset (unified). +Manifest: `scripts/switch/ota_launcher.manifest`. + +## Status / known gaps + +- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device. +- OTA replaces launcher + game from the unified zip (NACP versions stay aligned). +- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md). +- Install toolchain deps once: `bash scripts/switch/install_devkitpro_deps.sh` diff --git a/native/switch-ota-launcher/host/test_ota_protocol.c b/native/switch-ota-launcher/host/test_ota_protocol.c new file mode 100644 index 00000000..9f20fe15 --- /dev/null +++ b/native/switch-ota-launcher/host/test_ota_protocol.c @@ -0,0 +1,94 @@ +/* Host-only unit tests for ota_protocol.c — no libnx. */ +#include "ota_protocol.h" + +#include +#include + +static int g_fail = 0; + +static void expect(int cond, const char *msg) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", msg); + g_fail++; + } else { + printf("PASS: %s\n", msg); + } +} + +int main(void) { + expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer"); + expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal"); + expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older"); + expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name"); + expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name"); + expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload"); + + const char *json = + "{" + "\"tag_name\":\"v1.5.0\"," + "\"assets\":[" + "{\"name\":\"gen1recomp-1.5.0-switch.zip\"," + "\"browser_download_url\":\"https://example/switch.zip\"}," + "{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}" + "]" + "}"; + ota_release_t rel; + expect(ota_parse_release(json, &rel) == 1, "parse release"); + expect(strcmp(rel.version, "1.5.0") == 0, "release version"); + expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset"); + + ota_decision_t d; + ota_decide_update("1.4.0", &rel, &d); + expect(strcmp(d.status, "available") == 0, "decide available"); + ota_decide_update("1.5.0", &rel, &d); + expect(strcmp(d.status, "uptodate") == 0, "decide uptodate"); + + ota_release_t bad; + expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0, + "missing ota asset"); + expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason"); + + const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n"; + ota_verify_t v; + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v); + expect(v.ok == 1, "sha ok"); + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v); + expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch"); + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v); + expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected"); + + ota_apply_plan_t plan; + ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan); + expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy"); + expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename"); + expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy"); + expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename"); + expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff"); + expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves"); + expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves"); + expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write"); + + ota_offline_t off; + ota_offline_events_t ev = {0}; + ev.user_skip = 1; + ota_offline_policy(1.0, &ev, &off); + expect(strcmp(off.action, "play_installed") == 0, "skip plays installed"); + ev.user_skip = 0; + ev.network_ok = 0; + ota_offline_policy(1.0, &ev, &off); + expect(strcmp(off.action, "play_installed") == 0, "offline plays installed"); + ev.network_ok = 1; + ota_offline_policy(6.0, &ev, &off); + expect(strcmp(off.reason, "timeout") == 0, "timeout 6s"); + ota_offline_policy(2.0, &ev, &off); + expect(strcmp(off.action, "keep_checking") == 0, "still checking"); + + expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6"); + + if (g_fail) { + fprintf(stderr, "%d failure(s)\n", g_fail); + return 1; + } + printf("all ota_protocol host tests passed\n"); + return 0; +} diff --git a/native/switch-ota-launcher/include/ota_fs.h b/native/switch-ota-launcher/include/ota_fs.h new file mode 100644 index 00000000..4a43968e --- /dev/null +++ b/native/switch-ota-launcher/include/ota_fs.h @@ -0,0 +1,32 @@ +#ifndef GEN1_OTA_FS_H +#define GEN1_OTA_FS_H + +#include "ota_protocol.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Read installed game version from sibling version.txt or NRO nacp-less stamp file. + * Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */ +int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len); + +/* SHA-256 hex (lowercase) of file. Returns 0 on success. */ +int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len); + +/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */ +int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro, + char *err, size_t err_len); + +/* Atomically replace any named NRO under install_dir (copy→.part→rename). */ +int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name, + const char *verified_nro, char *err, size_t err_len); + +/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */ +int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/switch-ota-launcher/include/ota_net.h b/native/switch-ota-launcher/include/ota_net.h new file mode 100644 index 00000000..eca59864 --- /dev/null +++ b/native/switch-ota-launcher/include/ota_net.h @@ -0,0 +1,22 @@ +#ifndef GEN1_OTA_NET_H +#define GEN1_OTA_NET_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */ +int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len, + char *err, size_t err_len); + +/* Download URL to a filesystem path. Returns 0 on success. */ +int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, + size_t err_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/switch-ota-launcher/include/ota_protocol.h b/native/switch-ota-launcher/include/ota_protocol.h new file mode 100644 index 00000000..5e24535e --- /dev/null +++ b/native/switch-ota-launcher/include/ota_protocol.h @@ -0,0 +1,92 @@ +#ifndef GEN1_OTA_PROTOCOL_H +#define GEN1_OTA_PROTOCOL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OTA_CHECK_TIMEOUT_SEC 6 +#define OTA_GAME_NRO_NAME "gen1recomp-game.nro" +#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro" +#define OTA_SAVE_DIR_NAME "pokemon-love2d" +#define OTA_INSTALL_DIR "switch/gen1recomp" +#define OTA_RELEASES_API \ + "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" + +/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */ + +int ota_compare_semver(const char *a, const char *b); +int ota_is_ota_asset_name(const char *name); +int ota_version_from_ota_asset(const char *name, char *out, size_t out_len); + +typedef struct { + int ok; /* 1 on success */ + char reason[64]; + char tag[64]; + char version[32]; + char asset_name[128]; + char download_url[512]; +} ota_release_t; + +int ota_parse_release(const char *json_text, ota_release_t *out); + +typedef struct { + char status[32]; /* uptodate | available | error */ + char reason[64]; + char version[32]; + char asset_name[128]; + char download_url[512]; +} ota_decision_t; + +void ota_decide_update(const char *installed_version, const ota_release_t *release, + ota_decision_t *out); + +/* sums: newline-separated sha256sums.txt body */ +int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, + size_t out_len); + +typedef struct { + int ok; + char reason[64]; +} ota_verify_t; + +void ota_verify_sha256(const char *asset_name, const char *actual_hex, + const char *sums_text, ota_verify_t *out); + +typedef struct { + char steps[5][520]; /* human-readable ops */ + char preserve[256]; + char forbidden_delete[256]; + char forbidden_direct[256]; + char part_path[256]; + char game_nro[240]; + char launcher_nro[240]; + char launcher_part[256]; + char next_load[240]; +} ota_apply_plan_t; + +void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp, + ota_apply_plan_t *out); + +typedef struct { + char action[32]; /* play_installed | keep_checking */ + char reason[64]; + char message[128]; +} ota_offline_t; + +typedef struct { + int user_skip; + int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */ + int api_error; +} ota_offline_events_t; + +void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, + ota_offline_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* GEN1_OTA_PROTOCOL_H */ diff --git a/native/switch-ota-launcher/include/ota_unzip.h b/native/switch-ota-launcher/include/ota_unzip.h new file mode 100644 index 00000000..83e9c9ca --- /dev/null +++ b/native/switch-ota-launcher/include/ota_unzip.h @@ -0,0 +1,20 @@ +#ifndef GEN1_OTA_UNZIP_H +#define GEN1_OTA_UNZIP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Extract member_name from zip_path into dest_path. + * Tries exact member_name, then common prefixes (switch/gen1recomp/). + * Returns 0 on success. */ +int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path, + char *err, size_t err_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/switch-ota-launcher/src/main.c b/native/switch-ota-launcher/src/main.c new file mode 100644 index 00000000..ded6532b --- /dev/null +++ b/native/switch-ota-launcher/src/main.c @@ -0,0 +1,330 @@ +/* + * gen1recomp Switch OTA launcher + * + * Quiet by default: checks GitHub Releases with no console flash. Only shows + * a short prompt when an update is available. Downloads the same SD zip + * published for install (gen1recomp-*-switch.zip), verifies SHA-256, extracts + * switch/gen1recomp/gen1recomp-game.nro, then envSetNextLoad to the game. + * Never touches pokemon-love2d/. LÖVE self-updater stays off on NX. + */ + +#include "ota_fs.h" +#include "ota_net.h" +#include "ota_protocol.h" +#include "ota_unzip.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#include +#else +#include +#include +#endif + +#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp" +#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L) +#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME +#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME + +#if defined(__SWITCH__) +static int g_ui = 0; + +static void ui_begin(void) { + if (g_ui) return; + consoleInit(NULL); + consoleClear(); + g_ui = 1; +} + +static void ui_end(void) { + if (!g_ui) return; + consoleExit(NULL); + g_ui = 0; +} + +static void ui_line(const char *msg) { + ui_begin(); + printf("%s\n", msg); + consoleUpdate(NULL); +} + +static void ui_blank(void) { + if (!g_ui) return; + consoleClear(); + consoleUpdate(NULL); +} +#else +static void ui_begin(void) {} +static void ui_end(void) {} +static void ui_line(const char *msg) { fprintf(stderr, "%s\n", msg); } +static void ui_blank(void) {} +#endif + +/* Returns 1 = update, 0 = skip. Only called when UI is already needed. */ +static int wait_confirm_or_skip(void) { +#if defined(__SWITCH__) + PadState pad; + padInitializeDefault(&pad); + ui_line(""); + ui_line(" A — Update"); + ui_line(" B — Play without updating"); + while (appletMainLoop()) { + padUpdate(&pad); + u64 k = padGetButtonsDown(&pad); + if (k & HidNpadButton_A) return 1; + if (k & HidNpadButton_B) return 0; + consoleUpdate(NULL); + } + return 0; +#else + return 0; +#endif +} + +static int run_update_flow(const char *install_dir) { + char installed[64] = "0.0.0"; + (void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed)); + + char *json = NULL; + size_t json_len = 0; + char err[256]; + err[0] = '\0'; + + if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err, + sizeof(err)) != 0) { + free(json); + return 0; /* offline / timeout — play installed, no UI */ + } + + ota_release_t rel; + if (!ota_parse_release(json, &rel)) { + free(json); + return 0; + } + free(json); + + ota_decision_t dec; + ota_decide_update(installed, &rel, &dec); + if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) { + return 0; + } + + /* Update available — only now show UI */ + { + char msg[160]; + ui_blank(); + ui_line(""); + ui_line(" gen1recomp"); + ui_line(""); + snprintf(msg, sizeof(msg), " Update available: %s → %s", installed, dec.version); + ui_line(msg); + } + + if (!wait_confirm_or_skip()) { + ui_end(); + return 0; + } + + char updates_dir[192]; + char zip_path[256]; + char sums_path[256]; + snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir); + snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name); + snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir); + +#if defined(__SWITCH__) + mkdir(updates_dir, 0755); +#endif + + ui_blank(); + ui_line(""); + ui_line(" Downloading…"); + if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err)) != 0) { + ui_line(""); + ui_line(" Download failed."); + ui_line(" Playing the installed version."); +#if defined(__SWITCH__) + { + PadState pad; + padInitializeDefault(&pad); + ui_line(""); + ui_line(" Press B to continue"); + while (appletMainLoop()) { + padUpdate(&pad); + if (padGetButtonsDown(&pad) & HidNpadButton_B) break; + consoleUpdate(NULL); + } + } +#endif + ui_end(); + return 0; + } + + char sums_url[512]; + snprintf(sums_url, sizeof(sums_url), + "https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt", + rel.tag); + if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err)) != 0) { + ui_line(""); + ui_line(" Could not verify the update."); + ui_line(" Playing the installed version."); + remove(zip_path); + ui_end(); +#if defined(__SWITCH__) + svcSleepThread(1200000000ULL); +#endif + return 0; + } + + FILE *sf = fopen(sums_path, "rb"); + if (!sf) { + remove(zip_path); + ui_end(); + return 0; + } + fseek(sf, 0, SEEK_END); + long slen = ftell(sf); + fseek(sf, 0, SEEK_SET); + char *sums = (char *)malloc((size_t)slen + 1); + if (!sums) { + fclose(sf); + ui_end(); + return 0; + } + fread(sums, 1, (size_t)slen, sf); + sums[slen] = '\0'; + fclose(sf); + + char hex[96]; + if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) { + free(sums); + remove(zip_path); + ui_end(); + return 0; + } + ota_verify_t ver; + ota_verify_sha256(dec.asset_name, hex, sums, &ver); + free(sums); + if (!ver.ok) { + ui_line(""); + ui_line(" Update file failed checks."); + ui_line(" Playing the installed version."); + remove(zip_path); + ui_end(); +#if defined(__SWITCH__) + svcSleepThread(1200000000ULL); +#endif + return 0; + } + + char extracted[256]; + char extracted_launcher[256]; + snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir); + snprintf(extracted_launcher, sizeof(extracted_launcher), + "%s/updates/gen1recomp.nro.verified", install_dir); + +#if defined(__SWITCH__) + ui_line(""); + ui_line(" Installing…"); + /* Prefer path inside SD zip; fall back to bare name. */ + if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) { + if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) { + ui_line(""); + ui_line(" Install failed."); + ui_line(" Playing the installed version."); + remove(zip_path); + ui_end(); + svcSleepThread(1200000000ULL); + return 0; + } + } + + if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) { + ui_line(""); + ui_line(" Install failed."); + ui_line(" Playing the installed version."); + remove(extracted); + remove(zip_path); + ui_end(); + svcSleepThread(1200000000ULL); + return 0; + } + remove(extracted); + + /* Also replace launcher so hbmenu/Sphaira see the matching NACP version. + * Best-effort: game update already succeeded if this fails. */ + if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err, + sizeof(err)) == 0 || + ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err, + sizeof(err)) == 0) { + if (ota_fs_atomic_replace_nro(install_dir, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err, + sizeof(err)) == 0) { + remove(extracted_launcher); + } else { + remove(extracted_launcher); + } + } + + char vpath[192]; + snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir); + FILE *vf = fopen(vpath, "wb"); + if (vf) { + fprintf(vf, "%s\n", dec.version); + fclose(vf); + } +#else + (void)extracted; + (void)extracted_launcher; +#endif + remove(zip_path); + ui_end(); + return 0; +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + +#if defined(__SWITCH__) + socketInitializeDefault(); + padConfigureInput(1, HidNpadStyleSet_NpadStandard); +#endif + + const char *install = SD_INSTALL_DIR; + run_update_flow(install); + + char game[192]; + snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME); + char err[128]; + err[0] = '\0'; + if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) { + ui_line(""); + ui_line(" gen1recomp"); + ui_line(""); + ui_line(" Game files are missing."); + ui_line(" Copy the Switch zip onto your microSD,"); + ui_line(" then open gen1recomp again."); +#if defined(__SWITCH__) + ui_line(""); + ui_line(" Press + to exit"); + PadState pad; + padInitializeDefault(&pad); + while (appletMainLoop()) { + padUpdate(&pad); + if (padGetButtonsDown(&pad) & HidNpadButton_Plus) break; + consoleUpdate(NULL); + } +#endif + ui_end(); + } + +#if defined(__SWITCH__) + socketExit(); +#endif + return 0; +} diff --git a/native/switch-ota-launcher/src/ota_fs.c b/native/switch-ota-launcher/src/ota_fs.c new file mode 100644 index 00000000..f2d04bc9 --- /dev/null +++ b/native/switch-ota-launcher/src/ota_fs.c @@ -0,0 +1,136 @@ +#include "ota_fs.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#include +#else +#include +#endif + +int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) { + if (!out || out_len == 0) return -1; + out[0] = '\0'; + char path[256]; + snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR); + FILE *fp = fopen(path, "rb"); + if (!fp) return -1; + if (!fgets(out, (int)out_len, fp)) { + fclose(fp); + return -1; + } + fclose(fp); + /* trim */ + size_t n = strlen(out); + while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) { + out[--n] = '\0'; + } + return n > 0 ? 0 : -1; +} + +int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) { + if (!path || !out_hex || out_len < 65) return -1; + out_hex[0] = '\0'; + FILE *fp = fopen(path, "rb"); + if (!fp) return -1; + +#if defined(__SWITCH__) + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + mbedtls_sha256_starts(&ctx, 0); + unsigned char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { + mbedtls_sha256_update(&ctx, buf, n); + } + fclose(fp); + unsigned char digest[32]; + mbedtls_sha256_finish(&ctx, digest); + mbedtls_sha256_free(&ctx); + for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]); + return 0; +#else + /* Host: shell out to shasum/sha256sum for the host test path if needed. + * Protocol host tests do not call this; return not-implemented. */ + fclose(fp); + (void)out_len; + return -1; +#endif +} + +static int copy_file(const char *from, const char *to) { + FILE *in = fopen(from, "rb"); + if (!in) return -1; + FILE *out = fopen(to, "wb"); + if (!out) { + fclose(in); + return -1; + } + char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { + if (fwrite(buf, 1, n, out) != n) { + fclose(in); + fclose(out); + return -1; + } + } + fclose(in); + fclose(out); + return 0; +} + +int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro, + char *err, size_t err_len) { + return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err, + err_len); +} + +int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name, + const char *verified_nro, char *err, size_t err_len) { + if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) { + if (err && err_len) snprintf(err, err_len, "missing nro paths"); + return -1; + } + char dest[240]; + char part[256]; + snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name); + snprintf(part, sizeof(part), "%s.part", dest); + if (copy_file(verified_nro, part) != 0) { + if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name); + return -1; + } +#ifdef _WIN32 + remove(dest); +#endif + if (rename(part, dest) != 0) { + if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name); + remove(part); + return -1; + } + return 0; +} + +int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) { + if (!game_nro_path || !*game_nro_path) { + if (err && err_len) snprintf(err, err_len, "missing game path"); + return -1; + } +#if defined(__SWITCH__) + /* argv for next load: empty args string is fine */ + Result rc = envSetNextLoad(game_nro_path, game_nro_path); + if (R_FAILED(rc)) { + if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc); + return -1; + } + return 0; +#else + if (err && err_len) + snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path); + return 0; /* host stub succeeds for flow tests */ +#endif +} diff --git a/native/switch-ota-launcher/src/ota_net.c b/native/switch-ota-launcher/src/ota_net.c new file mode 100644 index 00000000..1e0979e6 --- /dev/null +++ b/native/switch-ota-launcher/src/ota_net.c @@ -0,0 +1,119 @@ +#include "ota_net.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#endif + +#if defined(__SWITCH__) +struct mem_buf { + char *data; + size_t len; +}; + +static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) { + struct mem_buf *m = (struct mem_buf *)userdata; + size_t n = size * nmemb; + char *p = (char *)realloc(m->data, m->len + n + 1); + if (!p) return 0; + m->data = p; + memcpy(m->data + m->len, ptr, n); + m->len += n; + m->data[m->len] = '\0'; + return n; +} + +static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) { + return fwrite(ptr, size, nmemb, (FILE *)userdata); +} +#endif + +int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len, + char *err, size_t err_len) { + if (out) *out = NULL; + if (out_len) *out_len = 0; + if (!url || !out) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + CURL *curl = curl_easy_init(); + if (!curl) { + if (err && err_len) snprintf(err, err_len, "curl_easy_init failed"); + return -1; + } + struct mem_buf mem = {0}; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem); + /* Atmosphere / homebrew CA store is limited; match common homebrew practice. */ + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + CURLcode rc = curl_easy_perform(curl); + curl_easy_cleanup(curl); + if (rc != CURLE_OK) { + free(mem.data); + if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc)); + return -1; + } + *out = mem.data; + if (out_len) *out_len = mem.len; + return 0; +#else + (void)timeout_ms; + if (err && err_len) + snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__"); + return -1; +#endif +} + +int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, + size_t err_len) { + if (!url || !path) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + FILE *fp = fopen(path, "wb"); + if (!fp) { + if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path); + return -1; + } + CURL *curl = curl_easy_init(); + if (!curl) { + fclose(fp); + if (err && err_len) snprintf(err, err_len, "curl_easy_init failed"); + return -1; + } + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + CURLcode rc = curl_easy_perform(curl); + curl_easy_cleanup(curl); + fclose(fp); + if (rc != CURLE_OK) { + if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc)); + return -1; + } + return 0; +#else + (void)timeout_ms; + if (err && err_len) + snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__"); + return -1; +#endif +} diff --git a/native/switch-ota-launcher/src/ota_protocol.c b/native/switch-ota-launcher/src/ota_protocol.c new file mode 100644 index 00000000..4e943ec8 --- /dev/null +++ b/native/switch-ota-launcher/src/ota_protocol.c @@ -0,0 +1,292 @@ +#include "ota_protocol.h" + +#include +#include +#include +#include + +typedef struct { + int major; + int minor; + int patch; + int ok; +} semver_t; + +static void set_reason(char *buf, size_t n, const char *r) { + if (!buf || n == 0) return; + snprintf(buf, n, "%s", r ? r : ""); +} + +static int parse_semver(const char *s, semver_t *out) { + memset(out, 0, sizeof(*out)); + if (!s || !*s) return 0; + if (s[0] == 'v' || s[0] == 'V') s++; + int maj = 0, min = 0, pat = 0; + char trail = 0; + if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0; + if (maj < 0 || min < 0 || pat < 0) return 0; + out->major = maj; + out->minor = min; + out->patch = pat; + out->ok = 1; + return 1; +} + +int ota_compare_semver(const char *a, const char *b) { + semver_t pa, pb; + int oa = parse_semver(a, &pa); + int ob = parse_semver(b, &pb); + if (!oa && !ob) return 0; + if (!oa) return -1; + if (!ob) return 1; + if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1; + if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1; + if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1; + return 0; +} + +int ota_is_ota_asset_name(const char *name) { + int maj = 0, min = 0, pat = 0; + if (!name) return 0; + /* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */ + size_t len = strlen(name); + const char *suffix = "-switch.zip"; + size_t slen = strlen(suffix); + if (len <= slen) return 0; + if (strcmp(name + len - slen, suffix) != 0) return 0; + if (strncmp(name, "gen1recomp-", 11) != 0) return 0; + if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0; + char rebuilt[128]; + snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat); + return strcmp(name, rebuilt) == 0; +} + +int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) { + int maj = 0, min = 0, pat = 0; + if (!out || out_len == 0) return 0; + out[0] = '\0'; + if (!ota_is_ota_asset_name(name)) return 0; + if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0; + snprintf(out, out_len, "%d.%d.%d", maj, min, pat); + return 1; +} + +static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) { + char pattern[128]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return NULL; + p = strchr(p + strlen(pattern), ':'); + if (!p) return NULL; + p++; + while (*p && isspace((unsigned char)*p)) p++; + if (*p != '"') return NULL; + p++; + size_t i = 0; + while (*p && *p != '"' && i + 1 < out_len) { + if (*p == '\\' && p[1]) { + p++; + out[i++] = *p++; + continue; + } + out[i++] = *p++; + } + out[i] = '\0'; + return out; +} + +int ota_parse_release(const char *json_text, ota_release_t *out) { + memset(out, 0, sizeof(*out)); + if (!json_text || !*json_text) { + set_reason(out->reason, sizeof(out->reason), "empty_json"); + return 0; + } + if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) { + set_reason(out->reason, sizeof(out->reason), "missing_tag"); + return 0; + } + semver_t sv; + if (!parse_semver(out->tag, &sv)) { + set_reason(out->reason, sizeof(out->reason), "bad_tag"); + return 0; + } + snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch); + + /* Scan for OTA asset name then its browser_download_url in the same object-ish window. */ + const char *cursor = json_text; + while ((cursor = strstr(cursor, "\"name\"")) != NULL) { + char name[128]; + if (!find_json_string(cursor, "name", name, sizeof(name))) { + cursor += 6; + continue; + } + if (ota_is_ota_asset_name(name)) { + snprintf(out->asset_name, sizeof(out->asset_name), "%s", name); + /* Prefer URL after this name key within a limited window. */ + const char *window_end = cursor + 800; + if (window_end > json_text + strlen(json_text)) window_end = json_text + strlen(json_text); + char url[512]; + const char *u = NULL; + const char *scan = cursor; + while (scan < window_end && (scan = strstr(scan, "\"browser_download_url\"")) != NULL && + scan < window_end) { + if (find_json_string(scan, "browser_download_url", url, sizeof(url))) { + u = url; + break; + } + scan += 21; + } + if (!u || !*u) { + set_reason(out->reason, sizeof(out->reason), "missing_download_url"); + return 0; + } + snprintf(out->download_url, sizeof(out->download_url), "%s", u); + out->ok = 1; + return 1; + } + cursor += 6; + } + set_reason(out->reason, sizeof(out->reason), "missing_ota_asset"); + return 0; +} + +void ota_decide_update(const char *installed_version, const ota_release_t *release, + ota_decision_t *out) { + memset(out, 0, sizeof(*out)); + semver_t inst; + if (!parse_semver(installed_version, &inst)) { + snprintf(out->status, sizeof(out->status), "error"); + set_reason(out->reason, sizeof(out->reason), "bad_installed_version"); + return; + } + if (!release || !release->ok) { + snprintf(out->status, sizeof(out->status), "error"); + set_reason(out->reason, sizeof(out->reason), "bad_release"); + return; + } + if (ota_compare_semver(release->version, installed_version) <= 0) { + snprintf(out->status, sizeof(out->status), "uptodate"); + snprintf(out->version, sizeof(out->version), "%s", installed_version); + return; + } + snprintf(out->status, sizeof(out->status), "available"); + snprintf(out->version, sizeof(out->version), "%s", release->version); + snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name); + snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url); +} + +int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) { + if (out_hex && out_len) out_hex[0] = '\0'; + if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0; + const char *p = sums_text; + while (*p) { + char hex[96]; + char name[256]; + int n = 0; + if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) { + const char *nm = name; + if (nm[0] == '*') nm++; + if (strncmp(nm, "./", 2) == 0) nm += 2; + if (strcmp(nm, asset_name) == 0) { + /* normalize hex to lowercase */ + size_t i; + for (i = 0; hex[i] && i + 1 < out_len; i++) + out_hex[i] = (char)tolower((unsigned char)hex[i]); + out_hex[i] = '\0'; + return 1; + } + p += n; + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + continue; + } + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + } + return 0; +} + +void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text, + ota_verify_t *out) { + memset(out, 0, sizeof(*out)); + if (!asset_name || !*asset_name) { + set_reason(out->reason, sizeof(out->reason), "bad_asset_name"); + return; + } + if (!sums_text) { + set_reason(out->reason, sizeof(out->reason), "missing_sums"); + return; + } + char expected[96]; + if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) { + set_reason(out->reason, sizeof(out->reason), "sum_not_found"); + return; + } + if (!actual_hex || !*actual_hex) { + set_reason(out->reason, sizeof(out->reason), "missing_actual_hash"); + return; + } + char actual[96]; + size_t i; + for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++) + actual[i] = (char)tolower((unsigned char)actual_hex[i]); + actual[i] = '\0'; + if (strcmp(actual, expected) != 0) { + set_reason(out->reason, sizeof(out->reason), "hash_mismatch"); + return; + } + out->ok = 1; +} + +void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp, + ota_apply_plan_t *out) { + memset(out, 0, sizeof(*out)); + if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR; + if (!verified_temp) verified_temp = ""; + snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME); + snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro); + snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir, + OTA_LAUNCHER_NRO_NAME); + snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro); + snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro); + /* verified_temp is the game NRO; launcher replace uses a sibling verified path */ + snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp, + out->part_path); + snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro); + snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part); + snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part, + out->launcher_nro); + snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro); + snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME); + snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir, + OTA_SAVE_DIR_NAME); + snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro); +} + +void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) { + memset(out, 0, sizeof(*out)); + ota_offline_events_t ev = {0, -1, 0}; + if (events) ev = *events; + if (ev.user_skip) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "user_skip"); + snprintf(out->message, sizeof(out->message), "update skipped"); + return; + } + if (ev.api_error || ev.network_ok == 0) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "offline_or_error"); + snprintf(out->message, sizeof(out->message), + "offline or update check failed — play installed version"); + return; + } + if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "timeout"); + snprintf(out->message, sizeof(out->message), "update check timed out after %ds", + OTA_CHECK_TIMEOUT_SEC); + return; + } + snprintf(out->action, sizeof(out->action), "keep_checking"); + set_reason(out->reason, sizeof(out->reason), "in_flight"); +} diff --git a/native/switch-ota-launcher/src/ota_unzip.c b/native/switch-ota-launcher/src/ota_unzip.c new file mode 100644 index 00000000..72c72425 --- /dev/null +++ b/native/switch-ota-launcher/src/ota_unzip.c @@ -0,0 +1,89 @@ +#include "ota_unzip.h" + +#include +#include + +#if defined(__SWITCH__) +#include +#include +#endif + +#if defined(__SWITCH__) +static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err, + size_t err_len) { + ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY); + if (!zf) return 1; /* not found / cannot open */ + + FILE *out = fopen(dest_path, "wb"); + if (!out) { + zzip_file_close(zf); + if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path); + return -1; + } + + char buf[8192]; + zzip_ssize_t n; + while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) { + if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) { + fclose(out); + zzip_file_close(zf); + if (err && err_len) snprintf(err, err_len, "fwrite failed"); + return -1; + } + } + fclose(out); + zzip_file_close(zf); + if (n < 0) { + if (err && err_len) snprintf(err, err_len, "zzip_file_read failed"); + return -1; + } + return 0; +} +#endif + +int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path, + char *err, size_t err_len) { + if (!zip_path || !member_name || !dest_path) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + zzip_error_t zerr = ZZIP_NO_ERROR; + ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr); + if (!dir) { + if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path); + return -1; + } + + char alt1[192]; + char alt2[192]; + snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name); + snprintf(alt2, sizeof(alt2), "./%s", member_name); + + const char *candidates[] = {member_name, alt1, alt2, NULL}; + int rc = -1; + if (err && err_len) err[0] = '\0'; + for (int i = 0; candidates[i]; i++) { + int t = extract_member(dir, candidates[i], dest_path, err, err_len); + if (t == 0) { + rc = 0; + break; + } + if (t < 0) { + rc = -1; + break; + } + } + if (rc != 0 && err && err_len && !err[0]) { + snprintf(err, err_len, "member not found in zip: %s", member_name); + } + zzip_dir_close(dir); + return rc; +#else + (void)zip_path; + (void)member_name; + (void)dest_path; + if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__"); + return -1; +#endif +} diff --git a/scripts/build_switch.sh b/scripts/build_switch.sh index 9bb2544f..8fbc86b6 100755 --- a/scripts/build_switch.sh +++ b/scripts/build_switch.sh @@ -5,8 +5,10 @@ # scripts/build_switch.sh --fetch # scripts/build_switch.sh --loose [path/to/game.love] # scripts/build_switch.sh --fused [--version X.Y.Z] +# scripts/build_switch.sh --ota [--version X.Y.Z] # scripts/build_switch.sh --fetch --loose # scripts/build_switch.sh --fetch --fused [--version X.Y.Z] +# scripts/build_switch.sh --fetch --ota [--version X.Y.Z] # # Modes: # --fetch Download pinned love.nro + love.elf into @@ -27,11 +29,17 @@ # Requires the pin (run --fetch or combine). GitHub Releases # publish the zip only; the versioned .nro stays local / PR CI. # -# Combinable: --fetch alone, or --fetch with --loose / --fused. -# XOR: --loose and --fused cannot be used together. +# --ota Like --fused, plus native OTA launcher NRO and dual-NRO SD zip +# (gen1recomp.nro launcher + gen1recomp-game.nro). The same +# gen1recomp--switch.zip is the OTA download asset. Needs +# DEVKITPRO switch-curl/mbedtls/zlib/zziplib — see +# scripts/switch/install_devkitpro_deps.sh. +# +# Combinable: --fetch alone, or --fetch with --loose / --fused / --ota. +# XOR: --loose, --fused, and --ota cannot be combined with each other. # # Non-goals (never done by this script): -# MTP push, ROM install, dkp-pacman auto-install. +# MTP push, ROM install, dkp-pacman auto-install (use install_devkitpro_deps.sh). set -euo pipefail @@ -41,6 +49,7 @@ DIST="$ROOT/dist/switch" LOOSE=0 FUSED=0 FETCH=0 +OTA=0 GAME_LOVE="" VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)" @@ -49,13 +58,14 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; } fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } usage() { - sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' } while [ $# -gt 0 ]; do case "$1" in --loose) LOOSE=1; shift ;; --fused) FUSED=1; shift ;; + --ota) OTA=1; shift ;; --fetch) FETCH=1; shift ;; --version) VERSION="$2"; shift 2 ;; -h|--help) @@ -73,12 +83,13 @@ while [ $# -gt 0 ]; do esac done -if [ "$LOOSE" -eq 1 ] && [ "$FUSED" -eq 1 ]; then - fail "choose one of --loose or --fused (XOR)" +MODE_COUNT=$((LOOSE + FUSED + OTA)) +if [ "$MODE_COUNT" -gt 1 ]; then + fail "choose one of --loose, --fused, or --ota (XOR)" fi -if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ]; then - fail "specify --fetch, --loose, and/or --fused (see --help)" +if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ] && [ "$OTA" -eq 0 ]; then + fail "specify --fetch, --loose, --fused, and/or --ota (see --help)" fi if [ "$FETCH" -eq 1 ]; then @@ -128,12 +139,23 @@ if [ "$LOOSE" -eq 1 ]; then exec "$ROOT/scripts/switch/assemble_loose.sh" "$GAME_LOVE" fi -if [ "$FUSED" -eq 1 ]; then +if [ "$FUSED" -eq 1 ] || [ "$OTA" -eq 1 ]; then GAME_LOVE="$(pack_game_love)" OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro" OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip" "$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO" - "$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" + + if [ "$OTA" -eq 1 ]; then + LAUNCHER_NRO="$DIST/gen1recomp-${VERSION}-launcher.nro" + say "building native OTA launcher" + "$ROOT/scripts/switch/build_ota_launcher.sh" "$LAUNCHER_NRO" "$VERSION" + say "packing dual-NRO SD zip (also the OTA download asset)" + "$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" "$LAUNCHER_NRO" + cp "$OUT_NRO" "$DIST/gen1recomp-${VERSION}-game.nro" + else + "$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" + fi + cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json" say "done. See $DIST/" exit 0 @@ -145,4 +167,4 @@ if [ "$FETCH" -eq 1 ]; then exit 0 fi -fail "specify --fetch, --loose, and/or --fused (see --help)" +fail "specify --fetch, --loose, --fused, and/or --ota (see --help)" diff --git a/scripts/switch/build_ota_launcher.sh b/scripts/switch/build_ota_launcher.sh new file mode 100755 index 00000000..c1ce80dd --- /dev/null +++ b/scripts/switch/build_ota_launcher.sh @@ -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 < 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" diff --git a/scripts/switch/ota_launcher.manifest b/scripts/switch/ota_launcher.manifest new file mode 100644 index 00000000..cf84a6d4 --- /dev/null +++ b/scripts/switch/ota_launcher.manifest @@ -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 diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh index 8f76cac0..6e7d7a7f 100755 --- a/scripts/switch/pack_sd_zip.sh +++ b/scripts/switch/pack_sd_zip.sh @@ -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" </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" diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh index 7377c5d9..b7eb9109 100755 --- a/scripts/switch/selftest_build_switch.sh +++ b/scripts/switch/selftest_build_switch.sh @@ -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 # --------------------------------------------------------------------------- diff --git a/src/update/SwitchOta.lua b/src/update/SwitchOta.lua new file mode 100644 index 00000000..ba87dd35 --- /dev/null +++ b/src/update/SwitchOta.lua @@ -0,0 +1,186 @@ +-- Switch OTA protocol contract (host-testable, no love.*). +-- The native DEVKITPRO launcher (libnx + switch-curl) must implement the +-- same decisions. LÖVE on NX never runs this path — Platform.networkValidated +-- stays false and src/update/Check.lua remains gated off on NX. +-- +-- Asset: gen1recomp--switch.zip (same SD zip used for install). + +local SwitchOta = {} + +SwitchOta.RELEASES_API = + "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" +SwitchOta.OTA_ASSET_PATTERN = "^gen1recomp%-(%d+%.%d+%.%d+)%-switch%.zip$" +SwitchOta.CHECK_TIMEOUT_SEC = 6 +SwitchOta.GAME_NRO_NAME = "gen1recomp-game.nro" +SwitchOta.LAUNCHER_NRO_NAME = "gen1recomp.nro" +SwitchOta.SAVE_DIR_NAME = "pokemon-love2d" +SwitchOta.INSTALL_DIR = "switch/gen1recomp" + +local function parseSemver(s) + if type(s) ~= "string" then return nil end + local body = s:match("^v?(.+)$") + if not body then return nil end + local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$") + if not maj then return nil end + return { major = tonumber(maj), minor = tonumber(min), patch = tonumber(pat) } +end + +function SwitchOta.compareSemver(a, b) + local pa, pb = parseSemver(a), parseSemver(b) + if not pa and not pb then return 0 end + if not pa then return -1 end + if not pb then return 1 end + for _, field in ipairs({ "major", "minor", "patch" }) do + if pa[field] < pb[field] then return -1 end + if pa[field] > pb[field] then return 1 end + end + return 0 +end + +function SwitchOta.isOtaAssetName(name) + if type(name) ~= "string" then return false end + return name:match(SwitchOta.OTA_ASSET_PATTERN) ~= nil +end + +function SwitchOta.versionFromOtaAsset(name) + if type(name) ~= "string" then return nil end + return name:match(SwitchOta.OTA_ASSET_PATTERN) +end + +-- Parse GitHub releases/latest JSON (minimal: tag_name + assets[].name/browser_download_url). +-- Returns { tag, version, assetName, downloadUrl } or nil + reason. +function SwitchOta.parseRelease(jsonText) + if type(jsonText) ~= "string" or jsonText == "" then + return nil, "empty_json" + end + local tag = jsonText:match('"tag_name"%s*:%s*"(.-)"') + if not tag then return nil, "missing_tag" end + local version = tag:match("^v?(%d+%.%d+%.%d+)$") + if not version then return nil, "bad_tag" end + + local assetName, downloadUrl + for block in jsonText:gmatch("%b{}") do + local name = block:match('"name"%s*:%s*"(.-)"') + if name and SwitchOta.isOtaAssetName(name) then + assetName = name + downloadUrl = block:match('"browser_download_url"%s*:%s*"(.-)"') + break + end + end + if not assetName then return nil, "missing_ota_asset" end + if not downloadUrl or downloadUrl == "" then return nil, "missing_download_url" end + + return { + tag = tag, + version = version, + assetName = assetName, + downloadUrl = downloadUrl, + } +end + +-- Decide check outcome given installed version and parsed release. +-- Returns status: uptodate | available | error +function SwitchOta.decideUpdate(installedVersion, release) + if type(installedVersion) ~= "string" or not parseSemver(installedVersion) then + return { status = "error", reason = "bad_installed_version" } + end + if type(release) ~= "table" or not release.version then + return { status = "error", reason = "bad_release" } + end + local cmp = SwitchOta.compareSemver(release.version, installedVersion) + if cmp <= 0 then + return { status = "uptodate", version = installedVersion } + end + return { + status = "available", + version = release.version, + assetName = release.assetName, + downloadUrl = release.downloadUrl, + } +end + +-- Parse sha256sums.txt lines: " " or " *" +function SwitchOta.parseSums(text) + local sums = {} + if type(text) ~= "string" then return sums end + for line in (text .. "\n"):gmatch("(.-)\n") do + local hex, name = line:match("^(%x+)%s+%*?%./?(.-)%s*$") + if hex and name and name ~= "" then + sums[name] = hex:lower() + end + end + return sums +end + +-- Require a matching sha256. Missing sum is ALWAYS reject (no silent accept). +function SwitchOta.verifySha256(assetName, actualHex, sums) + if type(assetName) ~= "string" or assetName == "" then + return false, "bad_asset_name" + end + if type(sums) ~= "table" then + return false, "missing_sums" + end + local expected = sums[assetName] + if type(expected) ~= "string" or expected == "" then + return false, "sum_not_found" + end + if type(actualHex) ~= "string" or actualHex == "" then + return false, "missing_actual_hash" + end + if actualHex:lower() ~= expected:lower() then + return false, "hash_mismatch" + end + return true, nil +end + +-- Atomic apply plan: never writes live NROs directly; never touches saves. +-- Replaces game + launcher so NACP versions stay in sync (hbmenu / Sphaira). +function SwitchOta.planAtomicApply(installDir, verifiedTempPath) + installDir = installDir or SwitchOta.INSTALL_DIR + local gameNro = installDir .. "/" .. SwitchOta.GAME_NRO_NAME + local launcherNro = installDir .. "/" .. SwitchOta.LAUNCHER_NRO_NAME + local partPath = gameNro .. ".part" + local launcherPart = launcherNro .. ".part" + return { + steps = { + { op = "copy_to_part", from = verifiedTempPath, to = partPath }, + { op = "rename", from = partPath, to = gameNro }, + { op = "copy_to_part", from = "launcher", to = launcherPart }, + { op = "rename", from = launcherPart, to = launcherNro }, + { op = "env_set_next_load", target = gameNro }, + }, + preserve = { installDir .. "/" .. SwitchOta.SAVE_DIR_NAME }, + forbidden = { + "delete:" .. installDir .. "/" .. SwitchOta.SAVE_DIR_NAME, + "write_direct:" .. gameNro, + }, + } +end + +-- Offline / skip / timeout policy. Never blocks forever. +-- elapsedSec: time spent on the network check so far +-- events: { networkOk=bool, userSkip=bool, apiError=bool } +function SwitchOta.offlinePolicy(elapsedSec, events) + events = events or {} + if events.userSkip then + return { action = "play_installed", reason = "user_skip", message = "update skipped" } + end + if events.apiError or events.networkOk == false then + return { + action = "play_installed", + reason = "offline_or_error", + message = "offline or update check failed — play installed version", + } + end + local timeout = SwitchOta.CHECK_TIMEOUT_SEC + if type(elapsedSec) == "number" and elapsedSec >= timeout then + return { + action = "play_installed", + reason = "timeout", + message = "update check timed out after " .. tostring(timeout) .. "s", + } + end + return { action = "keep_checking", reason = "in_flight" } +end + +return SwitchOta diff --git a/test/switch-nro-ota.spec.test.js b/test/switch-nro-ota.spec.test.js new file mode 100644 index 00000000..087433b0 --- /dev/null +++ b/test/switch-nro-ota.spec.test.js @@ -0,0 +1,467 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(root, rel), 'utf8'); +} + +function sha256Hex(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +/** JS mirror of src/update/SwitchOta.lua — contract under test. */ +function createSwitchOta() { + const OTA_RE = /^gen1recomp-(\d+\.\d+\.\d+)-switch\.zip$/; + const CHECK_TIMEOUT_SEC = 6; + const GAME_NRO_NAME = 'gen1recomp-game.nro'; + const LAUNCHER_NRO_NAME = 'gen1recomp.nro'; + const SAVE_DIR_NAME = 'pokemon-love2d'; + const INSTALL_DIR = 'switch/gen1recomp'; + + function parseSemver(s) { + if (typeof s !== 'string') return null; + const m = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; + } + + function compareSemver(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa && !pb) return 0; + if (!pa) return -1; + if (!pb) return 1; + for (const f of ['major', 'minor', 'patch']) { + if (pa[f] < pb[f]) return -1; + if (pa[f] > pb[f]) return 1; + } + return 0; + } + + function isOtaAssetName(name) { + return typeof name === 'string' && OTA_RE.test(name); + } + + function parseRelease(jsonText) { + if (!jsonText) return { ok: false, reason: 'empty_json' }; + let obj; + try { + obj = JSON.parse(jsonText); + } catch { + return { ok: false, reason: 'bad_json' }; + } + const tag = obj.tag_name; + if (!tag) return { ok: false, reason: 'missing_tag' }; + const version = String(tag).match(/^v?(\d+\.\d+\.\d+)$/)?.[1]; + if (!version) return { ok: false, reason: 'bad_tag' }; + const asset = (obj.assets || []).find((a) => isOtaAssetName(a.name)); + if (!asset) return { ok: false, reason: 'missing_ota_asset' }; + if (!asset.browser_download_url) return { ok: false, reason: 'missing_download_url' }; + return { + ok: true, + tag, + version, + assetName: asset.name, + downloadUrl: asset.browser_download_url, + }; + } + + function decideUpdate(installed, release) { + if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' }; + if (!release?.ok || !release.version) return { status: 'error', reason: 'bad_release' }; + if (compareSemver(release.version, installed) <= 0) { + return { status: 'uptodate', version: installed }; + } + return { + status: 'available', + version: release.version, + assetName: release.assetName, + downloadUrl: release.downloadUrl, + }; + } + + function parseSums(text) { + const sums = {}; + if (typeof text !== 'string') return sums; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^([0-9a-fA-F]+)\s+\*?\.?\/?(.*?)\s*$/); + if (m) sums[m[2]] = m[1].toLowerCase(); + } + return sums; + } + + function verifySha256(assetName, actualHex, sums) { + if (!assetName) return { ok: false, reason: 'bad_asset_name' }; + if (!sums || typeof sums !== 'object') return { ok: false, reason: 'missing_sums' }; + const expected = sums[assetName]; + if (!expected) return { ok: false, reason: 'sum_not_found' }; + if (!actualHex) return { ok: false, reason: 'missing_actual_hash' }; + if (actualHex.toLowerCase() !== expected.toLowerCase()) { + return { ok: false, reason: 'hash_mismatch' }; + } + return { ok: true }; + } + + function planAtomicApply(installDir, verifiedTempPath) { + installDir = installDir || INSTALL_DIR; + const gameNro = `${installDir}/${GAME_NRO_NAME}`; + const launcherNro = `${installDir}/${LAUNCHER_NRO_NAME}`; + const partPath = `${gameNro}.part`; + const launcherPart = `${launcherNro}.part`; + return { + steps: [ + { op: 'copy_to_part', from: verifiedTempPath, to: partPath }, + { op: 'rename', from: partPath, to: gameNro }, + { op: 'copy_to_part', from: 'launcher', to: launcherPart }, + { op: 'rename', from: launcherPart, to: launcherNro }, + { op: 'env_set_next_load', target: gameNro }, + ], + preserve: [`${installDir}/${SAVE_DIR_NAME}`], + forbidden: [`delete:${installDir}/${SAVE_DIR_NAME}`, `write_direct:${gameNro}`], + }; + } + + function offlinePolicy(elapsedSec, events = {}) { + if (events.userSkip) { + return { action: 'play_installed', reason: 'user_skip', message: 'update skipped' }; + } + if (events.apiError || events.networkOk === false) { + return { + action: 'play_installed', + reason: 'offline_or_error', + message: 'offline or update check failed — play installed version', + }; + } + if (typeof elapsedSec === 'number' && elapsedSec >= CHECK_TIMEOUT_SEC) { + return { + action: 'play_installed', + reason: 'timeout', + message: `update check timed out after ${CHECK_TIMEOUT_SEC}s`, + }; + } + return { action: 'keep_checking', reason: 'in_flight' }; + } + + return { + CHECK_TIMEOUT_SEC, + GAME_NRO_NAME, + LAUNCHER_NRO_NAME, + SAVE_DIR_NAME, + compareSemver, + isOtaAssetName, + parseRelease, + decideUpdate, + parseSums, + verifySha256, + planAtomicApply, + offlinePolicy, + }; +} + +const M = createSwitchOta(); + +const sampleRelease = M.parseRelease( + JSON.stringify({ + tag_name: 'v1.5.0', + assets: [ + { + name: 'gen1recomp-1.5.0-switch.zip', + browser_download_url: 'https://example/switch.zip', + }, + { name: 'sha256sums.txt', browser_download_url: 'https://example/sums' }, + ], + }) +); + +test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => { + assert.equal(M.compareSemver('1.2.0', '1.1.0'), 1); + assert.equal(M.compareSemver('1.1.0', '1.1.0'), 0); + assert.equal(M.compareSemver('1.0.0', '1.1.0'), -1); + + assert.equal(sampleRelease.ok, true); + assert.equal(sampleRelease.version, '1.5.0'); + assert.equal(sampleRelease.assetName, 'gen1recomp-1.5.0-switch.zip'); + + assert.equal(M.decideUpdate('1.4.0', sampleRelease).status, 'available'); + assert.equal(M.decideUpdate('1.5.0', sampleRelease).status, 'uptodate'); + assert.equal(M.decideUpdate('1.6.0', sampleRelease).status, 'uptodate'); + + const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] })); + assert.equal(missing.ok, false); + assert.equal(missing.reason, 'missing_ota_asset'); +}); + +test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => { + const name = 'gen1recomp-1.5.0-switch.zip'; + const payload = Buffer.from('switch-sd-payload'); + const hex = sha256Hex(payload); + const sums = M.parseSums(`${hex} ${name}\n`); + + assert.equal(M.verifySha256(name, hex, sums).ok, true); + assert.equal(M.verifySha256(name, 'deadbeef', sums).reason, 'hash_mismatch'); + // Invisible-error guard: download without a sum MUST NOT verify. + assert.equal(M.verifySha256(name, hex, {}).ok, false); + assert.equal(M.verifySha256(name, hex, {}).reason, 'sum_not_found'); + assert.equal(M.verifySha256(name, '', sums).reason, 'missing_actual_hash'); +}); + +test('AC-003: Aplicação atômica e handoff para o jogo @spec:AC-003', () => { + const plan = M.planAtomicApply('switch/gen1recomp', '/tmp/verified.zip'); + assert.ok(plan.steps.some((s) => s.op === 'copy_to_part')); + assert.ok(plan.steps.some((s) => s.op === 'rename')); + assert.ok(plan.steps.some((s) => s.op === 'env_set_next_load')); + assert.ok(plan.steps.filter((s) => s.op === 'rename').length >= 2, 'renames game + launcher'); + assert.ok(plan.preserve.some((p) => p.endsWith('pokemon-love2d'))); + assert.ok(plan.forbidden.some((f) => f.startsWith('delete:') && f.includes('pokemon-love2d'))); + assert.ok(plan.forbidden.some((f) => f.startsWith('write_direct:'))); + const renameGame = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp-game.nro')); + assert.ok(renameGame); + assert.ok(renameGame.from.endsWith('.part')); + const renameLauncher = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp.nro')); + assert.ok(renameLauncher, 'launcher NRO replaced so NACP version stays in sync'); +}); + +test('AC-004: Offline ou falha de rede não trava o jogo @spec:AC-004', () => { + assert.equal(M.CHECK_TIMEOUT_SEC, 6); + assert.equal(M.offlinePolicy(1, { userSkip: true }).action, 'play_installed'); + assert.equal(M.offlinePolicy(1, { networkOk: false }).action, 'play_installed'); + assert.equal(M.offlinePolicy(6, { networkOk: true }).action, 'play_installed'); + assert.equal(M.offlinePolicy(6, { networkOk: true }).reason, 'timeout'); + assert.equal(M.offlinePolicy(2, { networkOk: true }).action, 'keep_checking'); +}); + +test('AC-005: Documentação Switch descreve o launcher OTA @spec:AC-005', () => { + const install = read('docs/switch-install.md'); + const development = read('docs/switch-development.md'); + const updater = read('docs/updater.md'); + + for (const [name, text] of [ + ['switch-install', install], + ['switch-development', development], + ['updater', updater], + ]) { + assert.match(text, /native OTA launcher|launcher nativo/i, `${name} mentions native OTA launcher`); + } + + assert.match(install, /manual|zip|microSD/i, 'manual zip fallback still documented'); + assert.match(install, /gen1recomp-\*-switch\.zip|switch\.zip/i, 'unified Switch zip documented'); + assert.doesNotMatch(install, /switch-ota\.zip/, 'legacy separate OTA zip must not be documented'); + assert.match(install, /Sphaira|forwarder/i, 'Sphaira shortcut version note documented'); + assert.doesNotMatch( + install, + /Switch OTA uses `src\/update\/Check\.lua`/i, + 'must not claim Switch OTA uses LÖVE Check.lua' + ); + assert.match(updater, /Nintendo Switch/i); + assert.match(updater, /networkValidated|disabled|desligado|off/i); +}); + +test('AC-006: LOVE no NX continua sem rede de updater @spec:AC-006', () => { + const platform = read('src/core/Platform.lua'); + assert.match( + platform, + /networkValidated\s*=\s*not nx/, + 'Platform.networkValidated must stay false on NX' + ); + assert.doesNotMatch(platform, /networkValidated\s*=\s*true/); + + const boot = read('src/update/Boot.lua'); + assert.match(boot, /networkValidated/, 'Boot still gates on networkValidated'); + + assert.ok(read('src/update/Check.lua').length > 0); + assert.ok(read('src/update/check_worker.lua').length > 0); + assert.ok(read('src/core/HostShell.lua').length > 0); + assert.match(read('src/import/RomImporter.lua'), /updaterAllowed|networkValidated|Check/); + assert.ok(read('src/import/LauncherView.lua').length > 0); + + const switchOta = read('src/update/SwitchOta.lua'); + assert.doesNotMatch(switchOta, /networkValidated\s*=\s*true/); + assert.match(switchOta, /never runs this path|remains gated off on NX/i); +}); + +test('AC-007: Funções puras do protocolo OTA têm testes host-side @spec:AC-007', () => { + assert.ok(M.isOtaAssetName('gen1recomp-1.2.3-switch.zip')); + assert.equal(M.isOtaAssetName('gen1recomp-1.2.3-switch-ota.zip'), false); + assert.equal(M.isOtaAssetName('gen1recomp-1.2.3.love'), false); + assert.equal(M.compareSemver('2.0.0', '1.9.9'), 1); + assert.equal(M.compareSemver('1.0.0', '1.0.0'), 0); + assert.equal(M.compareSemver('0.9.0', '1.0.0'), -1); + + const rel = M.parseRelease( + JSON.stringify({ + tag_name: 'v9.9.9', + assets: [ + { + name: 'gen1recomp-9.9.9-switch.zip', + browser_download_url: 'https://example/x', + }, + ], + }) + ); + assert.equal(rel.version, '9.9.9'); + + const sums = M.parseSums('deadbeef unexpected-name.zip\n'); + assert.equal( + M.verifySha256('gen1recomp-9.9.9-switch.zip', 'deadbeef', sums).ok, + false, + 'unexpected filename in sums must not verify OTA asset' + ); + assert.equal(M.verifySha256('gen1recomp-9.9.9-switch.zip', 'anything', {}).ok, false); + + const lua = read('src/update/SwitchOta.lua'); + assert.ok(fs.existsSync(path.join(root, 'src/update/SwitchOta.lua'))); + for (const fn of [ + 'compareSemver', + 'parseRelease', + 'decideUpdate', + 'parseSums', + 'verifySha256', + 'planAtomicApply', + 'offlinePolicy', + ]) { + assert.match(lua, new RegExp(`function SwitchOta\\.${fn}`), `Lua exports ${fn}`); + } + assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/); + assert.match(lua, /switch%.zip|%-switch%.zip/); + assert.doesNotMatch(lua, /switch%-ota%.zip|switch-ota\.zip/); +}); + +test('AC-008: Gates de regressão anti-“erro invisível” @spec:AC-008', () => { + const platform = read('src/core/Platform.lua'); + assert.match(platform, /networkValidated\s*=\s*not nx\s+and\s+not uwp/); + + for (const rel of ['docs/switch-install.md', 'docs/switch-development.md', 'docs/updater.md']) { + const text = read(rel); + assert.match(text, /native OTA launcher|launcher nativo/i, rel); + assert.match( + text, + /LÖVE self-updater|LOVE self-updater|self-updater LÖVE|updater LÖVE|Check\.lua/i, + `${rel} contrasts with LÖVE updater` + ); + } + + const manifest = read('scripts/switch/ota_launcher.manifest'); + assert.match(manifest, /^OTA_ENABLED=1$/m); + assert.match(manifest, /ENTRY_NRO=switch\/gen1recomp\/gen1recomp\.nro/); + assert.match(manifest, /GAME_NRO=switch\/gen1recomp\/gen1recomp-game\.nro/); + assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/); + assert.doesNotMatch(manifest, /switch-ota\.zip/); + assert.match(manifest, /REQUIRE_SHA256SUMS=1/); +}); + +test('AC-009: Protocolo C host-testável espelha SwitchOta.lua @spec:AC-009', () => { + const proto = read('native/switch-ota-launcher/src/ota_protocol.c'); + const header = read('native/switch-ota-launcher/include/ota_protocol.h'); + assert.match(header, /OTA_CHECK_TIMEOUT_SEC\s+6/); + assert.match(header, /ota_compare_semver/); + assert.match(header, /ota_parse_release/); + assert.match(header, /ota_verify_sha256/); + assert.match(header, /ota_plan_atomic_apply/); + assert.match(header, /ota_offline_policy/); + assert.match(proto, /sum_not_found/); + assert.match(proto, /env_set_next_load/); + assert.match(header, /pokemon-love2d/); + assert.match(proto, /OTA_SAVE_DIR_NAME/); + + const lua = read('src/update/SwitchOta.lua'); + assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/); + + // Compile + run C host tests (gcc) + const r = spawnSync( + 'make', + ['-C', path.join(root, 'native/switch-ota-launcher'), 'host-test'], + { encoding: 'utf8' } + ); + assert.equal(r.status, 0, `host-test failed:\n${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /all ota_protocol host tests passed/); +}); + +test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () => { + for (const rel of [ + 'native/switch-ota-launcher/Makefile', + 'native/switch-ota-launcher/README.md', + 'native/switch-ota-launcher/src/main.c', + 'native/switch-ota-launcher/src/ota_net.c', + 'native/switch-ota-launcher/src/ota_fs.c', + 'scripts/switch/build_ota_launcher.sh', + 'docs/switch-build.md', + ]) { + assert.ok(fs.existsSync(path.join(root, rel)), `missing ${rel}`); + } + const main = read('native/switch-ota-launcher/src/main.c'); + assert.match(main, /envSetNextLoad|ota_fs_handoff_to_game/); + assert.match(main, /gen1recomp-game\.nro|OTA_GAME_NRO_NAME/); + assert.match(main, /Quiet by default|stays quiet|LÖVE self-updater stays off|self-updater stays off/i); + assert.match(main, /consoleInit/, 'UI uses console only when needed'); + assert.match(main, /ui_begin|g_ui/, 'UI is gated behind begin'); + + const mk = read('native/switch-ota-launcher/Makefile'); + assert.match(mk, /libnx\/switch_rules|DEVKITPRO/); + assert.match(mk, /-lcurl/); + assert.match(mk, /-lzzip/); + assert.match(mk, /assets\/switch\/icon\.jpg/, 'uses project Switch icon'); + + assert.ok(fs.existsSync(path.join(root, 'native/switch-ota-launcher/src/ota_unzip.c'))); + assert.match(read('native/switch-ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/); + assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/); + assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//); + assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_atomic_replace_nro/); + assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/); + assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/); + assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/); + + const buildDoc = read('docs/switch-build.md'); + assert.match(buildDoc, /native\/switch-ota-launcher|build_ota_launcher/); + assert.match(buildDoc, /switch-curl/); + assert.match(buildDoc, /--ota|install_devkitpro_deps/); + assert.doesNotMatch(buildDoc, /switch-ota\.zip/); + + const readme = read('native/switch-ota-launcher/README.md'); + assert.match(readme, /DEVKITPRO|devkitPro/i); + assert.match(readme, /Docker|devkita64/i); + assert.match(readme, /Quiet|quiet|silen/i); +}); + +test('AC-011: Empacotamento dual-NRO e selftest @spec:AC-011', () => { + const pack = read('scripts/switch/pack_sd_zip.sh'); + assert.match(pack, /LAUNCHER_NRO/); + assert.match(pack, /gen1recomp-game\.nro/); + assert.match(pack, /version\.txt/); + + assert.equal( + fs.existsSync(path.join(root, 'scripts/switch/pack_ota_zip.sh')), + false, + 'separate pack_ota_zip.sh removed — unified *-switch.zip' + ); + + const buildSwitch = read('scripts/build_switch.sh'); + assert.match(buildSwitch, /--ota/); + assert.doesNotMatch(buildSwitch, /pack_ota_zip\.sh/); + assert.match(buildSwitch, /build_ota_launcher\.sh/); + assert.match(buildSwitch, /dual-NRO SD zip|OTA download asset/i); + + const selftest = read('scripts/switch/selftest_build_switch.sh'); + assert.match(selftest, /dual-NRO|gen1recomp-game\.nro/); + assert.match(selftest, /ota_launcher\.manifest/); + assert.match(selftest, /unified OTA|same SD zip|legacy OTA-only/i); + assert.match(selftest, /Quiet by default|quiet by default|ui_begin/); + + const manifest = read('scripts/switch/ota_launcher.manifest'); + assert.match(manifest, /^OTA_ENABLED=1$/m); + assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/); + + // Run the dual-NRO portion via full offline selftest (includes legacy + OTA) + const r = spawnSync('bash', [path.join(root, 'scripts/switch/selftest_build_switch.sh')], { + encoding: 'utf8', + }); + assert.equal(r.status, 0, `selftest failed:\n${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /dual-NRO OTA layout/); +});