From 35e2489d8087f3fcf1848ba37877767adbff2c68 Mon Sep 17 00:00:00 2001 From: bps Date: Thu, 15 Oct 2026 13:09:38 -0300 Subject: [PATCH 1/2] Add a phase 0 diagnostic build of lineupd (/diag, /diag/trace) --- app/payloads/lineupd.c | 353 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 336 insertions(+), 17 deletions(-) diff --git a/app/payloads/lineupd.c b/app/payloads/lineupd.c index 6b49549..9bf8296 100644 --- a/app/payloads/lineupd.c +++ b/app/payloads/lineupd.c @@ -15,6 +15,12 @@ // in the new order, with the index the client read // POST /quit X-Lineup: 1; exits after answering // +// Built with -DLINEUPD_DIAG (port 9078, LAN), also: +// GET /diag avatars, homes, mounts, install candidates, SQLite version and strings +// POST /diag/trace?user=<10 dig> X-Lineup: 1; backup, then log-only triggers on tbl_iconinfo_ +// GET /diag/trace the trace rows and the triggers in place +// DELETE /diag/trace X-Lineup: 1; drops every lineup_trace trigger and the table +// // Built with -DLINEUPD_TEST it compiles on a desktop and runs a self-check against a copy of app.db, // or a synthetic database without arguments. #include @@ -33,6 +39,10 @@ #include #include #include "sqlite3.h" +#ifdef LINEUPD_DIAG +#include +#include +#endif #ifdef LINEUPD_TEST #include @@ -43,7 +53,14 @@ #endif #define LINEUPD_VERSION "1.1.0" +#ifdef LINEUPD_DIAG +// Phase 0 diagnostic build (docs/pt-BR/PLANO-1.2.md): its own port, open to the LAN, so it runs beside the +// app's lineupd and still answers when the home screen does not. +#define PORT 9078 +#define LINEUPD_LAN +#else #define PORT 9077 +#endif #define MAX_HEADER (16 * 1024) #define MAX_BODY (64 * 1024) #define MAX_GAMES 512 @@ -258,32 +275,36 @@ static void make_dirs(const char* path) { mkdir(partial, 0777); } -// Saves `posted` as the new order. Returns the HTTP status; `message` explains a failure. -static int save_order(const char* user, const Row* posted, int posted_count, char* backup, size_t backup_size, - char* integrity, size_t integrity_size, char* message, size_t message_size) { - sqlite3* db = open_db(1); - if (!db) { snprintf(message, message_size, "Could not open the home screen database."); return 500; } - int status = 500; +// Checks the database and copies it to a new file in backup_dir. Returns 0, or -1 with `message` set. +static int check_and_backup(sqlite3* db, char* backup, size_t backup_size, char* message, size_t message_size) { char check[128]; - static Row current[MAX_GAMES]; - static long long indices[MAX_GAMES]; - if (!table_exists(db, user)) { snprintf(message, message_size, "No such profile."); status = 404; goto done; } if (pragma_text(db, "PRAGMA quick_check", check, sizeof check)) { snprintf(message, message_size, "The home screen database failed its check (%s). Nothing was saved.", check); - goto done; + return -1; } make_dirs(backup_dir); snprintf(backup, backup_size, "%s/app-%010lld.db", backup_dir, (long long)time(NULL)); unlink(backup); - { - char sql[600]; - snprintf(sql, sizeof sql, "VACUUM INTO '%s'", backup); - if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) { - snprintf(message, message_size, "The backup failed (%s). Nothing was saved.", sqlite3_errmsg(db)); - goto done; - } + char sql[600]; + snprintf(sql, sizeof sql, "VACUUM INTO '%s'", backup); + if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) { + snprintf(message, message_size, "The backup failed (%s). Nothing was saved.", sqlite3_errmsg(db)); + return -1; } rotate_backups(); + return 0; +} + +// Saves `posted` as the new order. Returns the HTTP status; `message` explains a failure. +static int save_order(const char* user, const Row* posted, int posted_count, char* backup, size_t backup_size, + char* integrity, size_t integrity_size, char* message, size_t message_size) { + sqlite3* db = open_db(1); + if (!db) { snprintf(message, message_size, "Could not open the home screen database."); return 500; } + int status = 500; + static Row current[MAX_GAMES]; + static long long indices[MAX_GAMES]; + if (!table_exists(db, user)) { snprintf(message, message_size, "No such profile."); status = 404; goto done; } + if (check_and_backup(db, backup, backup_size, message, message_size)) goto done; if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { snprintf(message, message_size, "The database is busy (%s). Try again.", sqlite3_errmsg(db)); goto done; @@ -492,6 +513,293 @@ static const char* query_value(const char* query, const char* name, char* out, s return NULL; } +#ifdef LINEUPD_DIAG + +// ---- Phase 0 diagnostics ---- + +static void diag_error(Buffer* b, const char* message) { + puts_(b, "{\"error\":"); + put_json_string(b, message); + puts_(b, "}"); +} + +// A directory as [{"name","size","mtime","dir"}], `depth` levels deep, at most 200 entries a level. +static void diag_list(Buffer* b, const char* path, int depth) { + DIR* dir = opendir(path); + if (!dir) { diag_error(b, strerror(errno)); return; } + puts_(b, "["); + int count = 0; + for (struct dirent* entry; (entry = readdir(dir)) && count < 200;) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + char child[512]; + struct stat info; + snprintf(child, sizeof child, "%s/%s", path, entry->d_name); + int ok = !stat(child, &info); + puts_(b, count++ ? ",{\"name\":" : "{\"name\":"); + put_json_string(b, entry->d_name); + if (ok) putf(b, ",\"size\":%lld,\"mtime\":%lld", (long long)info.st_size, (long long)info.st_mtime); + if (ok && S_ISDIR(info.st_mode)) { + puts_(b, ",\"dir\":"); + if (depth > 1) diag_list(b, child, depth - 1); else puts_(b, "true"); + } + puts_(b, "}"); + } + closedir(dir); + puts_(b, "]"); +} + +// Each result row as an object keyed by column name. +static void diag_query(Buffer* b, sqlite3* db, const char* sql) { + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) != SQLITE_OK) { diag_error(b, sqlite3_errmsg(db)); return; } + puts_(b, "["); + for (int row = 0; sqlite3_step(statement) == SQLITE_ROW; ++row) { + puts_(b, row ? ",{" : "{"); + for (int column = 0; column < sqlite3_column_count(statement); ++column) { + if (column) puts_(b, ","); + put_json_string(b, sqlite3_column_name(statement, column)); + puts_(b, ":"); + int type = sqlite3_column_type(statement, column); + if (type == SQLITE_INTEGER) putf(b, "%lld", (long long)sqlite3_column_int64(statement, column)); + else if (type == SQLITE_NULL) puts_(b, "null"); + else put_json_string(b, (const char*)sqlite3_column_text(statement, column)); + } + puts_(b, "}"); + } + sqlite3_finalize(statement); + puts_(b, "]"); +} + +// Lineup sources ShadowMountPlus could pick up under `root`: a PPSA99077 folder or a Lineup-*.exfat image. +static void diag_candidates(Buffer* b, const char* root, int* first) { + DIR* dir = opendir(root); + if (!dir) return; + for (struct dirent* entry; (entry = readdir(dir));) { + size_t length = strlen(entry->d_name); + if (strcmp(entry->d_name, "PPSA99077") && + (strncmp(entry->d_name, "Lineup-", 7) || length < 6 || strcmp(entry->d_name + length - 6, ".exfat"))) continue; + char path[512]; + struct stat info; + snprintf(path, sizeof path, "%s/%s", root, entry->d_name); + puts_(b, *first ? "{\"path\":" : ",{\"path\":"); + *first = 0; + put_json_string(b, path); + if (!stat(path, &info)) putf(b, ",\"size\":%lld,\"mtime\":%lld,\"dir\":%s", (long long)info.st_size, + (long long)info.st_mtime, S_ISDIR(info.st_mode) ? "true" : "false"); + puts_(b, "}"); + } + closedir(dir); +} + +// Strings in a system SQLite module that tell its version and build options; empty when it is encrypted. +static void diag_sqlite_module(Buffer* b, const char* path) { + int file = open(path, O_RDONLY); + struct stat info; + if (file < 0 || fstat(file, &info) || info.st_size <= 0 || info.st_size > 32 * 1024 * 1024) { + if (file >= 0) close(file); + diag_error(b, file < 0 ? strerror(errno) : "unexpected size"); + return; + } + unsigned char* data = malloc((size_t)info.st_size); + ssize_t size = data ? read(file, data, (size_t)info.st_size) : -1; + close(file); + if (size < 4) { free(data); diag_error(b, "read failed"); return; } + putf(b, "{\"size\":%lld,\"magic\":\"%02x%02x%02x%02x\",\"strings\":[", (long long)size, data[0], data[1], data[2], data[3]); + int found = 0; + for (ssize_t start = 0, i = 0; i <= size && found < 150; ++i) { + if (i < size && data[i] >= 0x20 && data[i] < 0x7f) continue; + ssize_t length = i - start; + char text[128]; + if (length >= 4 && length < (ssize_t)sizeof text) { + memcpy(text, data + start, (size_t)length); + text[length] = 0; + int version = text[0] == '3' && text[1] == '.' && strspn(text, "0123456789.") == (size_t)length; + int source_id = length > 50 && !strncmp(text, "20", 2) && text[4] == '-' && text[10] == ' '; + if (version || source_id || strstr(text, "OMIT_") || strstr(text, "ENABLE_") || strstr(text, "THREADSAFE")) { + puts_(b, found++ ? "," : ""); + put_json_string(b, text); + } + } + start = i + 1; + } + puts_(b, "]}"); + free(data); +} + +static void get_diag(int socket) { + Buffer b = {0}; + puts_(&b, "{\"version\":\"" LINEUPD_VERSION "-diag\",\"profileCache\":"); + diag_list(&b, "/system_data/priv/cache/profile", 2); + puts_(&b, ",\"homes\":"); + diag_list(&b, home_dir, 2); + + puts_(&b, ",\"mounts\":"); + struct statfs* mounts; + int count = getmntinfo(&mounts, MNT_NOWAIT); + if (count <= 0) diag_error(&b, strerror(errno)); + else { + puts_(&b, "["); + for (int i = 0; i < count; ++i) { + puts_(&b, i ? ",{\"from\":" : "{\"from\":"); + put_json_string(&b, mounts[i].f_mntfromname); + puts_(&b, ",\"on\":"); + put_json_string(&b, mounts[i].f_mntonname); + puts_(&b, ",\"type\":"); + put_json_string(&b, mounts[i].f_fstypename); + putf(&b, ",\"flags\":%llu}", (unsigned long long)mounts[i].f_flags); + } + puts_(&b, "]"); + } + + puts_(&b, ",\"candidates\":["); + int first = 1; + diag_candidates(&b, "/data/homebrew", &first); + diag_candidates(&b, "/data/etaHEN/games", &first); + for (int i = 0; i < 8; ++i) { + char root[64]; + snprintf(root, sizeof root, "/mnt/usb%d/homebrew", i); + diag_candidates(&b, root, &first); + snprintf(root, sizeof root, "/mnt/ext%d/homebrew", i); + diag_candidates(&b, root, &first); + } + puts_(&b, "],\"shadowmnt\":"); + diag_list(&b, "/mnt/shadowmnt", 1); + + // The database header records the SQLite version that last wrote it: the home screen's own SQLite. + puts_(&b, ",\"appDb\":"); + unsigned char header[100]; + int file = open(db_path, O_RDONLY); + if (file < 0 || read(file, header, sizeof header) != (ssize_t)sizeof header) diag_error(&b, strerror(errno)); + else { + unsigned writer = (unsigned)header[96] << 24 | header[97] << 16 | header[98] << 8 | header[99]; + unsigned valid = (unsigned)header[92] << 24 | header[93] << 16 | header[94] << 8 | header[95]; + putf(&b, "{\"lastWriterSqlite\":%u,\"versionValidFor\":%u,\"writeFormat\":%d,\"readFormat\":%d,\"schema\":", + writer, valid, header[18], header[19]); + sqlite3* db = open_db(0); + if (!db) diag_error(&b, "open failed"); + else { + diag_query(&b, db, "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE type IN ('trigger','view') OR name LIKE 'lineup%'"); + puts_(&b, ",\"journalMode\":"); + diag_query(&b, db, "PRAGMA journal_mode"); + sqlite3_close(db); + } + puts_(&b, "}"); + } + if (file >= 0) close(file); + + puts_(&b, ",\"sqliteModules\":{"); + const char* lib_dirs[] = {"/system/common/lib", "/system/priv/lib", "/system_ex/common_ex/lib", "/system/vsh"}; + first = 1; + for (size_t d = 0; d < sizeof lib_dirs / sizeof *lib_dirs; ++d) { + DIR* dir = opendir(lib_dirs[d]); + for (struct dirent* entry; dir && (entry = readdir(dir));) { + if (!strcasestr(entry->d_name, "sqlite")) continue; + char path[512]; + snprintf(path, sizeof path, "%s/%s", lib_dirs[d], entry->d_name); + if (!first) puts_(&b, ","); + first = 0; + put_json_string(&b, path); + puts_(&b, ":"); + diag_sqlite_module(&b, path); + } + if (dir) closedir(dir); + } + puts_(&b, "}}"); + respond_json(socket, 200, &b); + free(b.data); +} + +// Log-only triggers: they record what the system writes to the row and change nothing else. +static void post_trace(int socket, const char* user) { + sqlite3* db = open_db(1); + if (!db) { respond_error(socket, 500, "Could not open the home screen database."); return; } + char backup[256], message[300], sql[1600]; + if (!table_exists(db, user)) { sqlite3_close(db); respond_error(socket, 404, "No such profile."); return; } + if (check_and_backup(db, backup, sizeof backup, message, sizeof message)) { + sqlite3_close(db); + respond_error(socket, 500, message); + return; + } + snprintf(sql, sizeof sql, + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS lineup_trace (op TEXT, titleId TEXT, beforeIndex INTEGER, afterIndex INTEGER, at INTEGER);" + "CREATE TRIGGER lineup_trace_u_%1$s AFTER UPDATE ON tbl_iconinfo_%1$s BEGIN INSERT INTO lineup_trace " + "VALUES ('U', NEW.titleId, OLD.lastAccessIndex, NEW.lastAccessIndex, strftime('%%s','now')); END;" + "CREATE TRIGGER lineup_trace_i_%1$s AFTER INSERT ON tbl_iconinfo_%1$s BEGIN INSERT INTO lineup_trace " + "VALUES ('I', NEW.titleId, NULL, NEW.lastAccessIndex, strftime('%%s','now')); END;" + "CREATE TRIGGER lineup_trace_d_%1$s AFTER DELETE ON tbl_iconinfo_%1$s BEGIN INSERT INTO lineup_trace " + "VALUES ('D', OLD.titleId, OLD.lastAccessIndex, NULL, strftime('%%s','now')); END;" + "COMMIT;", user); + char* error = NULL; + if (sqlite3_exec(db, sql, NULL, NULL, &error) != SQLITE_OK) { + snprintf(message, sizeof message, "Could not install the trace (%s). Nothing changed.", error ? error : "?"); + sqlite3_free(error); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + sqlite3_close(db); + respond_error(socket, 409, message); + return; + } + sqlite3_close(db); + LOG("trace on for %s, backup %s", user, backup); + Buffer reply = {0}; + puts_(&reply, "{\"ok\":true,\"backup\":"); + put_json_string(&reply, backup); + puts_(&reply, "}"); + respond_json(socket, 200, &reply); + free(reply.data); +} + +static void get_trace(int socket) { + sqlite3* db = open_db(0); + if (!db) { respond_error(socket, 500, "Could not open the home screen database."); return; } + Buffer b = {0}; + puts_(&b, "{\"triggers\":"); + diag_query(&b, db, "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'lineup_trace_%'"); + puts_(&b, ",\"rows\":"); + diag_query(&b, db, "SELECT rowid AS n, op, titleId, beforeIndex, afterIndex, at FROM lineup_trace ORDER BY rowid DESC LIMIT 500"); + puts_(&b, "}"); + sqlite3_close(db); + respond_json(socket, 200, &b); + free(b.data); +} + +static void delete_trace(int socket) { + sqlite3* db = open_db(1); + if (!db) { respond_error(socket, 500, "Could not open the home screen database."); return; } + static char names[64][64]; + int count = 0; + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'lineup_trace_%'", + -1, &statement, NULL) == SQLITE_OK) { + while (count < 64 && sqlite3_step(statement) == SQLITE_ROW) + snprintf(names[count++], sizeof names[0], "%s", (const char*)sqlite3_column_text(statement, 0)); + sqlite3_finalize(statement); + } + int failed = sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK; + for (int i = 0; !failed && i < count; ++i) { + char sql[128]; + snprintf(sql, sizeof sql, "DROP TRIGGER \"%s\"", names[i]); + failed = sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK; + } + if (!failed) failed = sqlite3_exec(db, "DROP TABLE IF EXISTS lineup_trace; COMMIT", NULL, NULL, NULL) != SQLITE_OK; + if (failed) { + char message[300]; + snprintf(message, sizeof message, "Could not remove the trace (%s).", sqlite3_errmsg(db)); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + sqlite3_close(db); + respond_error(socket, 500, message); + return; + } + sqlite3_close(db); + LOG("trace off, %d triggers dropped", count); + Buffer reply = {0}; + putf(&reply, "{\"ok\":true,\"dropped\":%d}", count); + respond_json(socket, 200, &reply); + free(reply.data); +} + +#endif + // Handles one request. Returns 1 when the service should exit. static int handle(int socket) { static char request[MAX_HEADER + MAX_BODY + 1]; @@ -546,6 +854,17 @@ static int handle(int socket) { else post_row(socket, user, body); } else if (get && !strncmp(target, "/icon/", 6)) { get_icon(socket, target + 6); +#ifdef LINEUPD_DIAG + } else if (get && !strcmp(target, "/diag")) { + get_diag(socket); + } else if (!strcmp(target, "/diag/trace")) { + if (get) get_trace(socket); + else if (!authorized) respond_error(socket, 403, "Missing the X-Lineup header."); + else if (!strcmp(method, "DELETE")) delete_trace(socket); + else if (!post) respond_error(socket, 405, "Use GET, POST or DELETE."); + else if (!query_value(query, "user", user, sizeof user) || !is_user_id(user)) respond_error(socket, 400, "user must be 10 digits."); + else post_trace(socket, user); +#endif } else if (post && !strcmp(target, "/quit")) { if (!authorized) { respond_error(socket, 403, "Missing the X-Lineup header."); return 0; } Buffer reply = {0}; From 11506e1e7a32e0c5c8c3bddd891b36c63d1d3330 Mon Sep 17 00:00:00 2001 From: bps Date: Thu, 15 Oct 2026 13:30:31 -0300 Subject: [PATCH 2/2] WIP: profile photos and in-app updater (in progress, not built yet) --- CHANGELOG.md | 10 + README.md | 6 +- README.pt-BR.md | 6 +- app/api.js | 147 ++++++++++- app/app.json | 2 +- app/i18n.js | 64 ++++- app/index.jsx | 127 ++++++++- app/payloads/lineupd.c | 490 ++++++++++++++++++++++++++++++++++- app/ui.jsx | 43 ++- docs/ARCHITECTURE.md | 31 ++- docs/pt-BR/ARQUITETURA.md | 32 ++- ps5-react/docs/NATIVE-API.md | 2 +- ps5-react/tools/build_ps5.py | 3 +- scripts/package.sh | 22 +- 14 files changed, 934 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1fa96..ecb8bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.2.0 + +- Profile pictures: in the header, under Options, and next to each name in Switch profile. A profile + without one shows its initial. +- Updates from the app: Lineup checks for a new release once a day, shows its notes and installs it + when you choose Update, then opens again. Options → Updates turns the daily check off, checks now, + or goes back to the previous version. Works for folder and exFAT image installs; updating from 1.1.0 + still needs a manual install. +- Releases also ship `update.json`, the manifest the app reads. + ## 1.1.0 First public release. diff --git a/README.md b/README.md index 935b73a..78d3fec 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,16 @@ out on the TV. Its local service then writes the new order where the home screen - **Lift, move, drop.** ✕ lifts the focused game, the D-pad carries it and ✕ drops it. ◯ while carrying puts it back. -- **Every profile.** Each console profile has its own row. Switch from the Options menu. +- **Every profile.** Each console profile has its own row, shown with its picture. Switch from the + Options menu. - **Home screen edge.** The home screen shows only the first 12 games. A divider in the row and a mark on the ruler show where that edge falls, and games past it read "not on the home screen". - **Sort A to Z**, discard changes, and a count of the games that moved. - **Applies on exit.** Saving closes the app, and the home screen comes back with the new order. No restart needed. +- **Updates in the app.** Lineup checks for a new release once a day (Options → Updates turns that off), + downloads it when you choose **Update**, and opens again on the new version. Options → Updates can + also go back to the previous version. This works from 1.2.0 on. - **English and Brazilian Portuguese**, chosen from the console's system language. ![The Options menu](docs/images/options.png) diff --git a/README.pt-BR.md b/README.pt-BR.md index f98e5e6..45a441b 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -15,13 +15,17 @@ ordem na TV. Depois, o serviço local dele grava a nova ordem onde a tela inicia - **Levantar, mover, soltar.** ✕ levanta o jogo em foco, o direcional leva e ✕ solta. ◯ durante o movimento devolve o jogo ao lugar. -- **Todos os perfis.** Cada perfil do console tem sua fileira. Troque pelo menu Opções. +- **Todos os perfis.** Cada perfil do console tem sua fileira, mostrada com a foto dele. Troque pelo + menu Opções. - **Limite da tela inicial.** A tela inicial mostra só os 12 primeiros jogos. Um traço na fileira e uma marca na régua mostram onde fica esse limite, e os jogos depois dele aparecem como "fora da tela inicial". - **Ordenar de A a Z**, descartar mudanças e contagem de jogos que mudaram de lugar. - **Aplica ao sair.** Salvar fecha o app, e a tela inicial volta com a nova ordem. Não precisa reiniciar. +- **Atualização no app.** O Lineup procura uma versão nova uma vez por dia (Opções → Atualizações + desliga isso), baixa quando você escolhe **Atualizar** e abre de novo na versão nova. Em Opções → + Atualizações também dá para voltar à versão anterior. Vale a partir da 1.2.0. - **Inglês e português do Brasil**, escolhidos pelo idioma do sistema. ![O menu Opções](docs/images/options.png) diff --git a/app/api.js b/app/api.js index 880f364..5feb4e9 100644 --- a/app/api.js +++ b/app/api.js @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Talks to lineupd (payloads/lineupd.c) on 127.0.0.1:9077 and starts it when it is not running. -import {Http, Payloads} from '@ps5-react/core'; +import {Archives, Downloads, FileSystem, Http, Payloads} from '@ps5-react/core'; import {T} from './i18n.js'; -export const VERSION = '1.1.0'; +export const VERSION = '1.2.0'; const BASE = 'http://127.0.0.1:9077'; /** An error the app shows as is: a short title and what to do about it. */ @@ -38,7 +38,7 @@ async function call(path, {method = 'GET', body, timeoutMs} = {}) { try { const response = await Http.request(BASE + path, { method, body, maxBytes: 1024 * 1024, signal: limit?.signal, - headers: method === 'POST' ? {'X-Lineup': '1', 'Content-Type': 'text/plain; charset=utf-8'} : undefined, + headers: method === 'POST' ? {'X-Lineup': '1', 'Content-Type': 'text/plain; charset=utf-8'} : {'X-Lineup': '1'}, }); let data = {}; try { @@ -99,6 +99,7 @@ async function read(path, what) { export const profiles = async () => (await read('/profiles', T.service.profiles)).profiles ?? []; export const row = async user => (await read(`/row?user=${user}`, T.service.row)).games ?? []; export const iconUrl = game => `${BASE}/icon/${game.titleId}?m=${game.icon}`; +export const avatarUrl = profile => `${BASE}/avatar/${profile.userId}?m=${profile.avatar}`; /** * Saves `order` (titleIds) for `user`, sending each game's index as it was read. Resolves to @@ -117,3 +118,143 @@ export async function save(user, order, games) { const detail = reply.data.error ? ` (${reply.data.error})` : ''; return {ok: false, stale: false, message: T.service.saveStatus(reply.status) + detail}; } + +// ---- Updates ---- + +const UPDATE_DIR = '/data/lineup/update'; +const SETTINGS = '/data/lineup/settings.json'; +const MANIFEST = 'https://github.com/bps2414/lineup/releases/latest/download/update.json'; +const RELEASES = 'https://github.com/bps2414/lineup/releases/download'; + +// "1.2.0", or param.json's "01.002.000", as one comparable number; -1 when malformed. +const versionNumber = text => (/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(text ?? '') + ? text.split('.').reduce((n, part) => n * 1000 + Number(part), 0) : -1); + +export function settings() { + try { + return {checkOnOpen: true, lastCheck: 0, ...JSON.parse(FileSystem.readFile(SETTINGS))}; + } catch { + return {checkOnOpen: true, lastCheck: 0}; + } +} + +export function saveSettings(next) { + try { + FileSystem.mkdir('/data/lineup', {recursive: true}); + FileSystem.writeFile(SETTINGS, JSON.stringify(next)); + } catch { + // Settings that do not stick are not worth stopping for. + } +} + +/** The release manifest when it offers a version newer than this one, else null. Throws when it cannot be read. */ +export async function checkUpdate() { + const limit = deadline(20000); + let response; + let manifest; + try { + response = await Http.request(MANIFEST, {maxBytes: 64 * 1024, signal: limit.signal}); + if (response.status === 404) return null; + manifest = response.ok ? await response.json() : null; + } catch { + throw new Error(T.update.offline); + } finally { + limit.clear(); + } + if (!manifest) throw new Error(T.update.checkStatus(response.status)); + // A version at or below this one is never offered: going back is only through the previous copy. + return versionNumber(manifest.version) > versionNumber(VERSION) ? manifest : null; +} + +/** How Lineup is installed: {kind: 'folder' | 'image', path, previous}. Throws with the service's detail. */ +export async function install() { + let reply; + try { + reply = await call('/update/install', {timeoutMs: 8000}); + } catch { + throw new Error(T.update.noAnswer); + } + if (!reply.ok) throw new Error(T.update.noInstall(reply.data.error ?? T.service.status(reply.status))); + return reply.data; +} + +/** + * Downloads `manifest`'s file for this kind of install into /data/lineup/update// and, for a folder, + * unpacks and checks it. `onStep(text)` reports progress. + */ +export async function download(manifest, kind, onStep) { + const {version} = manifest; + const file = kind === 'image' ? manifest.exfat : manifest.zip; + if (versionNumber(version) < 0 || !file || !/^[\w.-]+$/.test(file.name ?? '') || !/^[0-9a-f]{64}$/i.test(file.sha256 ?? '') + || !Number.isSafeInteger(file.size) || file.size <= 0) { + throw new Error(T.update.badManifest); + } + const dir = `${UPDATE_DIR}/${version}`; + // lineupd looks for exactly these names. + const destination = `${dir}/Lineup-${version}.${kind === 'image' ? 'exfat' : 'zip'}`; + FileSystem.mkdir(dir, {recursive: true}); + const task = Downloads.enqueue({url: `${RELEASES}/v${version}/${encodeURIComponent(file.name)}`, destination, + sha256: file.sha256.toLowerCase(), expectedBytes: file.size, recoverCompleted: true}); + const stop = task.subscribe(({written}) => onStep(T.update.downloading(Math.floor(100 * written / file.size)))); + try { + await task.done; + } catch (error) { + throw new Error(T.update.downloadFailed(error?.message ?? String(error))); + } finally { + stop(); + } + if (kind === 'image') { + if (Archives.inspect([destination]).kind !== 'exfat') throw new Error(T.update.badPackage); + return; + } + onStep(T.update.extracting); + const stage = `${dir}/stage`; + try { + await Archives.extract({sources: [destination], destination: stage, maxBytes: 1024 ** 3}).done; + } catch (error) { + throw new Error(T.update.extractFailed(error?.message ?? String(error))); + } + const app = `${stage}/PPSA99077`; + let param = {}; + try { + param = JSON.parse(FileSystem.readFile(`${app}/sce_sys/param.json`)); + } catch { + // Checked below. + } + if (param.titleId !== 'PPSA99077' || versionNumber(param.contentVersion) !== versionNumber(version) + || !FileSystem.exists(`${app}/eboot.bin`) || !FileSystem.exists(`${app}/lineupd.elf`)) { + throw new Error(T.update.badPackage); + } +} + +/** Hands the swap to lineupd; `version` 'previous' goes back. The app must close right after. */ +export async function apply(version) { + let reply; + try { + reply = await call(`/update/apply?version=${version}`, {method: 'POST', timeoutMs: 8000}); + } catch { + throw new Error(T.update.noAnswer); + } + if (!reply.ok) throw new Error(T.update.applyFailed(reply.data.error ?? T.service.status(reply.status))); +} + +/** After an update: what to tell the user, once (and ok- is written), or ''. */ +export function updateOutcome() { + try { + const error = `${UPDATE_DIR}/error`; + if (FileSystem.exists(error)) { + const detail = FileSystem.readFile(error).trim(); + FileSystem.remove(error); + return T.update.failed(detail); + } + const dir = `${UPDATE_DIR}/${VERSION}`; + const ok = `${UPDATE_DIR}/ok-${VERSION}`; + if (!FileSystem.exists(dir) || FileSystem.exists(ok)) return ''; + FileSystem.writeFile(ok, ''); + // The download and its unpacked copy are not needed any more. + FileSystem.removeAsync(dir).catch(() => {}); + return T.update.done(VERSION); + } catch { + return ''; + } +} diff --git a/app/app.json b/app/app.json index e0a3fde..a3578f1 100644 --- a/app/app.json +++ b/app/app.json @@ -3,7 +3,7 @@ "titleId": "PPSA99077", "contentId": "UP9000-PPSA99077_00-LINEUP0000000000", "name": "Lineup", - "version": "01.001.000", + "version": "01.002.000", "render": { "width": 1920, "height": 1080 diff --git a/app/i18n.js b/app/i18n.js index c5c88a0..aea169c 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -51,6 +51,36 @@ const en = { profiles: 'Switch profile', changes: n => (n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'no changes'), profileCount: n => `${n} profiles`, + updates: 'Updates', updateTo: v => `Update to ${v}`, + }, + updates: { + title: 'Updates', checkOnOpen: 'Check when opening', checkNow: 'Check now', + rollback: 'Go back to the previous version', + }, + update: { + available: v => `Lineup ${v} is available.`, + upToDate: 'Lineup is up to date.', + title: v => `Lineup ${v}`, + size: bytes => `Download: ${(bytes / 1048576).toFixed(1)} MB`, + install: 'Update', later: 'Not now', + preparing: 'Getting ready', + downloading: n => `Downloading the update, ${n}%`, + extracting: 'Unpacking the update', + applying: 'Closing to update', + offline: 'Could not check for updates. Check the connection and try again.', + checkStatus: code => `Could not check for updates. GitHub answered ${code}.`, + badManifest: 'The update information is incomplete. Try again later.', + noAnswer: 'The service did not answer. Open the app again and try again.', + noInstall: detail => `Could not update: ${detail}`, + downloadFailed: detail => `The download failed (${detail}). Try again to resume it.`, + extractFailed: detail => `Could not unpack the update (${detail}).`, + badPackage: 'The download is not a Lineup release. Nothing changed.', + applyFailed: detail => `Could not start the update (${detail}). Nothing changed.`, + failed: detail => `The update did not finish: ${detail}`, + done: v => `Updated to ${v}.`, + rollbackTitle: v => `Go back to ${v}?`, + rollbackBody: 'The app closes and opens again on the previous version.', + rollbackConfirm: 'Go back', cancel: 'Cancel', }, profilesTitle: 'Switch profile', profilesSubtitle: 'Each profile has its own row', @@ -108,6 +138,36 @@ const pt = { profiles: 'Trocar perfil', changes: n => (n ? `${n} ${n === 1 ? 'mudança' : 'mudanças'}` : 'sem mudanças'), profileCount: n => `${n} perfis`, + updates: 'Atualizações', updateTo: v => `Atualizar para ${v}`, + }, + updates: { + title: 'Atualizações', checkOnOpen: 'Procurar ao abrir', checkNow: 'Procurar agora', + rollback: 'Voltar à versão anterior', + }, + update: { + available: v => `Lineup ${v} disponível.`, + upToDate: 'O Lineup está atualizado.', + title: v => `Lineup ${v}`, + size: bytes => `Download: ${(bytes / 1048576).toFixed(1).replace('.', ',')} MB`, + install: 'Atualizar', later: 'Agora não', + preparing: 'Preparando', + downloading: n => `Baixando a atualização, ${n}%`, + extracting: 'Abrindo a atualização', + applying: 'Fechando para atualizar', + offline: 'Não consegui procurar atualizações. Confira a conexão e tente de novo.', + checkStatus: code => `Não consegui procurar atualizações. O GitHub respondeu ${code}.`, + badManifest: 'As informações da atualização estão incompletas. Tente mais tarde.', + noAnswer: 'O serviço não respondeu. Abra o app de novo e tente outra vez.', + noInstall: detail => `Não deu para atualizar: ${detail}`, + downloadFailed: detail => `O download falhou (${detail}). Tente de novo para continuar de onde parou.`, + extractFailed: detail => `Não consegui abrir a atualização (${detail}).`, + badPackage: 'O arquivo baixado não é uma versão do Lineup. Nada mudou.', + applyFailed: detail => `Não consegui começar a atualização (${detail}). Nada mudou.`, + failed: detail => `A atualização não terminou: ${detail}`, + done: v => `Atualizado para ${v}.`, + rollbackTitle: v => `Voltar para a ${v}?`, + rollbackBody: 'O app fecha e abre de novo na versão anterior.', + rollbackConfirm: 'Voltar', cancel: 'Cancelar', }, profilesTitle: 'Trocar perfil', profilesSubtitle: 'Cada perfil tem a sua fileira', @@ -131,4 +191,6 @@ function systemLanguage() { } } -export const T = systemLanguage().toLowerCase().startsWith('pt') ? pt : en; +/** The interface language as the update notes are keyed: 'pt-BR' or 'en'. */ +export const LANGUAGE = systemLanguage().toLowerCase().startsWith('pt') ? 'pt-BR' : 'en'; +export const T = LANGUAGE === 'pt-BR' ? pt : en; diff --git a/app/index.jsx b/app/index.jsx index ad9dbf6..3a6e5a0 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -11,9 +11,9 @@ import {AnimatePresence, AppRegistry, BackHandler, Controller, FocusScope, Image motion, useFocusActions} from '@ps5-react/core'; import {useInterfaceSounds} from './sounds.js'; import * as api from './api.js'; -import {T} from './i18n.js'; +import {LANGUAGE, T} from './i18n.js'; import {BOTTOM, DIGITS, EM, TOP} from './digits.js'; -import {Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash, +import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash, px} from './ui.jsx'; const LEFT = 64; // the focused game's left edge @@ -200,12 +200,13 @@ function Position({s}) { ); } -function Header({name}) { +function Header({profile}) { return ( Lineup - {name ? {name} : null} + {profile ? : null} + {profile ? {profile.name} : null} ); } @@ -217,7 +218,9 @@ const PICK_HINTS = [['cross', H.choose], ['circle', H.back]]; const START = {phase: 'loading', step: T.service.looking, error: null, profiles: [], user: null, games: {}, home: {}, base: [], order: [], focus: 0, dir: 1, lifted: false, liftFrom: 0, layer: null, - target: null, toast: null, pending: null}; + target: null, toast: null, pending: null, progress: null, settings: null, update: null, install: null}; + +const DAY = 24 * 60 * 60 * 1000; function App() { const [s, setS] = useState(START); @@ -284,18 +287,83 @@ function App() { if (!profiles.length) { throw new api.Problem(...T.noProfiles); } + // Decoded ahead at the profile list's size, so the list opens with its pictures. + for (const profile of profiles) { + if (profile.avatar) Image.prefetch(api.avatarUrl(profile), {width: px(40), height: px(40)}).catch(() => {}); + } const foreground = Users.getForeground(); const id = foreground ? String(foreground.id >>> 0).padStart(10, '0') : ''; await loadRow((profiles.find(profile => profile.userId === id) ?? profiles[0]).userId, profiles); } catch (error) { patch({phase: 'error', error: error instanceof api.Problem ? error : {title: T.openFailed, detail: String(error?.message ?? error)}}); + return; } + afterOpen(); }; useEffect(() => { boot(); }, []); + const keepSettings = settings => { + api.saveSettings(settings); + patch({settings}); + }; + + // In the background once the row is up: how the last update went, then a check at most once a day. + const afterOpen = async () => { + const outcome = api.updateOutcome(); + if (outcome) toast(outcome); + const settings = api.settings(); + patch({settings}); + api.install().then(install => patch({install}), error => patch({install: {error: error.message}})); + if (!settings.checkOnOpen || Date.now() - settings.lastCheck < DAY) return; + try { + const update = await api.checkUpdate(); + keepSettings({...ref.current.settings, lastCheck: Date.now()}); + if (update) { + patch({update}); + toast(T.update.available(update.version)); + } + } catch { + // Offline, say: the next opening tries again. + } + }; + + const checkNow = async () => { + patch({layer: 'busy'}); + try { + const update = await api.checkUpdate(); + keepSettings({...ref.current.settings, lastCheck: Date.now()}); + if (update) patch({update, layer: 'update'}); + else { + close(); + toast(T.update.upToDate); + } + } catch (error) { + close(); + toast(error.message); + } + }; + + // Downloads and prepares the update, hands the swap to lineupd and closes; lineupd opens the app again. + const runUpdate = async version => { + const {update, install} = ref.current; + light(false); + patch({lifted: false, layer: 'busy', progress: T.update.preparing}); + try { + if (!install || install.error) throw new Error(install?.error ?? T.update.noAnswer); + if (version !== 'previous') await api.download(update, install.kind, progress => patch({progress})); + patch({progress: T.update.applying}); + await api.apply(version); + exit(); + } catch (error) { + patch({progress: null}); + close(); + toast(error.message); + } + }; + const switchTo = async user => { patch({layer: 'busy'}); try { @@ -421,7 +489,7 @@ function App() { return ( -
+
true}> {saved ? ( } title={T.saved[0]} @@ -442,23 +510,36 @@ function App() { const count = changed(s); const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : ROW_HINTS; + const settings = s.settings ?? {checkOnOpen: true, lastCheck: 0}; + const file = s.update?.[s.install?.kind === 'image' ? 'exfat' : 'zip']; + const notes = [s.update?.notes?.[LANGUAGE] ?? s.update?.notes?.en, file?.size ? T.update.size(file.size) : ''] + .filter(Boolean).join('\n\n'); return ( -
+
- {s.layer === 'menu' || s.layer === 'profiles' ? ( - // One sheet for both pages, so it stays put and still knows which game to give focus back to. + {s.layer === 'menu' || s.layer === 'profiles' || s.layer === 'updates' ? ( + // One sheet for every page, so it stays put and still knows which game to give focus back to. (s.layer === 'profiles' ? patch({layer: 'menu'}) : close())} - title={s.layer === 'menu' ? T.menu.title : T.profilesTitle} - subtitle={s.layer === 'menu' ? profile?.name : T.profilesSubtitle}> - {s.layer === 'menu' ? [ + onBack={() => (s.layer === 'menu' ? close() : patch({layer: 'menu'}))} + title={s.layer === 'menu' ? T.menu.title : s.layer === 'profiles' ? T.profilesTitle : T.updates.title} + subtitle={s.layer === 'menu' ? profile?.name : s.layer === 'profiles' ? T.profilesSubtitle : `Lineup ${api.VERSION}`} + leading={s.layer === 'menu' && profile ? : null}> + {s.layer === 'updates' ? [ + keepSettings({...settings, checkOnOpen: !settings.checkOnOpen})} />, + , + s.install?.previous ? ( + patch({layer: 'rollback'})} /> + ) : null, + ] : s.layer === 'menu' ? [ save()} detail={T.menu.changes(count)} />, , @@ -466,8 +547,13 @@ function App() { , patch({layer: 'profiles'})} detail={s.profiles.length > 1 ? T.menu.profileCount(s.profiles.length) : ''} />, + s.update ? ( + patch({layer: 'update'})} /> + ) : null, + patch({layer: 'updates'})} />, ] : s.profiles.map(item => ( } autoFocus={item.userId === s.user} onPress={() => pickProfile(item.userId)} /> ))} @@ -487,12 +573,25 @@ function App() {