From 1e64e95aa371a7d8ac9ffd8f71b49ee37b3ac0cb Mon Sep 17 00:00:00 2001 From: bps Date: Thu, 15 Oct 2026 13:34:29 -0300 Subject: [PATCH 1/4] Start 1.3.0: pinned games From 4631430faa431f2efdbaa8e0bffeb9067f125f40 Mon Sep 17 00:00:00 2001 From: bps Date: Thu, 15 Oct 2026 13:44:23 -0300 Subject: [PATCH 2/4] lineupd: pinned games with triggers in app.db, /pins/off and /pins/uninstall GET /row reports each game's pinned slot and whether the pins are on, missing or off. POST /row takes " pin" after a game's index. save_order drops the profile's triggers inside its transaction, applies the order, records the pins and creates the triggers again. The self-test opens games twice, installs, uninstalls, turns pins off and removes everything. --- app/payloads/lineupd.c | 391 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 370 insertions(+), 21 deletions(-) diff --git a/app/payloads/lineupd.c b/app/payloads/lineupd.c index 7cbb562..bad2e68 100644 --- a/app/payloads/lineupd.c +++ b/app/payloads/lineupd.c @@ -9,11 +9,14 @@ // Endpoints (JSON unless noted), on 127.0.0.1:9077 (0.0.0.0 when built with -DLINEUPD_LAN): // GET /version {"version"} // GET /profiles {"profiles":[{"userId","hex","name","avatar"}]} -// GET /row?user=<10 dig> {"user","games":[{"titleId","name","index","icon"}]} +// GET /row?user=<10 dig> {"user","pins":"on"|"missing"|"off","games":[{"titleId","name","index","icon", +// "pinned":slot|null}]} // GET /icon/ image/png // GET /avatar/<10 dig> image/png, the profile's picture -// POST /row?user=<10 dig> X-Lineup: 1; body "LIN1\n" then " \n" per game, -// in the new order, with the index the client read +// POST /row?user=<10 dig> X-Lineup: 1; body "LIN1\n" then " [ pin]\n" per game, +// in the new order, with the index the client read; " pin" pins it at that place +// POST /pins/off?user=<10 dig> X-Lineup: 1; unpins that profile's games +// POST /pins/uninstall X-Lineup: 1; removes every pin, trigger and table Lineup added to app.db // POST /quit X-Lineup: 1; exits after answering // GET /update/install X-Lineup: 1; {"kind":"folder"|"image","path","previous"} // POST /update/apply?version= X-Lineup: 1; answers, waits for the app to close, swaps its @@ -134,7 +137,7 @@ static int is_title_id(const char* id) { // ---- Reorder logic (pure) ---- -typedef struct { char title[16]; long long index; } Row; +typedef struct { char title[16]; long long index; int pinned; } Row; static int by_index_descending(const void* a, const void* b) { long long x = *(const long long*)a, y = *(const long long*)b; @@ -157,7 +160,7 @@ static int plan_reorder(const Row* current, int count, const Row* posted, int po return 0; } -// Parses "LIN1\n" + " \n" lines. Returns the number of rows, or -1. +// Parses "LIN1\n" + " [ pin]\n" lines. Returns the number of rows, or -1. static int parse_order(const char* body, Row* rows, int capacity) { if (strncmp(body, "LIN1\n", 5)) return -1; int count = 0; @@ -169,11 +172,12 @@ static int parse_order(const char* body, Row* rows, int capacity) { if (length >= sizeof text || count == capacity) return -1; memcpy(text, line, length); text[length] = 0; - char title[16]; + char title[16], flag[8], extra; long long index; - char extra; - if (sscanf(text, "%15s %lld %c", title, &index, &extra) != 2 || !is_title_id(title)) return -1; + int fields = sscanf(text, "%15s %lld %7s %c", title, &index, flag, &extra); + if (fields < 2 || fields > 3 || (fields == 3 && strcmp(flag, "pin")) || !is_title_id(title)) return -1; strcpy(rows[count].title, title); + rows[count].pinned = fields == 3; rows[count++].index = index; } if (!end) break; @@ -195,17 +199,22 @@ static sqlite3* open_db(int writable) { return db; } -static int table_exists(sqlite3* db, const char* user) { - char name[64]; - snprintf(name, sizeof name, "tbl_iconinfo_%s", user); +// Whether the schema has a table or trigger named `name`. +static int has_object(sqlite3* db, const char* name) { sqlite3_stmt* statement; - if (sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", -1, &statement, NULL)) return 0; + if (sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master WHERE name=?", -1, &statement, NULL)) return 0; sqlite3_bind_text(statement, 1, name, -1, SQLITE_STATIC); int exists = sqlite3_step(statement) == SQLITE_ROW; sqlite3_finalize(statement); return exists; } +static int table_exists(sqlite3* db, const char* user) { + char name[64]; + snprintf(name, sizeof name, "tbl_iconinfo_%s", user); + return has_object(db, name); +} + // `user` must already be validated: table names cannot be bound. static int read_row(sqlite3* db, const char* user, Row* rows, char (*names)[256], int capacity) { char sql[160]; @@ -219,6 +228,7 @@ static int read_row(sqlite3* db, const char* user, Row* rows, char (*names)[256] const char* name = (const char*)sqlite3_column_text(statement, 1); snprintf(rows[count].title, sizeof rows[count].title, "%s", title ? title : ""); if (names) snprintf(names[count], sizeof names[count], "%s", name && *name ? name : rows[count].title); + rows[count].pinned = 0; rows[count++].index = sqlite3_column_int64(statement, 2); } sqlite3_finalize(statement); @@ -302,6 +312,185 @@ static int check_and_backup(sqlite3* db, char* backup, size_t backup_size, char* return 0; } +// ---- Pinned games ---- +// +// A pinned game keeps its place when the system moves games: three triggers on tbl_iconinfo_ +// (lineup_pin_u_, _i_ and _d_) run REORDER inside the system's own write, so the home screen rereads a row +// already in the pinned order. lineup_pin holds the slots, lineup_seq is 0..MAX_GAMES-1 (to find the k-th free +// place) and lineup_tmp is REORDER's scratch table, empty between writes. + +#define PINNED "SELECT titleId FROM lineup_pin WHERE user = '%1$s'" + +// In SQL the home screen's SQLite reads: no CTEs (not allowed in triggers anyway), window functions, UPSERT or +// RETURNING. %1$s is the user. In lineup_tmp, v is a game's index, r the place it gets and nv that place's index +// (before that, for a game that is not pinned, how many of those were used after it). +// ponytail: places stop at MAX_GAMES; a game past them gets no place and keeps its index. +#define REORDER \ + /* The row as it is now. */ \ + "INSERT OR IGNORE INTO lineup_tmp (titleId, v) SELECT titleId, lastAccessIndex FROM tbl_iconinfo_%1$s " \ + "WHERE visible = 1 AND dispLocation = 138;" \ + /* A pinned game keeps its slot; pinned past the end of a shorter row, they pack at its end in slot order. */ \ + "UPDATE lineup_tmp SET r = (SELECT MIN(p.slot, (SELECT COUNT(*) FROM lineup_tmp) - 1 - (SELECT COUNT(*) " \ + "FROM lineup_pin q, lineup_tmp t WHERE q.user = p.user AND t.titleId = q.titleId AND q.slot > p.slot)) " \ + "FROM lineup_pin p WHERE p.user = '%1$s' AND p.titleId = lineup_tmp.titleId) WHERE titleId IN (" PINNED ");" \ + /* The others take the free places by how recently they were used, as the system orders them. */ \ + "UPDATE lineup_tmp SET nv = (SELECT COUNT(*) FROM lineup_tmp o WHERE o.titleId NOT IN (" PINNED ") AND " \ + "(o.v > lineup_tmp.v OR (o.v = lineup_tmp.v AND o.titleId < lineup_tmp.titleId))) WHERE titleId NOT IN (" PINNED ");" \ + "UPDATE lineup_tmp SET r = (SELECT MIN(s.i) FROM lineup_seq s WHERE s.i < (SELECT COUNT(*) FROM lineup_tmp) AND " \ + "s.i NOT IN (SELECT t.r FROM lineup_tmp t WHERE t.titleId IN (" PINNED ")) AND s.i - (SELECT COUNT(*) " \ + "FROM lineup_tmp t WHERE t.titleId IN (" PINNED ") AND t.r < s.i) = lineup_tmp.nv) WHERE titleId NOT IN (" PINNED ");" \ + /* Place r gets the r-th largest index the row already has, as in save_order. */ \ + "UPDATE lineup_tmp SET nv = (SELECT a.v FROM lineup_tmp a WHERE (SELECT COUNT(*) FROM lineup_tmp b " \ + "WHERE b.v > a.v OR (b.v = a.v AND b.titleId < a.titleId)) = lineup_tmp.r);" \ + "UPDATE tbl_iconinfo_%1$s SET lastAccessIndex = (SELECT t.nv FROM lineup_tmp t WHERE t.titleId = " \ + "tbl_iconinfo_%1$s.titleId) WHERE visible = 1 AND dispLocation = 138 AND titleId IN " \ + "(SELECT titleId FROM lineup_tmp WHERE nv <> v);" \ + "DELETE FROM lineup_tmp;" + +// Opening a game raises its index (twice, a few seconds apart); installing inserts and uninstalling deletes a row. +// recursive_triggers is off by default, so REORDER's own UPDATE does not fire them again; a non-empty lineup_tmp +// stops them too, should it be on. +#define PIN_TRIGGER(kind, event, when) \ + "CREATE TRIGGER lineup_pin_" kind "_%1$s AFTER " event " ON tbl_iconinfo_%1$s WHEN " when \ + "NOT EXISTS (SELECT 1 FROM lineup_tmp) BEGIN " REORDER " END;" +#define PIN_TRIGGERS \ + PIN_TRIGGER("u", "UPDATE OF lastAccessIndex", "NEW.lastAccessIndex > OLD.lastAccessIndex AND ") \ + PIN_TRIGGER("i", "INSERT", "") PIN_TRIGGER("d", "DELETE", "") + +static int drop_pin_triggers(sqlite3* db, const char* user) { + char sql[256]; + snprintf(sql, sizeof sql, "DROP TRIGGER IF EXISTS lineup_pin_u_%1$s; DROP TRIGGER IF EXISTS lineup_pin_i_%1$s;" + "DROP TRIGGER IF EXISTS lineup_pin_d_%1$s;", user); + return sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK ? 0 : -1; +} + +// Removes everything Lineup adds to app.db: every profile's triggers and the three tables. +static int remove_pins(sqlite3* db) { + static char names[MAX_GAMES][64]; + int count = 0; + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name GLOB 'lineup_pin_[uid]_*'", + -1, &statement, NULL) != SQLITE_OK) return -1; + while (count < MAX_GAMES && sqlite3_step(statement) == SQLITE_ROW) + snprintf(names[count++], sizeof names[0], "%s", (const char*)sqlite3_column_text(statement, 0)); + sqlite3_finalize(statement); + for (int i = 0; i < count; ++i) { + char sql[128]; + snprintf(sql, sizeof sql, "DROP TRIGGER \"%s\"", names[i]); + if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) return -1; + } + return sqlite3_exec(db, "DROP TABLE IF EXISTS lineup_pin; DROP TABLE IF EXISTS lineup_seq; DROP TABLE IF EXISTS lineup_tmp;", + NULL, NULL, NULL) == SQLITE_OK ? 0 : -1; +} + +static int forget_pins(sqlite3* db, const char* user) { + if (!has_object(db, "lineup_pin")) return 0; + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, "DELETE FROM lineup_pin WHERE user = ?", -1, &statement, NULL) != SQLITE_OK) return -1; + sqlite3_bind_text(statement, 1, user, -1, SQLITE_STATIC); + int failed = sqlite3_step(statement) != SQLITE_DONE; + sqlite3_finalize(statement); + return failed ? -1 : 0; +} + +// Once no profile has a pinned game, nothing of Lineup's stays in app.db. +static int remove_pins_if_unused(sqlite3* db) { + if (has_object(db, "lineup_pin")) { + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, "SELECT 1 FROM lineup_pin LIMIT 1", -1, &statement, NULL) != SQLITE_OK) return -1; + int used = sqlite3_step(statement) == SQLITE_ROW; + sqlite3_finalize(statement); + if (used) return 0; + } + return remove_pins(db); +} + +// Inside save_order's transaction, after its triggers were dropped: records the posted games that are pinned, at +// their posted places, and creates the triggers again when there are any. +static int save_pins(sqlite3* db, const char* user, const Row* posted, int count) { + int pins = 0; + for (int i = 0; i < count; ++i) pins += posted[i].pinned; + if (forget_pins(db, user)) return -1; + if (!pins) return remove_pins_if_unused(db); + if (sqlite3_exec(db, "CREATE TABLE IF NOT EXISTS lineup_pin (user TEXT, titleId TEXT, slot INTEGER, PRIMARY KEY (user, titleId));" + "CREATE TABLE IF NOT EXISTS lineup_seq (i INTEGER PRIMARY KEY);" + "CREATE TABLE IF NOT EXISTS lineup_tmp (titleId TEXT PRIMARY KEY, v INTEGER, r INTEGER, nv INTEGER);", + NULL, NULL, NULL) != SQLITE_OK) return -1; + sqlite3_stmt* statement; + if (sqlite3_prepare_v2(db, "INSERT OR IGNORE INTO lineup_seq VALUES (?)", -1, &statement, NULL) != SQLITE_OK) return -1; + int failed = 0; + for (int i = 0; !failed && i < MAX_GAMES; ++i) { + sqlite3_bind_int(statement, 1, i); + failed = sqlite3_step(statement) != SQLITE_DONE; + sqlite3_reset(statement); + } + sqlite3_finalize(statement); + if (failed || sqlite3_prepare_v2(db, "INSERT INTO lineup_pin VALUES (?, ?, ?)", -1, &statement, NULL) != SQLITE_OK) return -1; + for (int i = 0; !failed && i < count; ++i) { + if (!posted[i].pinned) continue; + sqlite3_bind_text(statement, 1, user, -1, SQLITE_STATIC); + sqlite3_bind_text(statement, 2, posted[i].title, -1, SQLITE_STATIC); + sqlite3_bind_int(statement, 3, i); + failed = sqlite3_step(statement) != SQLITE_DONE; + sqlite3_reset(statement); + } + sqlite3_finalize(statement); + static char sql[16 * 1024]; + return failed || snprintf(sql, sizeof sql, PIN_TRIGGERS, user) >= (int)sizeof sql || + sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK ? -1 : 0; +} + +// Each of `rows`' pinned slot in `slots`, -1 when it is not pinned. Returns "on", "missing" (pinned games without +// their triggers, as after the database is rebuilt) or "off". +static const char* read_pins(sqlite3* db, const char* user, const Row* rows, int count, int* slots) { + for (int i = 0; i < count; ++i) slots[i] = -1; + sqlite3_stmt* statement; + if (!has_object(db, "lineup_pin") || + sqlite3_prepare_v2(db, "SELECT titleId, slot FROM lineup_pin WHERE user = ?", -1, &statement, NULL) != SQLITE_OK) + return "off"; + sqlite3_bind_text(statement, 1, user, -1, SQLITE_STATIC); + int any = 0; + for (; sqlite3_step(statement) == SQLITE_ROW; any = 1) { + const char* title = (const char*)sqlite3_column_text(statement, 0); + for (int i = 0; title && i < count; ++i) + if (!strcmp(rows[i].title, title)) slots[i] = sqlite3_column_int(statement, 1); + } + sqlite3_finalize(statement); + if (!any) return "off"; + for (const char* kind = "uid"; *kind; ++kind) { + char name[64]; + snprintf(name, sizeof name, "lineup_pin_%c_%s", *kind, user); + if (!has_object(db, name)) return "missing"; + } + return "on"; +} + +// Unpins every game of `user` (/pins/off), or of every profile when `user` is NULL (/pins/uninstall), after a +// backup. Returns the HTTP status; `message` explains a failure. +static int unpin(const char* user, char* backup, size_t backup_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; + if (user && !table_exists(db, user)) { snprintf(message, message_size, "No such profile."); status = 404; goto done; } + // Nothing to remove: no backup either. + if (user && !has_object(db, "lineup_pin")) { status = 200; 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; + } + if ((user ? drop_pin_triggers(db, user) || forget_pins(db, user) || remove_pins_if_unused(db) : remove_pins(db)) || + sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { + snprintf(message, message_size, "The write failed (%s). Nothing changed.", sqlite3_errmsg(db)); + sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + goto done; + } + status = 200; +done: + sqlite3_close(db); + return status; +} + // 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) { @@ -326,14 +515,16 @@ static int save_order(const char* user, const Row* posted, int posted_count, cha { char sql[128]; snprintf(sql, sizeof sql, "UPDATE tbl_iconinfo_%s SET lastAccessIndex = ? WHERE titleId = ?", user); - sqlite3_stmt* update; - int failed = sqlite3_prepare_v2(db, sql, -1, &update, NULL) != SQLITE_OK; + sqlite3_stmt* update = NULL; + // The pin triggers go first, or these writes would fire them. + int failed = drop_pin_triggers(db, user) || sqlite3_prepare_v2(db, sql, -1, &update, NULL) != SQLITE_OK; for (int i = 0; !failed && i < posted_count; ++i) { sqlite3_bind_int64(update, 1, indices[i]); sqlite3_bind_text(update, 2, posted[i].title, -1, SQLITE_STATIC); failed = sqlite3_step(update) != SQLITE_DONE || sqlite3_changes(db) != 1; sqlite3_reset(update); } + if (!failed) failed = save_pins(db, user, posted, posted_count); if (failed) snprintf(message, message_size, "The write failed (%s). Nothing changed.", sqlite3_errmsg(db)); sqlite3_finalize(update); if (failed || sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { @@ -619,24 +810,28 @@ static void get_row(int socket, const char* user) { if (!table_exists(db, user)) { sqlite3_close(db); respond_error(socket, 404, "No such profile."); return; } int count = read_row(db, user, rows, names, MAX_GAMES); static long long icons[MAX_GAMES]; + static int slots[MAX_GAMES]; for (int i = 0; i < count; ++i) { char path[256]; struct stat info; icon_path(db, rows[i].title, path, sizeof path); icons[i] = stat(path, &info) ? 0 : (long long)info.st_mtime; } + const char* pins = read_pins(db, user, rows, count < 0 ? 0 : count, slots); sqlite3_close(db); if (count < 0) { respond_error(socket, 500, "Could not read the row."); return; } Buffer body = {0}; puts_(&body, "{\"user\":"); put_json_string(&body, user); - puts_(&body, ",\"games\":["); + putf(&body, ",\"pins\":\"%s\",\"games\":[", pins); for (int i = 0; i < count; ++i) { puts_(&body, i ? ",{\"titleId\":" : "{\"titleId\":"); put_json_string(&body, rows[i].title); puts_(&body, ",\"name\":"); put_json_string(&body, names[i]); - putf(&body, ",\"index\":%lld,\"icon\":%lld}", rows[i].index, icons[i]); + putf(&body, ",\"index\":%lld,\"icon\":%lld", rows[i].index, icons[i]); + if (slots[i] < 0) puts_(&body, ",\"pinned\":null}"); + else putf(&body, ",\"pinned\":%d}", slots[i]); } puts_(&body, "]}"); respond_json(socket, 200, &body); @@ -696,6 +891,19 @@ static void post_row(int socket, const char* user, const char* body) { free(reply.data); } +static void post_unpin(int socket, const char* user) { + char backup[256] = "", message[300] = ""; + int status = unpin(user, backup, sizeof backup, message, sizeof message); + if (status != 200) { respond_error(socket, status, message); return; } + LOG("pins off for %s, backup %s", user ? user : "every profile", backup); + Buffer reply = {0}; + puts_(&reply, "{\"ok\":true,\"backup\":"); + put_json_string(&reply, backup); + puts_(&reply, "}"); + respond_json(socket, 200, &reply); + free(reply.data); +} + // Returns the value of `name` in a query string, copied into `out`. static const char* query_value(const char* query, const char* name, char* out, size_t size) { size_t length = strlen(name); @@ -1283,6 +1491,12 @@ static int handle(int socket) { else if (get) get_row(socket, user); else if (!authorized) respond_error(socket, 403, "Missing the X-Lineup header."); else post_row(socket, user, body); + } else if (post && !strncmp(target, "/pins/", 6)) { + if (!authorized) respond_error(socket, 403, "Missing the X-Lineup header."); + else if (!strcmp(target, "/pins/uninstall")) post_unpin(socket, NULL); + else if (strcmp(target, "/pins/off")) respond_error(socket, 404, "Unknown route."); + else if (!query_value(query, "user", user, sizeof user) || !is_user_id(user)) respond_error(socket, 400, "user must be 10 digits."); + else post_unpin(socket, user); } else if (get && !strncmp(target, "/icon/", 6)) { get_icon(socket, target + 6); } else if (get && !strncmp(target, "/avatar/", 8)) { @@ -1356,22 +1570,156 @@ int main(void) { #else +static long long given; // the largest index the simulated system gave + +static long long scalar(const char* sql) { + sqlite3* db = open_db(0); + sqlite3_stmt* statement; + assert(db && sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW); + long long value = sqlite3_column_int64(statement, 0); + sqlite3_finalize(statement); + sqlite3_close(db); + return value; +} + +// Runs `format` (a statement, %1$s the user and %2$s a title) as the system would, outside Lineup. +static void run(const char* format, const char* user, const char* title) { + char sql[512]; + snprintf(sql, sizeof sql, format, user, title); + sqlite3* db = open_db(1); + assert(db && sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK); + sqlite3_close(db); +} + +// What the console does when a game opens: two raises to its counter plus one, a few seconds apart, between writes +// that leave the index alone. +static void open_game(const char* user, const char* title) { + for (int i = 0; i < 2; ++i) { + char sql[128]; + snprintf(sql, sizeof sql, "SELECT MAX(lastAccessIndex) FROM tbl_iconinfo_%s", user); + given = scalar(sql) + 1; + snprintf(sql, sizeof sql, "UPDATE tbl_iconinfo_%%1$s SET lastAccessIndex = %lld WHERE titleId = '%%2$s'", given); + run(sql, user, title); + run("UPDATE tbl_iconinfo_%1$s SET titleName = titleName WHERE titleId = '%2$s'", user, title); + } +} + +// Checks the row against `expected`, the games' last digits from first to last, and that no index went past +// the largest the system gave. +static void expect_row(const char* user, const char* expected) { + static Row rows[MAX_GAMES]; + sqlite3* db = open_db(0); + int count = read_row(db, user, rows, NULL, MAX_GAMES); + sqlite3_close(db); + char got[MAX_GAMES + 1]; + for (int i = 0; i < count; ++i) got[i] = rows[i].title[8]; + got[count] = 0; + if (strcmp(got, expected)) { fprintf(stderr, "row %s, expected %s\n", got, expected); exit(1); } + for (int i = 1; i < count; ++i) assert(rows[i].index < rows[i - 1].index); + assert(rows[0].index <= given); +} + +// Saves the row in `order` (last digits), pinning the games in `pins` at their places. +static void save_digits(const char* user, const char* order, const char* pins) { + static Row current[MAX_GAMES], posted[MAX_GAMES]; + sqlite3* db = open_db(0); + int count = read_row(db, user, current, NULL, MAX_GAMES); + sqlite3_close(db); + assert(count == (int)strlen(order)); + for (int i = 0; i < count; ++i) + for (int j = 0; j < count; ++j) + if (current[j].title[8] == order[i]) { posted[i] = current[j]; posted[i].pinned = strchr(pins, order[i]) != NULL; } + char backup[256], integrity[256], message[300]; + int status = save_order(user, posted, count, backup, sizeof backup, integrity, sizeof integrity, message, sizeof message); + if (status != 200) { fprintf(stderr, "%d %s\n", status, message); exit(1); } +} + +static const char* pins_of(const char* user, int* slots) { + static Row rows[MAX_GAMES]; + sqlite3* db = open_db(0); + int count = read_row(db, user, rows, NULL, MAX_GAMES); + const char* state = read_pins(db, user, rows, count, slots); + sqlite3_close(db); + return state; +} + +// Pinned games on the synthetic database: games 1 to 5 of user 1, in that order. +static void test_pins(void) { + const char* one = "0000000001"; + int slots[MAX_GAMES]; + given = scalar("SELECT MAX(lastAccessIndex) FROM tbl_iconinfo_0000000001"); + save_digits(one, "31254", "35"); + expect_row(one, "31254"); + assert(!strcmp(pins_of(one, slots), "on") && slots[0] == 0 && slots[1] < 0 && slots[3] == 3); + // Opening games: the others take the free places by how recently they were used. + open_game(one, "PPSA00004"); + expect_row(one, "34152"); + open_game(one, "PPSA00002"); + expect_row(one, "32451"); + open_game(one, "PPSA00005"); + expect_row(one, "32451"); + // Installing: the new game takes the first free place. + given = scalar("SELECT MAX(lastAccessIndex) FROM tbl_iconinfo_0000000001") + 1; + char sql[256]; + snprintf(sql, sizeof sql, "INSERT INTO tbl_iconinfo_%%1$s VALUES ('%%2$s','Seven',%lld,1,138)", given); + run(sql, one, "PPSA00007"); + expect_row(one, "372541"); + // Uninstalling: a game pinned past the end of the shorter row goes last. + run("DELETE FROM tbl_iconinfo_%1$s WHERE titleId = '%2$s'", one, "PPSA00007"); + run("DELETE FROM tbl_iconinfo_%1$s WHERE titleId = '%2$s'", one, "PPSA00004"); + expect_row(one, "3215"); + run("DELETE FROM tbl_iconinfo_%1$s WHERE titleId = '%2$s'", one, "PPSA00001"); + expect_row(one, "325"); + // Saving again moves a pinned game and keeps it pinned at its new place. + save_digits(one, "235", "35"); + open_game(one, "PPSA00005"); + expect_row(one, "235"); + open_game(one, "PPSA00002"); + expect_row(one, "235"); + assert(!strcmp(pins_of(one, slots), "on") && slots[1] == 1 && slots[0] < 0 && slots[2] == 2); + // Without its triggers (a rebuilt database), the pins show as missing. + run("DROP TRIGGER lineup_pin_d_%1$s", one, ""); + assert(!strcmp(pins_of(one, slots), "missing")); + + // /pins/off: the profile's pins and, the last ones gone, every lineup_* object; the system's order is back. + char backup[256], message[300]; + assert(unpin(one, backup, sizeof backup, message, sizeof message) == 200); + assert(!strcmp(pins_of(one, slots), "off") && !scalar("SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'lineup%'")); + open_game(one, "PPSA00005"); + expect_row(one, "523"); + + // /pins/uninstall with two profiles pinned. + const char* two = "0000000002"; + run("CREATE TABLE tbl_iconinfo_%1$s (titleId TEXT, titleName TEXT, lastAccessIndex INTEGER, visible INTEGER, " + "dispLocation INTEGER); INSERT INTO tbl_iconinfo_%1$s VALUES ('PPSA00008','Eight',2,1,138),('PPSA00009','Nine',1,1,138)", + two, ""); + save_digits(one, "523", "2"); + save_digits(two, "98", "8"); + assert(unpin(one, backup, sizeof backup, message, sizeof message) == 200); + assert(!strcmp(pins_of(two, slots), "on") && scalar("SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'lineup%'") == 6); + save_digits(one, "523", "2"); + assert(unpin(NULL, backup, sizeof backup, message, sizeof message) == 200); + assert(!strcmp(pins_of(one, slots), "off") && !strcmp(pins_of(two, slots), "off")); + assert(!scalar("SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'lineup%'")); +} + // Self-check: lineupd-test [ ]. Reorders the copy (or a synthetic database), checks it, // restores it. int main(int argc, char** argv) { - Row current[3] = {{"PPSA00001", 30}, {"PPSA00002", 20}, {"PPSA00003", 10}}; - Row posted[3] = {{"PPSA00003", 10}, {"PPSA00001", 30}, {"PPSA00002", 20}}; + Row current[3] = {{"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}, {"PPSA00003", 10, 0}}; + Row posted[3] = {{"PPSA00003", 10, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; long long out[3]; assert(!plan_reorder(current, 3, posted, 3, out) && out[0] == 30 && out[1] == 20 && out[2] == 10); - Row stale[3] = {{"PPSA00003", 11}, {"PPSA00001", 30}, {"PPSA00002", 20}}; + Row stale[3] = {{"PPSA00003", 11, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; assert(plan_reorder(current, 3, stale, 3, out)); - Row duplicate[3] = {{"PPSA00001", 30}, {"PPSA00001", 30}, {"PPSA00002", 20}}; + Row duplicate[3] = {{"PPSA00001", 30, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; assert(plan_reorder(current, 3, duplicate, 3, out)); assert(plan_reorder(current, 3, posted, 2, out)); Row parsed[4]; assert(parse_order("LIN1\nPPSA00003 10\nPPSA00001 30\n", parsed, 4) == 2 && parsed[1].index == 30); assert(parse_order("LIN1\nPPSA0003 10\n", parsed, 4) < 0 && parse_order("XXX\n", parsed, 4) < 0); - assert(parse_order("LIN1\nPPSA00003 10 x\n", parsed, 4) < 0); + assert(parse_order("LIN1\nPPSA00003 10 x\n", parsed, 4) < 0 && parse_order("LIN1\nPPSA00003 10 pin x\n", parsed, 4) < 0); + assert(parse_order("LIN1\nPPSA00003 10 pin\nPPSA00001 30\n", parsed, 4) == 2 && parsed[0].pinned && !parsed[1].pinned); // Without a copy of app.db, a synthetic one: five games on the row and one hidden. const char* user = argc >= 3 ? argv[2] : "0000000001"; @@ -1413,6 +1761,7 @@ int main(int argc, char** argv) { read_row(db, user, after, NULL, MAX_GAMES); sqlite3_close(db); for (int i = 0; i < count; ++i) assert(!strcmp(after[i].title, before[i].title) && after[i].index == before[i].index); + if (argc < 3) test_pins(); // Profile pictures: folders named with 8 and 16 hex digits. profile_cache_dir = "/tmp/lineupd-test-profile"; From 9098f0f858b977723f7ad2b299baa8e1ade874a2 Mon Sep 17 00:00:00 2001 From: bps Date: Thu, 15 Oct 2026 13:49:47 -0300 Subject: [PATCH 3/4] App: pin games with Square A pin badge on the game, a block on the ruler and "pinned at 3rd" under the number. Options gains Pin the whole row, Unpin all and Remove pinned games from every profile. The change count includes pins. When the pins' triggers are missing, the app offers to pin the games again. Texts in English and Portuguese. --- app/api.js | 25 +++++++++--- app/i18n.js | 38 ++++++++++++++++-- app/index.jsx | 108 ++++++++++++++++++++++++++++++++++++++++---------- app/ui.jsx | 18 ++++++++- 4 files changed, 156 insertions(+), 33 deletions(-) diff --git a/app/api.js b/app/api.js index 5feb4e9..17f2828 100644 --- a/app/api.js +++ b/app/api.js @@ -97,16 +97,21 @@ 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 ?? []; +/** {games, pins}: each game's `pinned` is its slot or null; `pins` is 'on', 'missing' (triggers lost) or 'off'. */ +export async function row(user) { + const data = await read(`/row?user=${user}`, T.service.row); + return {games: data.games ?? [], pins: data.pins ?? 'off'}; +} 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 - * {ok: true} or {ok: false, stale, message}; `stale` means the row changed since it was read. + * Saves `order` (titleIds) for `user`, sending each game's index as it was read and pinning the games in `pins` + * where they stand. Resolves to {ok: true} or {ok: false, stale, message}; `stale` means the row changed since it + * was read. */ -export async function save(user, order, games) { - const body = `LIN1\n${order.map(id => `${id} ${games[id].index}\n`).join('')}`; +export async function save(user, order, games, pins) { + const body = `LIN1\n${order.map(id => `${id} ${games[id].index}${pins[id] ? ' pin' : ''}\n`).join('')}`; let reply; try { reply = await call(`/row?user=${user}`, {method: 'POST', body}); @@ -119,6 +124,16 @@ export async function save(user, order, games) { return {ok: false, stale: false, message: T.service.saveStatus(reply.status) + detail}; } +/** Removes every profile's pinned games and all Lineup added to the home screen database. Resolves to an error message or ''. */ +export async function unpinAll() { + try { + const reply = await call('/pins/uninstall', {method: 'POST', timeoutMs: 15000}); + return reply.ok ? '' : T.pins.removeFailed(reply.data.error ?? T.service.status(reply.status)); + } catch { + return T.pins.removeFailed(T.pins.noAnswer); + } +} + // ---- Updates ---- const UPDATE_DIR = '/data/lineup/update'; diff --git a/app/i18n.js b/app/i18n.js index aea169c..1b031fb 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -37,7 +37,22 @@ const en = { of: n => `of ${n}`, hints: { lift: 'Lift', save: 'Save', options: 'Options', quit: 'Quit', move: 'Move', drop: 'Drop', - cancel: 'Cancel', choose: 'Choose', back: 'Back', close: 'Close', + cancel: 'Cancel', choose: 'Choose', back: 'Back', close: 'Close', pin: 'Pin', unpin: 'Unpin', + }, + pins: { + pinnedAt: n => `pinned at ${englishOrdinal(n)}`, + pinAll: 'Pin the whole row', unpinAll: 'Unpin all', removeAll: 'Remove pinned games from every profile', + removeTitle: 'Remove pinned games from every profile?', + removeBody: "Every profile goes back to the system's rule: the last game opened comes first. " + + 'The current order stays as it is.', + removeConfirm: 'Remove', cancel: 'Cancel', + removed: 'Pinned games removed from every profile.', + removeFailed: detail => `Could not remove the pinned games (${detail}).`, + noAnswer: 'the service did not answer', + missingTitle: 'The pinned games came loose', + missingBody: 'The home screen database was rebuilt or updated, so the games no longer hold their places. ' + + 'Pin them again?', + reapply: 'Pin again', later: 'Not now', }, saved: ['Order saved', 'Closing the app. The home screen comes back with the new order.'], closeNow: 'Close now', @@ -86,7 +101,7 @@ const en = { profilesSubtitle: 'Each profile has its own row', exitDialog: { title: 'Save the new order before leaving?', - body: n => (n === 1 ? 'One game moved.' : `${n} games moved.`), + body: n => (n === 1 ? 'One game changed.' : `${n} games changed.`), save: 'Save and quit', discard: 'Quit without saving', back: 'Back', }, switchDialog: { @@ -124,7 +139,22 @@ const pt = { of: n => `de ${n}`, hints: { lift: 'Levantar', save: 'Salvar', options: 'Opções', quit: 'Sair', move: 'Mover', drop: 'Soltar', - cancel: 'Cancelar', choose: 'Escolher', back: 'Voltar', close: 'Fechar', + cancel: 'Cancelar', choose: 'Escolher', back: 'Voltar', close: 'Fechar', pin: 'Fixar', unpin: 'Desafixar', + }, + pins: { + pinnedAt: n => `fixo no ${n}º`, + pinAll: 'Fixar toda a fileira', unpinAll: 'Desafixar todos', removeAll: 'Remover jogos fixos de todos os perfis', + removeTitle: 'Remover os jogos fixos de todos os perfis?', + removeBody: 'Todos os perfis voltam à regra do sistema: o último jogo aberto vai para a frente. ' + + 'A ordem atual continua como está.', + removeConfirm: 'Remover', cancel: 'Cancelar', + removed: 'Jogos fixos removidos de todos os perfis.', + removeFailed: detail => `Não consegui remover os jogos fixos (${detail}).`, + noAnswer: 'o serviço não respondeu', + missingTitle: 'Os jogos fixos se soltaram', + missingBody: 'O banco de dados da tela inicial foi reconstruído ou atualizado, e os jogos não seguram mais o ' + + 'lugar. Fixar de novo?', + reapply: 'Fixar de novo', later: 'Agora não', }, saved: ['Ordem salva', 'Fechando o app. A tela inicial já volta com a nova ordem.'], closeNow: 'Fechar agora', @@ -173,7 +203,7 @@ const pt = { profilesSubtitle: 'Cada perfil tem a sua fileira', exitDialog: { title: 'Salvar a nova ordem antes de sair?', - body: n => (n === 1 ? 'Um jogo mudou de lugar.' : `${n} jogos mudaram de lugar.`), + body: n => (n === 1 ? 'Um jogo mudou.' : `${n} jogos mudaram.`), save: 'Salvar e sair', discard: 'Sair sem salvar', back: 'Voltar', }, switchDialog: { diff --git a/app/index.jsx b/app/index.jsx index 3a6e5a0..a6f4833 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Lineup reorders the PS5 home-screen game row. The lineupd service (payloads/lineupd.c) reads and -// saves the row; this app draws it, lifts the focused game with X and carries it with the D-pad. +// saves the row; this app draws it, lifts the focused game with X and carries it with the D-pad. Square pins +// the focused game where it stands when the order is saved. // // Focus lives on invisible slots, one per position, laid out in a plain row so the focus manager moves // between them. The games are drawn apart, keyed by title, and only their transforms move: the focused @@ -13,8 +14,8 @@ import {useInterfaceSounds} from './sounds.js'; import * as api from './api.js'; import {LANGUAGE, T} from './i18n.js'; import {BOTTOM, DIGITS, EM, TOP} from './digits.js'; -import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash, - px} from './ui.jsx'; +import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, PinBadge, Sheet, SheetItem, SheetRule, + Toast, Wash, px} from './ui.jsx'; const LEFT = 64; // the focused game's left edge const BIG = 168; // focused icon @@ -35,7 +36,21 @@ const SPRING = {type: 'spring', stiffness: 420, damping: 40}; const TILE = {...SPRING, opacity: {type: 'tween', duration: 0.14}}; const sortKey = name => name.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); -const changed = s => s.order.reduce((count, id, i) => count + (s.home[id] !== i), 0); +// Games moved or pinned/unpinned since the row was read. `pinned` maps the games read pinned to their slots. +const changed = s => s.order.reduce((count, id, i) => count + (s.home[id] !== i || !s.pins[id] !== !(id in s.pinned)), 0); +const allPinned = pinned => Object.fromEntries(Object.keys(pinned).map(id => [id, true])); + +// The order the pins ask for, as lineupd's triggers work it out: pinned games at their slots (packed at the end of +// a row that got shorter), the others in their order in the free places. +function pinnedOrder(order, pinned) { + const fixed = order.filter(id => id in pinned).sort((a, b) => pinned[a] - pinned[b]); + const at = {}; + fixed.forEach((id, k) => { + at[Math.min(pinned[id], order.length - fixed.length + k)] = id; + }); + const rest = order.filter(id => !(id in pinned)); + return order.map((_, i) => at[i] ?? rest.shift()); +} const Slots = memo(function Slots({count, onFocusIndex, onPressIndex}) { const slots = []; @@ -53,7 +68,7 @@ const Slots = memo(function Slots({count, onFocusIndex, onPressIndex}) { // row, and 168 px with its ring for focus. They swap with a short crossfade while scaling into each // other, so at rest neither is scaled or faded. Tiles sit at fixed places in a strip the row slides // as a whole, so a move re-renders only the games whose `place` changed. -const Tile = memo(function Tile({game, index, place, lifted, moved}) { +const Tile = memo(function Tile({game, index, place, lifted, moved, pinned}) { const focused = place === 'focus'; const up = focused && lifted; const x = LEFT + index * STRIDE + (place === 'after' ? BIG - SMALL : 0); @@ -72,6 +87,7 @@ const Tile = memo(function Tile({game, index, place, lifted, moved}) { + {pinned ? : null} {game.name} + {pinned ? : null} {focused && !lifted ? : null} {up ? ( @@ -109,7 +126,7 @@ function Row({s}) { const place = index < s.focus ? 'before' : index > s.focus ? 'after' : 'focus'; return ( + moved={s.home[id] !== index} pinned={!!s.pins[id]} /> ); })} {s.order.length > HOME_LIMIT ? ( @@ -125,12 +142,13 @@ function Row({s}) { const RAIL = 1152; // The whole row at a glance: a hairline ruler with a tick per game (when they fit), the moved games' -// ticks in white and a marker on the focused one. -const Ticks = memo(function Ticks({order, home}) { +// ticks in white, the pinned ones as small white blocks, and a marker on the focused one. +const Ticks = memo(function Ticks({order, home, pins}) { const at = i => Math.round(i * RAIL / Math.max(order.length - 1, 1)); if (order.length > 96) return null; - const ticks = order.map((id, i) => ( - (pins[id] + ? + : )); // Where the home screen stops: a tall mark halfway between the last game it shows and the next. @@ -146,7 +164,7 @@ function Rail({s}) { return ( - + @@ -178,6 +196,7 @@ function Position({s}) { const game = s.games[id]; const was = s.home[id]; const notes = []; + if (s.pins[id]) notes.push(T.pins.pinnedAt(s.focus + 1)); if (was !== s.focus) notes.push(T.before(was + 1)); if (s.focus >= HOME_LIMIT) notes.push(T.offHome); const note = notes.join(', '); @@ -217,7 +236,7 @@ const LIFT_HINTS = [['dpad', H.move], ['cross', H.drop], ['circle', H.cancel]]; 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, + games: {}, home: {}, base: [], order: [], pins: {}, pinned: {}, focus: 0, dir: 1, lifted: false, liftFrom: 0, layer: null, target: null, toast: null, pending: null, progress: null, settings: null, update: null, install: null}; const DAY = 24 * 60 * 60 * 1000; @@ -265,18 +284,21 @@ function App() { }; const loadRow = async (user, profiles) => { - const list = await api.row(user); + const {games: list, pins} = await api.row(user); if (!list.length) throw new api.Problem(...T.emptyRow); const games = {}; const home = {}; + const pinned = {}; list.forEach((game, i) => { games[game.titleId] = game; home[game.titleId] = i; + if (game.pinned !== null && game.pinned !== undefined) pinned[game.titleId] = game.pinned; }); const base = list.map(game => game.titleId); + const missing = pins === 'missing'; light(false); - patch({phase: 'row', profiles, user, games, home, base, order: base, focus: 0, dir: 1, lifted: false, - layer: null, target: null, pending: {index: 0}}); + patch({phase: 'row', profiles, user, games, home, base, order: base, pins: allPinned(pinned), pinned, focus: 0, + dir: 1, lifted: false, layer: missing ? 'pinsMissing' : null, target: null, pending: missing ? null : {index: 0}}); }; const boot = async () => { @@ -374,17 +396,17 @@ function App() { } }; - // Saves, then shows the saved screen, or `next()` instead when switching profiles. - const save = async next => { - const now = ref.current; - if (!changed(now)) { + // Saves, then shows the saved screen, or `next()` instead when switching profiles. `force` saves an unchanged + // row too, to put the pins' triggers back. + const save = async (next, now = ref.current, force = false) => { + if (!changed(now) && !force) { close(); toast(T.alreadySaved); return; } light(false); patch({lifted: false, layer: 'busy'}); - const result = await api.save(now.user, now.order, now.games); + const result = await api.save(now.user, now.order, now.games, now.pins); if (result.ok) { if (next) { toast(T.savedToast); @@ -443,13 +465,39 @@ function App() { save(); return true; } + if (action === 'square') { + patch(p => { + const id = p.order[p.focus]; + const pins = {...p.pins}; + if (pins[id]) delete pins[id]; + else pins[id] = true; + return {pins}; + }); + return true; + } return false; }; const discard = () => { - patch(p => ({order: p.base, layer: null, pending: {index: 0}})); + patch(p => ({order: p.base, pins: allPinned(p.pinned), layer: null, pending: {index: 0}})); toast(T.discarded); }; + const pinAll = on => patch(p => ({pins: on ? Object.fromEntries(p.order.map(id => [id, true])) : {}, layer: null, + pending: {index: p.focus}})); + // Puts the games back at their slots and saves, which creates the triggers again. + const reapply = () => { + const now = ref.current; + const order = pinnedOrder(now.order, now.pinned); + patch({order}); + save(null, {...now, order}, true); + }; + const unpinEverywhere = async () => { + patch({layer: 'busy'}); + const error = await api.unpinAll(); + if (!error) patch({pins: {}, pinned: {}}); + close(); + toast(error || T.pins.removed); + }; const sortAZ = () => patch(p => { const id = p.order[p.focus]; const order = p.order.slice().sort((a, b) => { @@ -509,7 +557,8 @@ function App() { } const count = changed(s); - const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : ROW_HINTS; + const pinHint = ['square', s.pins[s.order[s.focus]] ? H.unpin : H.pin]; + const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : [ROW_HINTS[0], pinHint, ...ROW_HINTS.slice(1)]; 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) : ''] @@ -544,6 +593,8 @@ function App() { detail={T.menu.changes(count)} />, , , + pinAll(true)} />, + pinAll(false)} />, , patch({layer: 'profiles'})} detail={s.profiles.length > 1 ? T.menu.profileCount(s.profiles.length) : ''} />, @@ -551,6 +602,7 @@ function App() { patch({layer: 'update'})} /> ) : null, patch({layer: 'updates'})} />, + patch({layer: 'removePins'})} />, ] : s.profiles.map(item => ( } @@ -586,6 +638,18 @@ function App() {