Fix Switch OTA launcher self-update via a bootstrap NRO.

A running launcher cannot replace its own NRO on sdmc/FAT; stage the new
binary and chainload a tiny helper from romfs to swap it in before loading
the game.
This commit is contained in:
Andrew Quenehen
2026-08-06 09:59:01 -03:00
parent dd503706b0
commit 3fae3088ca
9 changed files with 204 additions and 9 deletions
+10 -2
View File
@@ -130,7 +130,10 @@ ifneq ($(ROMFS),)
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
endif
.PHONY: $(BUILD) clean all host-test sync-romfs
BOOTSTRAP_DIR := $(TOPDIR)/../switch-ota-bootstrap
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
#---------------------------------------------------------------------------------
all: sync-romfs $(BUILD)
@@ -138,7 +141,12 @@ all: sync-romfs $(BUILD)
CACERT_URL := https://curl.se/ca/cacert.pem
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
sync-romfs:
bootstrap-romfs:
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
@mkdir -p $(CURDIR)/$(ROMFS)
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
sync-romfs: bootstrap-romfs
@mkdir -p $(CURDIR)/$(ROMFS)
@cp -f $(LOGO_SRC) $(CURDIR)/$(ROMFS)/logo.png
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
+4 -1
View File
@@ -68,7 +68,10 @@ 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 install zip (NACP versions stay aligned).
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
launcher, then loads the game.
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
@@ -22,6 +22,12 @@ int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_gam
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
const char *verified_nro, char *err, size_t err_len);
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
* The running launcher cannot replace its own NRO on sdmc/FAT. */
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
char *bootstrap_out, size_t bootstrap_out_len, 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);
@@ -12,6 +12,9 @@ extern "C" {
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
#define OTA_INSTALL_DIR "switch/gen1recomp"
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
#define OTA_RELEASES_API \
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
+25 -6
View File
@@ -198,12 +198,15 @@ static int run_update_flow(const char *install_dir) {
"Update zip is missing launcher files.", err, installed);
return 0;
}
if (ota_fs_atomic_replace_nro(install_dir, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
sizeof(err)) != 0) {
char bootstrap_path[256];
bootstrap_path[0] = '\0';
if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path,
sizeof(bootstrap_path), err, sizeof(err)) != 0) {
remove(extracted_launcher);
remove(zip_path);
show_update_error("Could not install",
"Game updated but launcher could not be replaced. Reinstall from the SD zip.",
"Game updated but launcher could not be staged. Reinstall from the SD zip.",
err, installed);
return 0;
}
@@ -217,8 +220,16 @@ static int run_update_flow(const char *install_dir) {
fclose(vf);
}
ota_ui_show_progress("Ready", "Starting the game...", 1.0f);
ota_ui_show_progress("Ready", "Finishing update...", 1.0f);
svcSleepThread(600000000ULL);
remove(zip_path);
ota_ui_shutdown();
if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) {
show_update_error("Could not install", "Launcher bootstrap failed.", err, installed);
return 0;
}
return 2; /* chainload bootstrap; main must not hand off to game */
#else
(void)extracted;
(void)extracted_launcher;
@@ -233,18 +244,26 @@ int main(int argc, char **argv) {
(void)argv;
const char *install = SD_INSTALL_DIR;
int update_rc = 0;
#if defined(__SWITCH__)
socketInitializeDefault();
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
if (ota_net_init() == 0) {
run_update_flow(install);
update_rc = run_update_flow(install);
ota_net_shutdown();
}
#else
run_update_flow(install);
update_rc = run_update_flow(install);
#endif
if (update_rc == 2) {
#if defined(__SWITCH__)
socketExit();
#endif
return 0;
}
char game[192];
snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME);
char err[128];
+42
View File
@@ -7,6 +7,7 @@
#if defined(__SWITCH__)
#include <switch.h>
#include <mbedtls/sha256.h>
#include <sys/stat.h>
#include <unistd.h>
#else
#include <unistd.h>
@@ -114,6 +115,47 @@ int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
return 0;
}
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
char *bootstrap_out, size_t bootstrap_out_len, char *err,
size_t err_len) {
if (!verified_launcher || !*verified_launcher) {
if (err && err_len) snprintf(err, err_len, "missing staged launcher path");
return -1;
}
if (!bootstrap_out || bootstrap_out_len == 0) {
if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer");
return -1;
}
bootstrap_out[0] = '\0';
const char *base = install_dir ? install_dir : OTA_INSTALL_DIR;
char staged[256];
char bootstrap[256];
char updates[192];
snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME,
OTA_LAUNCHER_STAGED_SUFFIX);
snprintf(updates, sizeof(updates), "%s/updates", base);
snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME);
remove(staged);
if (copy_file(verified_launcher, staged) != 0) {
if (err && err_len) snprintf(err, err_len, "stage launcher failed");
return -1;
}
#if defined(__SWITCH__)
mkdir(updates, 0755);
#endif
if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) {
remove(staged);
if (err && err_len) snprintf(err, err_len, "extract bootstrap failed");
return -1;
}
snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap);
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");