From dc04a4e848b31a3ba1ffd47a9868133c11b895c1 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Thu, 6 Aug 2026 07:54:28 -0300 Subject: [PATCH] Improve Switch OTA flow with download progress and clearer error screens. --- native/switch-ota-launcher/include/ota_net.h | 7 +- native/switch-ota-launcher/include/ota_ui.h | 4 + native/switch-ota-launcher/src/main.c | 62 ++++- native/switch-ota-launcher/src/ota_net.c | 34 ++- native/switch-ota-launcher/src/ota_ui.c | 271 +++++++++++++++---- test/switch-nro-ota.spec.test.js | 13 +- 6 files changed, 321 insertions(+), 70 deletions(-) diff --git a/native/switch-ota-launcher/include/ota_net.h b/native/switch-ota-launcher/include/ota_net.h index eca59864..dcd86916 100644 --- a/native/switch-ota-launcher/include/ota_net.h +++ b/native/switch-ota-launcher/include/ota_net.h @@ -7,13 +7,16 @@ extern "C" { #endif +/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */ +typedef void (*ota_net_progress_fn)(void *userdata, double fraction); + /* 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. */ +/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */ int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, - size_t err_len); + size_t err_len, ota_net_progress_fn progress, void *progress_ud); #ifdef __cplusplus } diff --git a/native/switch-ota-launcher/include/ota_ui.h b/native/switch-ota-launcher/include/ota_ui.h index f46723c4..0ad19e6f 100644 --- a/native/switch-ota-launcher/include/ota_ui.h +++ b/native/switch-ota-launcher/include/ota_ui.h @@ -20,6 +20,10 @@ void ota_ui_show_progress(const char *title, const char *detail, float progress0 /* Error / info + wait for B. */ void ota_ui_alert(const char *title, const char *line1, const char *line2); +/* User-friendly error with optional technical detail and installed-version footer. */ +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version); + /* Missing game install screen; wait for +. */ void ota_ui_missing_game(void); diff --git a/native/switch-ota-launcher/src/main.c b/native/switch-ota-launcher/src/main.c index 7717c9bc..70cc6248 100644 --- a/native/switch-ota-launcher/src/main.c +++ b/native/switch-ota-launcher/src/main.c @@ -32,6 +32,27 @@ #define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME #define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME +typedef struct { + const char *detail; + float base; + float span; +} download_progress_ctx_t; + +static void on_download_progress(void *userdata, double fraction) { + download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata; + if (!ctx) return; + float p = ctx->base; + if (fraction >= 0.0) p += ctx->span * (float)fraction; + else p += ctx->span * 0.1f; + if (p > ctx->base + ctx->span) p = ctx->base + ctx->span; + ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p); +} + +static void show_update_error(const char *title, const char *friendly, const char *technical, + const char *installed) { + ota_ui_alert_error(title, friendly, technical, installed); +} + 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)); @@ -75,27 +96,32 @@ static int run_update_flow(const char *install_dir) { mkdir(updates_dir, 0755); #endif - ota_ui_show_progress("Downloading…", "This may take a minute.", 0.15f); - if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err)) != 0) { - ota_ui_alert("Download failed", "Playing the installed version.", NULL); + download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f}; + ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base); + if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err), + on_download_progress, &dl_ctx) != 0) { + show_update_error("Download failed", + "Could not download the update. Check Wi-Fi and try again.", err, installed); return 0; } - ota_ui_show_progress("Checking update…", "Verifying file integrity.", 0.55f); + ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f); 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) { + if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL, + NULL) != 0) { remove(zip_path); - ota_ui_alert("Could not verify", "Playing the installed version.", NULL); + show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed); return 0; } FILE *sf = fopen(sums_path, "rb"); if (!sf) { remove(zip_path); - ota_ui_shutdown(); + show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed", + installed); return 0; } fseek(sf, 0, SEEK_END); @@ -104,18 +130,22 @@ static int run_update_flow(const char *install_dir) { char *sums = (char *)malloc((size_t)slen + 1); if (!sums) { fclose(sf); - ota_ui_shutdown(); + remove(zip_path); + show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed", + installed); return 0; } fread(sums, 1, (size_t)slen, sf); sums[slen] = '\0'; fclose(sf); + ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f); char hex[96]; if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) { free(sums); remove(zip_path); - ota_ui_shutdown(); + show_update_error("Could not verify", "Could not read downloaded update file.", + "sha256 file read failed", installed); return 0; } ota_verify_t ver; @@ -123,7 +153,8 @@ static int run_update_flow(const char *install_dir) { free(sums); if (!ver.ok) { remove(zip_path); - ota_ui_alert("Update check failed", "Playing the installed version.", NULL); + show_update_error("Update check failed", + "Downloaded file did not match expected checksum.", ver.reason, installed); return 0; } @@ -134,11 +165,12 @@ static int run_update_flow(const char *install_dir) { install_dir); #if defined(__SWITCH__) - ota_ui_show_progress("Installing…", "Keeping your saves safe.", 0.8f); + ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f); 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) { remove(zip_path); - ota_ui_alert("Install failed", "Playing the installed version.", NULL); + show_update_error("Could not extract", + "Update zip is missing game files or is corrupted.", err, installed); return 0; } } @@ -146,7 +178,9 @@ static int run_update_flow(const char *install_dir) { if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) { remove(extracted); remove(zip_path); - ota_ui_alert("Install failed", "Playing the installed version.", NULL); + show_update_error("Could not install", + "Could not replace game on microSD. Free up space and try again.", err, + installed); return 0; } remove(extracted); @@ -168,7 +202,7 @@ 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", "Starting the game...", 1.0f); svcSleepThread(600000000ULL); #else (void)extracted; diff --git a/native/switch-ota-launcher/src/ota_net.c b/native/switch-ota-launcher/src/ota_net.c index 1e0979e6..5ce38bb4 100644 --- a/native/switch-ota-launcher/src/ota_net.c +++ b/native/switch-ota-launcher/src/ota_net.c @@ -15,6 +15,11 @@ struct mem_buf { size_t len; }; +struct file_progress { + ota_net_progress_fn fn; + void *userdata; +}; + 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; @@ -30,6 +35,23 @@ static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) { static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) { return fwrite(ptr, size, nmemb, (FILE *)userdata); } + +static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, + curl_off_t ulnow) { + (void)ultotal; + (void)ulnow; + struct file_progress *fp = (struct file_progress *)clientp; + if (!fp || !fp->fn) return 0; + if (dltotal > 0) { + double frac = (double)dlnow / (double)dltotal; + if (frac < 0.0) frac = 0.0; + if (frac > 1.0) frac = 1.0; + fp->fn(fp->userdata, frac); + } else if (dlnow > 0) { + fp->fn(fp->userdata, -1.0); + } + return 0; +} #endif int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len, @@ -54,7 +76,6 @@ int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t 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); @@ -76,7 +97,7 @@ int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t } int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, - size_t err_len) { + size_t err_len, ota_net_progress_fn progress, void *progress_ud) { if (!url || !path) { if (err && err_len) snprintf(err, err_len, "bad args"); return -1; @@ -93,6 +114,7 @@ int ota_net_download_file(const char *url, const char *path, long timeout_ms, ch if (err && err_len) snprintf(err, err_len, "curl_easy_init failed"); return -1; } + struct file_progress fp_cb = {progress, progress_ud}; curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota"); @@ -102,6 +124,11 @@ int ota_net_download_file(const char *url, const char *path, long timeout_ms, ch curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + if (progress) { + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb); + } CURLcode rc = curl_easy_perform(curl); curl_easy_cleanup(curl); fclose(fp); @@ -109,9 +136,12 @@ int ota_net_download_file(const char *url, const char *path, long timeout_ms, ch if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc)); return -1; } + if (progress) progress(progress_ud, 1.0); return 0; #else (void)timeout_ms; + (void)progress; + (void)progress_ud; if (err && err_len) snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__"); return -1; diff --git a/native/switch-ota-launcher/src/ota_ui.c b/native/switch-ota-launcher/src/ota_ui.c index cfe00ec9..e11262db 100644 --- a/native/switch-ota-launcher/src/ota_ui.c +++ b/native/switch-ota-launcher/src/ota_ui.c @@ -21,7 +21,13 @@ #define FB_W 1280 #define FB_H 720 -/* Theme.PAL (0–255) → RGBA8 */ +#define TITLE_Y 200 +#define LINE_TITLE 44 +#define LINE_BODY 36 +#define LINE_SMALL 28 +#define WRAP_MAX_PX 720 + +/* Theme.PAL (0-255) -> RGBA8 */ #define COL_BG RGBA8_MAXALPHA(0, 0, 0) #define COL_INK RGBA8_MAXALPHA(255, 255, 255) #define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200) @@ -60,9 +66,54 @@ static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 co } } +/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */ +static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) { + if (!dst || dst_len == 0) return 0; + dst[0] = '\0'; + if (!src) return 0; + size_t w = 0; + for (size_t i = 0; src[i] && w + 1 < dst_len;) { + unsigned char c = (unsigned char)src[i]; + if (c < 0x80) { + dst[w++] = (char)c; + i++; + continue; + } + if (c == 0xE2 && src[i + 1] && src[i + 2]) { + unsigned char b2 = (unsigned char)src[i + 1]; + unsigned char b3 = (unsigned char)src[i + 2]; + if (b2 == 0x80 && b3 == 0xA6) { + if (w + 3 < dst_len) { + dst[w++] = '.'; + dst[w++] = '.'; + dst[w++] = '.'; + } + i += 3; + continue; + } + if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) { + dst[w++] = '-'; + i += 3; + continue; + } + if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) { + dst[w++] = '\''; + i += 3; + continue; + } + } + if ((c & 0xE0) == 0xC0) i += 2; + else if ((c & 0xF0) == 0xE0) i += 3; + else if ((c & 0xF8) == 0xF0) i += 4; + else i++; + } + dst[w] = '\0'; + return w; +} + static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) { unsigned char c = (unsigned char)ch; - if (c > 127) c = '?'; + if (c > 127) return; const char *bits = font8x8_basic[c]; for (int row = 0; row < 8; row++) { unsigned char line = (unsigned char)bits[row]; @@ -81,21 +132,100 @@ static int text_width(const char *s, int scale) { static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) { if (!s) return; + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); int cx = x; - for (; *s; s++) { - if (*s == '\n') { + for (const char *p = buf; *p; p++) { + if (*p == '\n') { cx = x; y += 8 * scale + 4; continue; } - draw_glyph(fb, stride_px, cx, y, *s, color, scale); + draw_glyph(fb, stride_px, cx, y, *p, color, scale); cx += 8 * scale; } } static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) { - int w = text_width(s, scale); - draw_text(fb, stride_px, (FB_W - w) / 2, y, s, color, scale); + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); + int w = text_width(buf, scale); + draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale); +} + +static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, + int scale, int max_px) { + if (!s || !*s) return y; + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); + int char_w = 8 * scale; + int max_chars = max_px / char_w; + if (max_chars < 8) max_chars = 8; + + char line[128]; + int line_len = 0; + const char *word = buf; + int lines = 0; + + while (*word) { + while (*word == ' ') word++; + if (!*word) break; + + const char *end = word; + while (*end && *end != ' ' && *end != '\n') end++; + int wlen = (int)(end - word); + + if (line_len > 0 && line_len + 1 + wlen > max_chars) { + line[line_len] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + } + + if (wlen > max_chars) { + if (line_len > 0) { + line[line_len] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + } + while (wlen > 0) { + int chunk = wlen > max_chars ? max_chars : wlen; + memcpy(line, word, (size_t)chunk); + line[chunk] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + word += chunk; + wlen -= chunk; + } + word = end; + continue; + } + + if (line_len > 0) line[line_len++] = ' '; + memcpy(line + line_len, word, (size_t)wlen); + line_len += wlen; + line[line_len] = '\0'; + word = (*end == '\n') ? end + 1 : end; + if (*(end - 1) == '\n' || *end == '\n') { + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + if (*end == '\n') word = end + 1; + } + } + + if (line_len > 0) { + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + } + if (lines == 0) return y; + return y; } static void draw_rail(u32 *fb, u32 stride_px) { @@ -145,7 +275,6 @@ static void load_logo(void) { } for (int i = 0; i < w * h; i++) { unsigned char *p = data + i * 4; - /* Premultiply-ish: keep RGB, store A in high byte for skip */ g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]); } g_logo_w = w; @@ -178,7 +307,6 @@ static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 const char *label, int focused) { fill_rect(fb, stride_px, x, y, w, h, fill); if (focused) { - /* 2px white focus ring (launcher focus outline) */ fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK); fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK); fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK); @@ -190,10 +318,17 @@ static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE); } int scale = 3; - int tw = text_width(label, scale); + char lbl[128]; + ota_ui_sanitize_ascii(label, lbl, sizeof(lbl)); + int max_tw = w - 24; + int tw = text_width(lbl, scale); + while (scale > 2 && tw > max_tw) { + scale--; + tw = text_width(lbl, scale); + } int tx = x + (w - tw) / 2; int ty = y + (h - 8 * scale) / 2; - draw_text(fb, stride_px, tx, ty, label, ink, scale); + draw_text(fb, stride_px, tx, ty, lbl, ink, scale); } static void draw_chrome(u32 *fb, u32 stride_px) { @@ -231,25 +366,25 @@ static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) { prompt_ctx_t *p = (prompt_ctx_t *)ctx; draw_chrome(fb, stride_px); - int y = 180; + int y = TITLE_Y - 20; draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3); - y += 48; + y += LINE_TITLE; char line[96]; - snprintf(line, sizeof(line), "v%s -> v%s", p->installed ? p->installed : "?", + snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?", p->latest ? p->latest : "?"); draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3); - y += 56; + y += LINE_TITLE + 8; draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2); - y += 64; + y += LINE_BODY + 28; - int bw = 420; + int bw = 600; int bh = 64; int bx = (FB_W - bw) / 2; draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "A Update", p->focus == 0); y += bh + 24; - draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "B Play without updating", + draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "B Play without update", p->focus == 1); } @@ -285,31 +420,32 @@ typedef struct { int show_bar; } status_ctx_t; +static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) { + int bw = 520; + int bh = 18; + int bx = (FB_W - bw) / 2; + if (t < 0.f) t = 0.f; + if (t > 1.f) t = 1.f; + fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED); + fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE); + fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE); + fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE); + fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE); + int fw = (int)(bw * t); + if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN); +} + static void paint_status(u32 *fb, u32 stride_px, void *ctx) { status_ctx_t *s = (status_ctx_t *)ctx; draw_chrome(fb, stride_px); - int y = 200; + int y = TITLE_Y; draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3); - y += 48; + y += LINE_TITLE; if (s->detail && s->detail[0]) { - draw_text_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2); - y += 40; - } - if (s->show_bar) { - int bw = 520; - int bh = 18; - int bx = (FB_W - bw) / 2; - float t = s->progress; - if (t < 0.f) t = 0.f; - if (t > 1.f) t = 1.f; - fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED); - fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE); - fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE); - fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE); - fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE); - int fw = (int)(bw * t); - if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN); + y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_SMALL; } + if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress); } void ota_ui_show_status(const char *title, const char *detail) { @@ -326,18 +462,24 @@ typedef struct { const char *title; const char *line1; const char *line2; + const char *line3; } alert_ctx_t; static void paint_alert(u32 *fb, u32 stride_px, void *ctx) { alert_ctx_t *a = (alert_ctx_t *)ctx; draw_chrome(fb, stride_px); - int y = 200; + int y = TITLE_Y; draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3); - y += 52; - if (a->line1) draw_text_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2); - y += 36; - if (a->line2) draw_text_centered(fb, stride_px, y, a->line2, COL_MUTED, 2); - y += 64; + y += LINE_TITLE; + if (a->line1 && a->line1[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_SMALL; + if (a->line2 && a->line2[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX); + y += LINE_SMALL; + if (a->line3 && a->line3[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX); + y += LINE_BODY + 16; int bw = 360; int bh = 56; draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "B Continue", 1); @@ -345,7 +487,30 @@ static void paint_alert(u32 *fb, u32 stride_px, void *ctx) { void ota_ui_alert(const char *title, const char *line1, const char *line2) { if (!ui_ensure()) return; - alert_ctx_t a = {title, line1, line2}; + alert_ctx_t a = {title, line1, line2, NULL}; + while (appletMainLoop()) { + present(paint_alert, &a); + padUpdate(&g_pad); + if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break; + svcSleepThread(16000000ULL); + } + ota_ui_shutdown(); +} + +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version) { + if (!ui_ensure()) return; + char footer[96]; + snprintf(footer, sizeof(footer), "Playing installed version (v%s).", + installed_version && installed_version[0] ? installed_version : "?"); + char tech[256]; + if (technical && technical[0]) { + ota_ui_sanitize_ascii(technical, tech, sizeof(tech)); + if (strlen(tech) > 80) tech[80] = '\0'; + } else { + tech[0] = '\0'; + } + alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer}; while (appletMainLoop()) { present(paint_alert, &a); padUpdate(&g_pad); @@ -358,13 +523,13 @@ void ota_ui_alert(const char *title, const char *line1, const char *line2) { static void paint_missing(u32 *fb, u32 stride_px, void *ctx) { (void)ctx; draw_chrome(fb, stride_px); - int y = 190; + int y = TITLE_Y - 10; draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3); - y += 52; - draw_text_centered(fb, stride_px, y, "Copy the Switch zip onto your microSD,", COL_DETAIL, 2); - y += 32; - draw_text_centered(fb, stride_px, y, "then open gen1recomp again.", COL_DETAIL, 2); - y += 64; + y += LINE_TITLE; + y = draw_text_wrapped_centered(fb, stride_px, y, + "Copy the Switch zip onto your microSD, then open gen1recomp again.", + COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_BODY + 16; int bw = 320; int bh = 56; draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "+ Exit", 1); @@ -399,6 +564,12 @@ void ota_ui_alert(const char *title, const char *line1, const char *line2) { fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "", line2 ? line2 : ""); } +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version) { + fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "", + friendly ? friendly : "", technical ? technical : "", + installed_version ? installed_version : "?"); +} void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); } void ota_ui_shutdown(void) {} diff --git a/test/switch-nro-ota.spec.test.js b/test/switch-nro-ota.spec.test.js index 5103bd22..3c56cdd9 100644 --- a/test/switch-nro-ota.spec.test.js +++ b/test/switch-nro-ota.spec.test.js @@ -500,10 +500,19 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () = 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, /ota_ui_prompt_update|ota_ui_show_progress/); + assert.match(main, /ota_ui_alert_error/); + assert.match(main, /Step 1\/3: Downloading/); + assert.doesNotMatch(main, /\u2026/, 'OTA UI strings must be ASCII (no Unicode ellipsis)'); + assert.doesNotMatch(main, /ota_ui_alert\([^)]*Install failed/, 'generic install alert removed'); assert.doesNotMatch(main, /consoleInit/, 'no terminal console UI'); assert.ok(fs.existsSync(path.join(root, 'native/switch-ota-launcher/src/ota_ui.c'))); - assert.match(read('native/switch-ota-launcher/src/ota_ui.c'), /COL_RAIL_R|rail|FFD600|255,\s*214/); - assert.match(read('native/switch-ota-launcher/src/ota_ui.c'), /logo\.png|stb_image/); + const otaUi = read('native/switch-ota-launcher/src/ota_ui.c'); + assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/); + assert.match(otaUi, /logo\.png|stb_image/); + assert.match(otaUi, /ota_ui_sanitize_ascii/); + assert.match(otaUi, /draw_text_wrapped_centered/); + assert.match(otaUi, /ota_ui_alert_error/); + assert.match(read('native/switch-ota-launcher/src/ota_net.c'), /XFERINFOFUNCTION|xfer_progress/); assert.match(read('native/switch-ota-launcher/Makefile'), /^ROMFS\s*:=/m); const mk = read('native/switch-ota-launcher/Makefile');