diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e3c463..a2a6474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,25 @@ # Changelog -## Unreleased - +## 2.0.0 + +- Saving no longer fails with "The row changed since it was read" once apps are pinned. The console renumbers the + pinned apps every half minute while Lineup is open; a save now checks the order, not the exact numbers (#19). +- All Apps, the App Library tile on the Media tab, can be hidden and shown again from Hidden apps, like the PS Store + and the Media Gallery (#14). +- Switching to the Media tab right after Lineup opens waits for the service instead of failing after a few seconds, + and asks again once (#15). +- Shorter Brazilian Portuguese messages, so they fit on the notification (#16). +- The QR code on the star page draws whole, so a phone can read it. It was cut off about halfway (#21). - The Media Gallery moves between the Games and Media tabs (focus it on the row, Options, Move), and stays there - after a restart or rest mode. The PS Store only hides. Game Library and App Library never move or hide. + after a restart or rest mode. The PS Store and App Library only hide. Game Library never moves or hides. - The Media Gallery is on its tab's row: it can be pinned and moved from the row like an app. - Welcome can be shown, on every profile, and hidden again (Options, Hidden apps). -- Every other system tile that is showing can be hidden, and stays hidden. +- Every other system tile that is showing on a tab can be hidden, and stays hidden. +- The Media Gallery goes back to its place in the Games row's system group when it moves back from Media. - Moving an app now moves its concept too, which 1.8.0 left on the old tab. Apps moved before are fixed when Lineup starts. -- More options has Remove TabShifter leftovers: TabShifter's "TS" tile goes from both databases. +- More options has Remove TabShifter leftovers: TabShifter's "TS" tile goes from both databases, and its folders. + The message says when only folders were left. - Backups: the first time this version runs, it keeps both databases for good in /data/lineup/backups (original-app.db, original-appinfo.db), before its own checks write. After an upgrade they already hold what earlier versions of Lineup changed, so they are not a copy from before Lineup. Two backups in the same second no diff --git a/app/api.js b/app/api.js index 981f19b..d90f160 100644 --- a/app/api.js +++ b/app/api.js @@ -3,7 +3,7 @@ import {Archives, Downloads, FileSystem, Http, Payloads} from '@ps5-react/core'; import {T} from './i18n.js'; -export const VERSION = '1.8.0'; +export const VERSION = '2.0.0'; // "local", or the commit of a CI build, as LINEUPD_BUILD in lineupd.c: a service of another build is replaced. export const BUILD = 'local'; const SERVICE = `${VERSION}+${BUILD}`; @@ -85,13 +85,17 @@ export async function ensureService(onStep) { throw problem(T.service.noAnswer); } -// The service's own message is English detail; the title is always in the interface language. -async function read(path, what) { +// The service's own message is English detail; the title is always in the interface language. The service answers one +// request at a time and checks its databases right after starting, so a read can wait behind that: `patient` waits +// longer and asks again once. +async function read(path, what, patient = false) { let reply; - try { - reply = await call(path, {timeoutMs: 8000}); - } catch { - throw problem(T.service.stopped(what)); + for (let attempt = 0; !reply; attempt++) { + try { + reply = await call(path, {timeoutMs: patient ? 20000 : 8000}); + } catch { + if (!patient || attempt) throw problem(T.service.stopped(what)); + } } if (!reply.ok) throw new Problem(T.service.readFailed(what), reply.data.error ?? T.service.status(reply.status)); return reply.data; @@ -103,7 +107,7 @@ export const profiles = async () => (await read('/profiles', T.service.profiles) * (triggers lost) or 'off'. */ export async function row(user, tab = 'games') { - const data = await read(`/row?user=${user}&tab=${tab}`, T.service.row); + const data = await read(`/row?user=${user}&tab=${tab}`, T.service.row, true); return {games: data.games ?? [], pins: data.pins ?? 'off'}; } export const iconUrl = game => `${BASE}/icon/${game.titleId}?m=${game.icon}`; @@ -147,11 +151,11 @@ export async function hidden(user) { return {hidden: data.hidden ?? [], system: data.system ?? []}; } -/** Removes TabShifter's tile from the home screen databases. Resolves to {removed} or {error}. */ +/** Removes TabShifter's tile and folders. Resolves to {removed, folders} (tiles and folders deleted) or {error}. */ export async function removeTabShifter() { try { const reply = await call('/tabshifter/remove', {method: 'POST', timeoutMs: 15000}); - return reply.ok ? {removed: reply.data.removed ?? 0} + return reply.ok ? {removed: reply.data.removed ?? 0, folders: reply.data.folders ?? 0} : {error: T.cleanup.failed(reply.data.error ?? T.service.status(reply.status))}; } catch { return {error: T.cleanup.failed(T.pins.noAnswer)}; diff --git a/app/app.json b/app/app.json index 42ccfd6..46d36d1 100644 --- a/app/app.json +++ b/app/app.json @@ -3,7 +3,7 @@ "titleId": "PPSA99077", "contentId": "UP9000-PPSA99077_00-LINEUP0000000000", "name": "Lineup", - "version": "01.008.000", + "version": "02.000.000", "render": { "width": 1920, "height": 1080 diff --git a/app/i18n.js b/app/i18n.js index 5b319d9..895b9ae 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -71,6 +71,7 @@ const en = { moveTo: tab => `Move to ${tab}`, hide: 'Hide from the home screen', store: 'PS Store', storeHidden: 'Hidden', storeShown: 'Showing', gallery: 'Media Gallery', galleryHidden: 'Hidden', galleryShown: 'Showing', + library: 'All Apps', libraryHidden: 'Hidden', libraryShown: 'Showing', welcome: 'Welcome, on every profile', welcomeShown: 'Showing', welcomeHidden: 'Hidden', systemLabel: 'Other system tiles', hideTile: 'Hide', show: 'Bring back', @@ -94,8 +95,8 @@ const en = { body: 'Lineup backs up both home screen databases first, then takes TabShifter\'s "TS" tile out of them and ' + 'deletes its folders. Do this only after deleting TabShifter.', confirm: 'Remove', cancel: 'Cancel', - done: removed => (removed ? "TabShifter's tile was removed. The home screen updates when Lineup closes." - : 'No TabShifter tile was found.'), + done: (removed, folders) => (removed ? "TabShifter's tile was removed. The home screen updates when Lineup closes." + : folders ? 'No TabShifter tile was left; its folders were deleted.' : 'No TabShifter tile was found.'), failed: detail => `Nothing was removed (${detail}).`, }, report: { @@ -189,9 +190,9 @@ const pt = { profiles: 'os perfis', row: 'a fileira', hidden: 'os apps escondidos', - saveNoAnswer: 'O serviço não respondeu ao salvar. Abra o app de novo para conferir a ordem.', + saveNoAnswer: 'O serviço não respondeu. Reabra o app e confira a ordem.', saveStatus: code => `O serviço respondeu ${code}. Nada foi gravado.`, - stale: 'A fileira mudou desde que foi lida. Ela foi carregada de novo.', + stale: 'A fileira mudou e foi carregada de novo.', }, emptyRow: ['A fileira está vazia', 'Este perfil não tem jogos na tela inicial.'], noProfiles: ['Nenhum perfil encontrado', 'O serviço não achou perfis com fileira na tela inicial.'], @@ -217,7 +218,7 @@ const pt = { missingBody: 'O banco de dados da tela inicial foi reconstruído ou atualizado, e os apps não seguram mais o ' + 'lugar. Fixar de novo?', reapply: 'Fixar de novo', later: 'Agora não', - keepFree: 'Deixe um lugar da tela inicial sem fixar, para o último app aberto.', + keepFree: 'Deixe um lugar livre para o último app aberto.', fullTitle: 'Todos os lugares da tela inicial estão fixos', fullBody: 'O último app aberto fica sem lugar ali e vai parar na biblioteca. ' + 'Liberar o primeiro lugar para ele?', @@ -234,6 +235,7 @@ const pt = { moveTo: tab => `Mover para ${tab}`, hide: 'Esconder da tela inicial', store: 'PS Store', storeHidden: 'Escondida', storeShown: 'Aparecendo', gallery: 'Galeria de mídia', galleryHidden: 'Escondida', galleryShown: 'Aparecendo', + library: 'Todos os apps', libraryHidden: 'Escondido', libraryShown: 'Aparecendo', welcome: 'Boas-vindas, em todos os perfis', welcomeShown: 'Aparecendo', welcomeHidden: 'Escondida', systemLabel: 'Outros blocos do sistema', hideTile: 'Esconder', show: 'Trazer de volta', @@ -244,10 +246,10 @@ const pt = { moveBody: 'Ele muda em todos os perfis e perde o lugar fixo. Dá para mover de volta do mesmo jeito.', confirmHide: 'Esconder', confirmMove: 'Mover', cancel: 'Cancelar', done: { - hide: name => `${name} escondido. A tela inicial atualiza quando o Lineup fechar.`, - show: name => `${name} voltou. A tela inicial atualiza quando o Lineup fechar.`, - games: name => `${name} foi para Jogos. A tela inicial atualiza quando o Lineup fechar.`, - media: name => `${name} foi para Mídia. A tela inicial atualiza quando o Lineup fechar.`, + hide: name => `${name} escondido. A tela inicial atualiza ao fechar o Lineup.`, + show: name => `${name} voltou. A tela inicial atualiza ao fechar o Lineup.`, + games: name => `${name} foi para Jogos. A tela inicial atualiza ao fechar o Lineup.`, + media: name => `${name} foi para Mídia. A tela inicial atualiza ao fechar o Lineup.`, }, failed: detail => `Nada mudou (${detail}).`, }, @@ -257,8 +259,9 @@ const pt = { body: 'O Lineup faz backup dos dois bancos da tela inicial antes, tira deles o bloco "TS" do TabShifter e apaga ' + 'as pastas dele. Faça isso só depois de apagar o TabShifter.', confirm: 'Remover', cancel: 'Cancelar', - done: removed => (removed ? 'O bloco do TabShifter foi removido. A tela inicial atualiza quando o Lineup fechar.' - : 'Nenhum bloco do TabShifter foi encontrado.'), + done: (removed, folders) => (removed ? 'O bloco do TabShifter foi removido. A tela inicial atualiza ao fechar o Lineup.' + : folders ? 'Não havia bloco do TabShifter; as pastas foram apagadas.' + : 'Nenhum bloco do TabShifter foi encontrado.'), failed: detail => `Nada foi removido (${detail}).`, }, report: { diff --git a/app/index.jsx b/app/index.jsx index 37e5eda..90b6aa0 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -275,6 +275,7 @@ const PICK_HINTS = [['cross', H.choose], ['circle', H.back]]; const STORE = 'NPXS40047'; const GALLERY = 'NPXS40075'; const WELCOME = 'NPXS40063'; +const APP_LIBRARY = 'NPXS40139'; // The side sheet's pages: title and subtitle. The menu and Updates work out their own subtitle. const SHEETS = {menu: [T.menu.title], profiles: [T.profilesTitle, T.profilesSubtitle], updates: [T.updates.title], @@ -631,7 +632,7 @@ function App() { const removeTabShifter = async () => { const p = ref.current; patch({layer: 'busy'}); - const {removed, error} = await api.removeTabShifter(); + const {removed, folders, error} = await api.removeTabShifter(); if (!error && removed) { const keep = {focus: p.focus}; try { @@ -642,7 +643,7 @@ function App() { } } close(); - toast(error || T.cleanup.done(removed)); + toast(error || T.cleanup.done(removed, folders)); }; const askApp = layer => { if (changed(ref.current)) toast(T.tabs.saveFirst); @@ -669,11 +670,12 @@ function App() { const hiddenApps = s.hidden ?? []; const storeHidden = hiddenApps.some(app => app.titleId === STORE); const galleryHidden = hiddenApps.some(app => app.titleId === GALLERY); - const otherHidden = hiddenApps.filter(app => app.titleId !== STORE && app.titleId !== GALLERY); + const libraryHidden = hiddenApps.some(app => app.titleId === APP_LIBRARY); + const otherHidden = hiddenApps.filter(app => ![STORE, GALLERY, APP_LIBRARY].includes(app.titleId)); const otherTab = s.tab === 'games' ? 'media' : 'games'; // Welcome shows on every profile; the other system tiles showing can hide on this one. const welcome = (s.system ?? []).find(tile => tile.titleId === WELCOME); - const otherSystem = (s.system ?? []).filter(tile => tile.visible && ![STORE, GALLERY, WELCOME].includes(tile.titleId)); + const otherSystem = (s.system ?? []).filter(tile => tile.visible && ![STORE, GALLERY, WELCOME, APP_LIBRARY].includes(tile.titleId)); if (s.phase === 'loading') { return ( @@ -745,6 +747,8 @@ function App() { onPress={() => changeApp(STORE, storeHidden ? 'show' : 'hide', T.apps.store)} />, changeApp(GALLERY, galleryHidden ? 'show' : 'hide', T.apps.gallery)} />, + changeApp(APP_LIBRARY, libraryHidden ? 'show' : 'hide', T.apps.library)} />, welcome && (welcome.shown || welcome.tab !== 'none') ? ( - - - + + {qr.PATHS.map(d => ( + + + + + + ))} + , {T.star.body}, patch({layer: 'more'})} />, diff --git a/app/payloads/lineupd.c b/app/payloads/lineupd.c index 2618ad9..9cb1bf6 100644 --- a/app/payloads/lineupd.c +++ b/app/payloads/lineupd.c @@ -19,11 +19,13 @@ // 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 and appinfo.db // GET /hidden?user=<10 dig> {"hidden":[{"titleId","name"}],"system":[{"titleId","tab"}]}, what Lineup hid on -// that profile, and the PS Store's and Media Gallery's tabs (games, media or none) +// that profile, and the system tiles showing on a tab, the PS Store, the Media +// Gallery, App Library and Welcome with their tabs (games, media or none) // POST /app?user=<10 dig>&title=&action=hide|show|games|media X-Lineup: 1; hides or shows an app on that // profile, or moves it to that tab on every profile // The PS Store and the Media Gallery move too (move_system); not the libraries -// POST /tabshifter/remove X-Lineup: 1; {"ok","removed","backup"}, takes TabShifter's TBSH00001 tile out +// POST /tabshifter/remove X-Lineup: 1; {"ok","removed","folders","backup"}, takes TabShifter's TBSH00001 tile +// out: removed counts the rows, folders the folders that were there and went // POST /report X-Lineup: 1; writes /data/lineup/report.json, the Games and Media tabs' apps // POST /quit X-Lineup: 1; exits after answering // GET /update/install X-Lineup: 1; {"kind":"folder"|"image","path","previous"} @@ -68,7 +70,7 @@ #define LOG(fmt, ...) klog_printf("[lineupd] " fmt "\n", ##__VA_ARGS__) #endif -#define LINEUPD_VERSION "1.8.0" +#define LINEUPD_VERSION "2.0.0" // Which build this is: "local", or the commit for a CI build (.github/workflows/ci.yml writes it, with BUILD in // app/api.js). Builds of one version answer /version alike, so without it the app kept talking to a lineupd left // running by an earlier test build. @@ -163,16 +165,22 @@ static int by_index_descending(const void* a, const void* b) { } // Fills `out` with the index each posted game gets: the current rows' indices, largest first, in the -// posted order. Fails when the posted list is not exactly the current rows with the indices read. +// posted order. Fails when the posted list is not exactly the current rows, or when they no longer stand in the +// order read. Only the order counts: while Lineup is open the console raises lastAccessIndex every half minute or +// so, and lineup_pin_u renumbers the pinned apps, so the indices read go stale while the row stays the same. static int plan_reorder(const Row* current, int count, const Row* posted, int posted_count, long long* out) { - if (count != posted_count || count <= 0) return -1; + static int at[MAX_GAMES]; + if (count != posted_count || count <= 0 || count > MAX_GAMES) return -1; for (int i = 0; i < posted_count; ++i) { - int found = 0; - for (int j = 0; j < count; ++j) - if (!strcmp(posted[i].title, current[j].title)) { found = current[j].index == posted[i].index; break; } - if (!found) return -1; + at[i] = -1; + for (int j = 0; j < count; ++j) if (!strcmp(posted[i].title, current[j].title)) { at[i] = j; break; } + if (at[i] < 0) return -1; for (int j = 0; j < i; ++j) if (!strcmp(posted[i].title, posted[j].title)) return -1; } + // Read before another, an app must still be ahead of it. + for (int i = 0; i < posted_count; ++i) + for (int j = 0; j < posted_count; ++j) + if (posted[i].index > posted[j].index && current[at[i]].index < current[at[j]].index) return -1; for (int i = 0; i < count; ++i) out[i] = current[i].index; qsort(out, (size_t)count, sizeof *out, by_index_descending); return 0; @@ -629,13 +637,15 @@ static const char* read_pins(sqlite3* db, const char* user, const Row* rows, int // again, whether the system updates the row or writes it anew. They read lineup_hidden: a write to the profile's // table fails if the table goes before them. -// The system's tiles have places of their own, off both tabs, and Lineup can hide them too: all but Game Library -// and App Library, which stay, and Welcome, which Lineup shows instead (see show_welcome). +// The system's tiles have places of their own, off both tabs, and Lineup can hide those on a tab too: all but Game +// Library, which stays, and Welcome, which Lineup shows instead (see show_welcome). The PS Store, the Media Gallery +// and App Library hide whatever their tab. #define STORE_TITLE "NPXS40047" #define GALLERY_TITLE "NPXS40075" #define GAME_LIBRARY_TITLE "NPXS40071" #define APP_LIBRARY_TITLE "NPXS40139" -#define KEPT_SYSTEM "('" GAME_LIBRARY_TITLE "', '" APP_LIBRARY_TITLE "', 'NPXS40063')" +#define KEPT_SYSTEM "('" GAME_LIBRARY_TITLE "', 'NPXS40063')" +#define LISTED_SYSTEM "('" STORE_TITLE "', '" GALLERY_TITLE "', '" APP_LIBRARY_TITLE "')" #define HIDE_TRIGGER(kind, event) \ "CREATE TRIGGER IF NOT EXISTS lineup_hide_" kind "_%1$s AFTER " event " ON tbl_iconinfo_%1$s WHEN NEW.visible = 1 AND " \ "NEW.titleId IN (SELECT titleId FROM lineup_hidden WHERE user = '%1$s') BEGIN UPDATE tbl_iconinfo_%1$s SET visible = 0 " \ @@ -701,7 +711,7 @@ static int forget_pin(sqlite3* db, const char* user, const char* title) { static int set_hidden(const char* user, const char* title, int hide, char* backup, size_t backup_size, char* message, size_t message_size) { if (hide && !strcmp(title, TITLE_ID)) { snprintf(message, message_size, "Lineup cannot hide itself."); return 400; } - if (!strcmp(title, GAME_LIBRARY_TITLE) || !strcmp(title, APP_LIBRARY_TITLE) || !strcmp(title, "NPXS40063")) { + if (!strcmp(title, GAME_LIBRARY_TITLE) || !strcmp(title, "NPXS40063")) { snprintf(message, message_size, "That tile is not hidden by Lineup."); return 400; } @@ -716,12 +726,16 @@ static int set_hidden(const char* user, const char* title, int hide, char* backu goto done; } { - char sql[512]; + char sql[768]; int changed, failed = 0; if (hide) { + // Another system tile only from a tab: one off both never shows on the home screen. snprintf(sql, sizeof sql, "UPDATE tbl_iconinfo_%s SET visible = 0 WHERE titleId = ?1 AND visible = 1 AND " - "(dispLocation IN (%d, %d) OR (titleId GLOB 'NPXS*' AND titleId NOT IN " KEPT_SYSTEM "))", user, GAMES_TAB, - MEDIA_TAB); + "(dispLocation IN (%d, %d) OR titleId IN " LISTED_SYSTEM " OR (titleId GLOB 'NPXS*' AND titleId NOT IN " + KEPT_SYSTEM " AND (%s & 6) <> 0))", user, GAMES_TAB, MEDIA_TAB, + has_column(db, "main", "tbl_contentinfo", "dispLocation") + ? "ifnull((SELECT c.dispLocation FROM tbl_contentinfo c WHERE c.titleId = ?1), dispLocation)" + : "dispLocation"); changed = run_title(db, sql, user, title); failed = changed < 0 || (changed && (sqlite3_exec(db, "CREATE TABLE IF NOT EXISTS lineup_hidden (user TEXT, titleId " "TEXT, PRIMARY KEY (user, titleId))", NULL, NULL, NULL) != SQLITE_OK || @@ -1426,24 +1440,26 @@ static int system_triggers(sqlite3* db, char users[][16], int count, int create) return missing; } -// Inside a transaction, with appinfo.db attached as info: removes the system tiles' triggers and tables, in both. +// Inside a transaction, with appinfo.db attached as info: removes the system tiles' triggers and tables, in both, +// and the MyHome rows kept in lineup_home_. static int drop_system(sqlite3* db) { - static char names[4 * MAX_GAMES + 16][96]; + static char names[5 * MAX_GAMES + 16][96]; int count = 0; - for (int schema = 0; schema < 2; ++schema) { + for (int kind = 0; kind < 3; ++kind) { char sql[160]; - snprintf(sql, sizeof sql, "SELECT name FROM %s.sqlite_master WHERE type = 'trigger' AND name GLOB 'lineup_move_sys_*'", - schema ? "info" : "main"); + snprintf(sql, sizeof sql, "SELECT name FROM %s.sqlite_master WHERE type = '%s' AND name GLOB '%s'", + kind == 1 ? "info" : "main", kind == 2 ? "table" : "trigger", + kind == 2 ? "lineup_home_*" : "lineup_move_sys_*"); sqlite3_stmt* statement; if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) != SQLITE_OK) return -1; while (count < (int)(sizeof names / sizeof names[0]) && sqlite3_step(statement) == SQLITE_ROW) - snprintf(names[count++], sizeof names[0], "%s.\"%s\"", schema ? "info" : "main", - (const char*)sqlite3_column_text(statement, 0)); + snprintf(names[count++], sizeof names[0], "%s %s.\"%s\"", kind == 2 ? "TABLE" : "TRIGGER", + kind == 1 ? "info" : "main", (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]); + snprintf(sql, sizeof sql, "DROP %s", names[i]); if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) return -1; } return sqlite3_exec(db, "DROP TABLE IF EXISTS main.lineup_system; DROP TABLE IF EXISTS info.lineup_system;", NULL, @@ -1496,12 +1512,28 @@ static int write_system(sqlite3* db, const char* title, const char* concept, int "NULL AND AppInfoJson ->> '$.DISPLAYLOCATION' IS NOT dispLocation", 0, title, NULL) < 0) return -1; if (*concept && fix_concept_json(db, concept)) return -1; // Off the Games row, a tile leaves each profile's MyHome group, which places the system's tiles there; the rest - // keep their order. + // keep their order. lineup_home_ keeps its row, every column of it, with idx its place counted from 1, for + // return_home. for (int u = 0; set == BIT_MEDIA && u < count; ++u) { - char table[64], sql[300]; + char table[64], home[64], sql[300]; snprintf(table, sizeof table, "tbl_group_%s", users[u]); + snprintf(home, sizeof home, "lineup_home_%s", users[u]); if (!has_column(db, "main", table, "itemId") || !has_column(db, "main", table, "groupName") || !has_column(db, "main", table, "idx")) continue; + snprintf(sql, sizeof sql, "SELECT 1 FROM %s WHERE groupName = 'MyHome' AND itemId = ?1", table); + int found = finds_row(db, sql, title); + if (found < 0) return -1; + if (found) { + snprintf(sql, sizeof sql, "CREATE TABLE IF NOT EXISTS main.%s AS SELECT * FROM %s WHERE 0", home, table); + if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) return -1; + snprintf(sql, sizeof sql, "DELETE FROM %s WHERE itemId = ?2", home); + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + snprintf(sql, sizeof sql, "INSERT INTO %s SELECT * FROM %s WHERE groupName = 'MyHome' AND itemId = ?2", home, table); + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + snprintf(sql, sizeof sql, "UPDATE %1$s SET idx = (SELECT COUNT(*) FROM %2$s g WHERE g.groupName = 'MyHome' AND " + "g.idx <= %1$s.idx) WHERE itemId = ?2", home, table); + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + } snprintf(sql, sizeof sql, "DELETE FROM %s WHERE groupName = 'MyHome' AND itemId = ?2", table); int removed = run_values(db, sql, 0, title, NULL); if (removed < 0) return -1; @@ -1512,6 +1544,48 @@ static int write_system(sqlite3* db, const char* title, const char* concept, int return 0; } +// Inside a transaction: puts `title`, back on the Games row, in each profile's MyHome group where it left (its row +// kept in lineup_home_), or last when nothing was kept, and lets go of the kept row. The rest keep their order. +static int return_home(sqlite3* db, const char* title, char users[][16], int count) { + for (int u = 0; u < count; ++u) { + char table[64], home[64], sql[300]; + snprintf(table, sizeof table, "tbl_group_%s", users[u]); + snprintf(home, sizeof home, "lineup_home_%s", users[u]); + if (!has_column(db, "main", table, "itemId") || !has_column(db, "main", table, "groupName") || + !has_column(db, "main", table, "idx")) continue; + snprintf(sql, sizeof sql, "SELECT 1 FROM %s WHERE groupName = 'MyHome' AND itemId = ?1", table); + int there = finds_row(db, sql, title), kept = 0; + if (has_object_in(db, "main", home)) { + snprintf(sql, sizeof sql, "SELECT 1 FROM %s WHERE itemId = ?1", home); + kept = finds_row(db, sql, title); + } + if (there < 0 || kept < 0) return -1; + if (!there) { + if (kept) { + snprintf(sql, sizeof sql, "UPDATE %1$s SET idx = idx + 1 WHERE groupName = 'MyHome' AND idx >= (SELECT idx FROM " + "%2$s WHERE itemId = ?2)", table, home); + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + snprintf(sql, sizeof sql, "INSERT INTO %s SELECT * FROM %s WHERE itemId = ?2", table, home); + } else { + snprintf(sql, sizeof sql, "INSERT INTO %1$s (itemId, groupName, idx) VALUES (?2, 'MyHome', (SELECT ifnull(MAX(idx), " + "0) + 1 FROM %1$s WHERE groupName = 'MyHome'))", table); + } + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + snprintf(sql, sizeof sql, "UPDATE %1$s SET idx = (SELECT COUNT(*) FROM %1$s g WHERE g.groupName = 'MyHome' AND " + "g.idx <= %1$s.idx) WHERE groupName = 'MyHome'", table); + if (run_values(db, sql, 0, NULL, NULL) < 0) return -1; + } + if (!kept) continue; + snprintf(sql, sizeof sql, "DELETE FROM %s WHERE itemId = ?2", home); + if (run_values(db, sql, 0, title, NULL) < 0) return -1; + snprintf(sql, sizeof sql, "SELECT 1 FROM %s LIMIT 1", home); + int left = finds_row(db, sql, NULL); + snprintf(sql, sizeof sql, "DROP TABLE main.%s", home); + if (left < 0 || (!left && sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK)) return -1; + } + return 0; +} + // Inside a transaction: `title`'s visible on every profile. static int system_visible(sqlite3* db, const char* title, int visible, char users[][16], int count) { for (int u = 0; u < count; ++u) { @@ -1622,7 +1696,9 @@ static int move_system(const char* title, int media, char* backup, size_t backup return 409; } int home = system_home(db, title, from); - int failed = (home == bit ? forget_system(db, title) + // Back in MyHome first: forgetting the last tile drops what was kept of it. + int failed = (bit == BIT_GAMES && return_home(db, title, users, count)) || + (home == bit ? forget_system(db, title) : keep_system(db, title, concept, bit, 6 - bit, home, 0, users, count)) || write_system(db, title, concept, bit, 6 - bit, 6 - bit, users, count); // A pin is a place on the tab the tile just left. @@ -1885,7 +1961,10 @@ static int unpin(const char* user, char* backup, size_t backup_size, char* messa 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; } - int moves = !user && has_object(db, "lineup_moved"), tiles = !user && has_object(db, "lineup_system"); + int moves = !user && has_object(db, "lineup_moved"); + int tiles = !user && (has_object(db, "lineup_system") || + finds_row(db, "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name GLOB 'lineup_home_*'", + NULL) > 0); if (check_and_backup(db, backup, backup_size, message, message_size) || ((moves || tiles) && (attach_appinfo(db, message, message_size) || backup_appinfo(db, backup, message, message_size)))) goto done; @@ -2194,7 +2273,7 @@ static int write_report(char* path, size_t path_size, char* message, size_t mess #define TABSHIFTER_TITLE "TBSH00001" #define TABSHIFTER_CONCEPT "cid:local:" TABSHIFTER_TITLE -static const char* const tabshifter_dirs[] = {"/user/app/" TABSHIFTER_TITLE, "/user/appmeta/" TABSHIFTER_TITLE, +static const char* tabshifter_dirs[] = {"/user/app/" TABSHIFTER_TITLE, "/user/appmeta/" TABSHIFTER_TITLE, "/system_data/priv/appmeta/" TABSHIFTER_TITLE}; // Deletes the rows of `schema`.`table` whose `column` is `value`, when the table has that column. Returns the rows @@ -2235,10 +2314,11 @@ static int tabshifter_rows(sqlite3* db, char users[][16], int count) { return total; } -// Removes TabShifter's tile, after backing up both databases. `removed` gets the rows taken out. Returns the HTTP -// status; `message` explains a failure. -static int remove_tabshifter(int* removed, char* backup, size_t backup_size, char* message, size_t message_size) { - *removed = 0; +// Removes TabShifter's tile, after backing up both databases. `removed` gets the rows taken out, `folders` the folders +// that were there and went. Returns the HTTP status; `message` explains a failure. +static int remove_tabshifter(int* removed, int* folders, char* backup, size_t backup_size, char* message, + size_t message_size) { + *removed = *folders = 0; sqlite3* db = open_db(1); if (!db) { snprintf(message, message_size, "Could not open the home screen database."); return 500; } int status = 500, began = 0, failed = 0; @@ -2292,8 +2372,12 @@ static int remove_tabshifter(int* removed, char* backup, size_t backup_size, cha began = 0; } // The folders, once no row points at them. - for (int i = 0; i < 3; ++i) + for (int i = 0; i < 3; ++i) { + struct stat info; + if (lstat(tabshifter_dirs[i], &info)) continue; if (remove_tree(tabshifter_dirs[i])) LOG("could not remove %s: %s", tabshifter_dirs[i], strerror(errno)); + else ++*folders; + } status = 200; done: if (began) sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); @@ -2444,16 +2528,17 @@ static void get_hidden(int socket, const char* user) { } sqlite3_finalize(statement); } - // The system's tiles Lineup manages on this profile: those showing (which can hide), and the PS Store, the Media - // Gallery and Welcome whatever their state. tab is games, media or none (off both); shown, that Lineup shows it. + // The system's tiles Lineup manages on this profile: those showing on a tab (which can hide), and the PS Store, the + // Media Gallery, App Library and Welcome whatever their state. tab is games, media or none (off both); shown, that + // Lineup shows it. puts_(&body, "],\"system\":["); - char tiles[700]; + char tiles[1024]; int content = has_column(db, "main", "tbl_contentinfo", "dispLocation"); int kept = has_object_in(db, "main", "lineup_system"); - snprintf(tiles, sizeof tiles, "SELECT i.titleId, ifnull(i.titleName, i.titleId), i.visible, %s, %s FROM " - "tbl_iconinfo_%s i WHERE i.titleId GLOB 'NPXS*' AND i.titleId NOT IN ('" GAME_LIBRARY_TITLE "', '" - APP_LIBRARY_TITLE "') AND (i.visible = 1 OR i.titleId IN ('" STORE_TITLE "', '" GALLERY_TITLE "', '" - WELCOME_TITLE "')) ORDER BY i.titleName", + snprintf(tiles, sizeof tiles, "SELECT i.titleId, ifnull(i.titleName, i.titleId), i.visible, %1$s, %2$s FROM " + "tbl_iconinfo_%3$s i WHERE i.titleId GLOB 'NPXS*' AND i.titleId <> '" GAME_LIBRARY_TITLE "' AND " + "((i.visible = 1 AND (%1$s & 6) <> 0) OR i.titleId IN " LISTED_SYSTEM " OR i.titleId = '" WELCOME_TITLE "') " + "ORDER BY i.titleName", content ? "ifnull((SELECT c.dispLocation FROM tbl_contentinfo c WHERE c.titleId = i.titleId), i.dispLocation)" : "i.dispLocation", kept ? "EXISTS (SELECT 1 FROM lineup_system s WHERE s.titleId = i.titleId AND s.visible = 1)" : "0", user); @@ -2478,11 +2563,12 @@ static void get_hidden(int socket, const char* user) { static void post_tabshifter(int socket) { char backup[256] = "", message[300] = ""; - int removed = 0, status = remove_tabshifter(&removed, backup, sizeof backup, message, sizeof message); + int removed = 0, folders = 0; + int status = remove_tabshifter(&removed, &folders, backup, sizeof backup, message, sizeof message); if (status != 200) { respond_error(socket, status, message); return; } - LOG("TabShifter's tile: %d rows removed, backup %s", removed, *backup ? backup : "none"); + LOG("TabShifter's tile: %d rows and %d folders removed, backup %s", removed, folders, *backup ? backup : "none"); Buffer reply = {0}; - putf(&reply, "{\"ok\":true,\"removed\":%d,\"backup\":", removed); + putf(&reply, "{\"ok\":true,\"removed\":%d,\"folders\":%d,\"backup\":", removed, folders); put_json_string(&reply, backup); puts_(&reply, "}"); respond_json(socket, 200, &reply); @@ -3489,6 +3575,30 @@ static void test_pins(void) { 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); + // The console raises an app while Lineup is open and lineup_pin_u renumbers the pinned ones: the row read still + // saves while its order holds, and not once an app opened moves ahead. + { + static Row read[MAX_GAMES], again[MAX_GAMES]; + char backup[256], integrity[256], message[300]; + sqlite3* db = open_db(0); + int count = read_tab(db, one, GAMES_TAB, read, NULL, MAX_GAMES); + sqlite3_close(db); + for (int i = 0; i < count; ++i) read[i].pinned = read[i].title[8] == '3' || read[i].title[8] == '5'; + open_game(one, "PPSA00003"); + expect_row(one, "31254"); + db = open_db(0); + assert(read_tab(db, one, GAMES_TAB, again, NULL, MAX_GAMES) == count && again[0].index != read[0].index); + sqlite3_close(db); + assert(save_order(one, GAMES_TAB, read, count, backup, sizeof backup, integrity, sizeof integrity, message, + sizeof message) == 200); + expect_row(one, "31254"); + open_game(one, "PPSA00004"); + expect_row(one, "34152"); + assert(save_order(one, GAMES_TAB, read, count, backup, sizeof backup, integrity, sizeof integrity, message, + sizeof message) == 409); + save_digits(one, "31254", "35"); + expect_row(one, "31254"); + } // 1.4.2's update trigger, which ran for the Media tab too, is replaced once; the current ones stay. { static char old[16 * 1024]; @@ -3603,8 +3713,11 @@ static void test_pins(void) { assert(!scalar("SELECT COUNT(*) FROM lineup_pin WHERE titleId = 'PPSA00005'")); assert(set_hidden(one, "PPSA00005", 1, backup, sizeof backup, message, sizeof message) == 409); run("INSERT INTO tbl_iconinfo_%1$s (titleId, titleName, lastAccessIndex, visible, dispLocation) VALUES " - "('NPXS40047','Store',1,1,146),('NPXS40037','Plus',1,0,147),('NPXS40071','Library',1,1,146)", one, ""); + "('NPXS40047','Store',1,1,146),('NPXS40037','Plus',1,0,147),('NPXS40071','Library',1,1,146)," + "('NPXS40010','Off',1,1,128)", one, ""); assert(set_hidden(one, "NPXS40071", 1, backup, sizeof backup, message, sizeof message) == 400); + // A system tile off both tabs never shows on the home screen, so it does not hide either. + assert(set_hidden(one, "NPXS40010", 1, backup, sizeof backup, message, sizeof message) == 409); assert(set_hidden(one, "NPXS40037", 0, backup, sizeof backup, message, sizeof message) == 409); assert(set_hidden(one, STORE_TITLE, 1, backup, sizeof backup, message, sizeof message) == 200); assert(scalar("SELECT COUNT(*) FROM lineup_hidden") == 2); @@ -3918,11 +4031,18 @@ static void test_system(const char* user) { struct stat first, again; assert(!stat(path, &first) && !backup_originals() && !stat(path, &again) && first.st_mtime == again.st_mtime); - // The libraries never move or hide. + // The libraries never move, and Game Library never hides; App Library hides, stays hidden when the system shows it + // again, and comes back. assert(move_system("NPXS40071", 1, backup, sizeof backup, message, sizeof message) == 400); assert(move_system("NPXS40139", 0, backup, sizeof backup, message, sizeof message) == 400); assert(set_hidden(user, "NPXS40071", 1, backup, sizeof backup, message, sizeof message) == 400); - assert(set_hidden(user, "NPXS40139", 1, backup, sizeof backup, message, sizeof message) == 400); + assert(set_hidden(user, APP_LIBRARY_TITLE, 1, backup, sizeof backup, message, sizeof message) == 200); + run("UPDATE tbl_iconinfo_%1$s SET visible = 1 WHERE titleId = '%2$s'", user, APP_LIBRARY_TITLE); + assert(!scalar_of("SELECT visible FROM tbl_iconinfo_%1$s WHERE titleId = '%2$s'", user, APP_LIBRARY_TITLE)); + assert(move_system(APP_LIBRARY_TITLE, 0, backup, sizeof backup, message, sizeof message) == 400); + assert(set_hidden(user, APP_LIBRARY_TITLE, 0, backup, sizeof backup, message, sizeof message) == 200); + assert(scalar_of("SELECT visible FROM tbl_iconinfo_%1$s WHERE titleId = '%2$s'", user, APP_LIBRARY_TITLE) == 1); + expect_system(user, APP_LIBRARY_TITLE, 148, 2196); assert(set_hidden(user, WELCOME_TITLE, 1, backup, sizeof backup, message, sizeof message) == 400); assert(move_app("NPXS40071", MEDIA_TAB, backup, sizeof backup, message, sizeof message) != 200); @@ -3931,6 +4051,11 @@ static void test_system(const char* user) { expect_system(user, STORE_TITLE, 146, 2194); assert(move_system(STORE_TITLE, 1, backup, sizeof backup, message, sizeof message) == 400); assert(move_system(GALLERY_TITLE, 0, backup, sizeof backup, message, sizeof message) == 409); + // The Gallery's place in MyHome counted from 1, 0 when it is not there; the group's size. + const char* home_place = "SELECT COUNT(*) FROM tbl_group_%1$s g WHERE g.groupName = 'MyHome' AND g.idx <= (SELECT idx " + "FROM tbl_group_%1$s WHERE groupName = 'MyHome' AND itemId = '%2$s')"; + const char* home_size = "SELECT COUNT(*) FROM tbl_group_%1$s WHERE groupName = 'MyHome'"; + long long gallery_home = scalar_of(home_place, user, GALLERY_TITLE), home_count = scalar_of(home_size, user, ""); assert(move_system(GALLERY_TITLE, 1, backup, sizeof backup, message, sizeof message) == 200); snprintf(path, sizeof path, "%.*s.appinfo.db", (int)strlen(backup) - 3, backup); assert(integrity_ok(backup) && integrity_ok(path)); @@ -4088,9 +4213,22 @@ static void test_system(const char* user) { open_game(user, other); assert(tab_rows(user, MEDIA_TAB, rows) == count && !strcmp(rows[0].title, GALLERY_TITLE) && !strcmp(rows[1].title, other)); - // Moved back to Games, it loses its pin and joins that row. + // Moved back to Games, it loses its pin and joins that row, and MyHome again in the place it left (last when it + // was not there), the others in order from 1. assert(move_system(GALLERY_TITLE, 0, backup, sizeof backup, message, sizeof message) == 200); assert(tab_rows(user, MEDIA_TAB, rows) == count - 1); + long long size = scalar_of(home_size, user, ""); + assert(size == home_count + !gallery_home); + assert(scalar_of(home_place, user, GALLERY_TITLE) == (gallery_home ? gallery_home : size)); + assert(scalar_of("SELECT idx FROM tbl_group_%1$s WHERE groupName = 'MyHome' AND itemId = '%2$s'", user, + GALLERY_TITLE) == (gallery_home ? gallery_home : size)); + assert(!scalar_of("SELECT COUNT(*) FROM tbl_group_%1$s g WHERE g.groupName = 'MyHome' AND g.idx <> (SELECT COUNT(*) " + "FROM tbl_group_%1$s o WHERE o.groupName = 'MyHome' AND o.idx <= g.idx)", user, "")); + assert(!scalar("SELECT COUNT(*) FROM sqlite_master WHERE name GLOB 'lineup_home_*'")); + // On the synthetic databases, the whole group. + assert(strcmp(user, "0000000007") || + scalar_of("SELECT group_concat(itemId, '|') = 'PPSA00001|NPXS40075|NPXS40071' FROM (SELECT itemId FROM " + "tbl_group_%1$s WHERE groupName = 'MyHome' ORDER BY idx)", user, "")); assert(!scalar_of("SELECT COUNT(*) FROM sqlite_master WHERE name = 'lineup_pin'", user, "") || !scalar_of("SELECT COUNT(*) FROM lineup_pin WHERE titleId = '%2$s'", user, GALLERY_TITLE)); count = tab_rows(user, GAMES_TAB, rows); @@ -4102,15 +4240,28 @@ static void test_system(const char* user) { // TabShifter's tile goes from both databases, once; its nsx flags stay. long long flags = scalar_of("SELECT COUNT(*) FROM tbl_info_%1$s WHERE key GLOB 'nsx_content_flag_*'", user, ""); - int removed = 0; - assert(remove_tabshifter(&removed, backup, sizeof backup, message, sizeof message) == 200 && removed > 0); + int removed = 0, folders = 0; + // Its folders go too, and only those that were there count. + tabshifter_dirs[0] = "/tmp/lineupd-test-tbsh"; + tabshifter_dirs[1] = "/tmp/lineupd-test-tbsh-meta"; + tabshifter_dirs[2] = "/tmp/lineupd-test-tbsh-none"; + assert(!system("rm -rf /tmp/lineupd-test-tbsh* && mkdir -p /tmp/lineupd-test-tbsh/sce_sys /tmp/lineupd-test-tbsh-meta && " + "touch /tmp/lineupd-test-tbsh/sce_sys/param.json")); + assert(remove_tabshifter(&removed, &folders, backup, sizeof backup, message, sizeof message) == 200 && removed > 0 && + folders == 2); + assert(access("/tmp/lineupd-test-tbsh", F_OK) && access("/tmp/lineupd-test-tbsh-meta", F_OK)); assert(!scalar("SELECT COUNT(*) FROM tbl_contentinfo WHERE titleId = 'TBSH00001'")); assert(!scalar_of("SELECT COUNT(*) FROM tbl_iconinfo_%1$s WHERE titleId = 'TBSH00001'", user, "")); assert(!scalar_of("SELECT COUNT(*) FROM tbl_concepticoninfo_%1$s WHERE localConceptId = 'cid:local:TBSH00001'", user, "")); assert(!info_scalar("SELECT COUNT(*) FROM tbl_appinfo WHERE titleId = 'TBSH00001'") && !info_scalar("SELECT COUNT(*) FROM tbl_conceptinfo WHERE local_concept_id = 'cid:local:TBSH00001'")); assert(scalar_of("SELECT COUNT(*) FROM tbl_info_%1$s WHERE key GLOB 'nsx_content_flag_*'", user, "") == flags); - assert(remove_tabshifter(&removed, backup, sizeof backup, message, sizeof message) == 200 && !removed); + assert(remove_tabshifter(&removed, &folders, backup, sizeof backup, message, sizeof message) == 200 && !removed && + !folders); + // No rows left but a folder: the folder goes and counts. + assert(!mkdir("/tmp/lineupd-test-tbsh-meta", 0755)); + assert(remove_tabshifter(&removed, &folders, backup, sizeof backup, message, sizeof message) == 200 && !removed && + folders == 1); // /pins/uninstall takes every lineup_* object out of both databases and leaves the tiles where they are. assert(integrity_ok(db_path) && integrity_ok(appinfo_path)); @@ -4128,7 +4279,10 @@ int main(int argc, char** argv) { 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, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; + // Renumbered by the console in the same order, the indices read still save; read in another order, they do not. + Row renumbered[3] = {{"PPSA00003", 11, 0}, {"PPSA00001", 33, 0}, {"PPSA00002", 21, 0}}; + assert(!plan_reorder(current, 3, renumbered, 3, out) && out[0] == 30 && out[1] == 20 && out[2] == 10); + Row stale[3] = {{"PPSA00003", 40, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; assert(plan_reorder(current, 3, stale, 3, out)); Row duplicate[3] = {{"PPSA00001", 30, 0}, {"PPSA00001", 30, 0}, {"PPSA00002", 20, 0}}; assert(plan_reorder(current, 3, duplicate, 3, out)); diff --git a/app/qr.js b/app/qr.js index cf26c91..ec8fb07 100644 --- a/app/qr.js +++ b/app/qr.js @@ -4,3 +4,7 @@ export const URL = 'https://github.com/bps2414/lineup'; export const SIZE = 29; export const PATH = 'M0 0h7v1h-7zM9 0h1v1h-1zM11 0h1v1h-1zM15 0h2v1h-2zM19 0h1v1h-1zM22 0h7v1h-7zM0 1h1v1h-1zM6 1h1v1h-1zM9 1h2v1h-2zM17 1h2v1h-2zM20 1h1v1h-1zM22 1h1v1h-1zM28 1h1v1h-1zM0 2h1v1h-1zM2 2h3v1h-3zM6 2h1v1h-1zM8 2h1v1h-1zM14 2h1v1h-1zM16 2h1v1h-1zM18 2h2v1h-2zM22 2h1v1h-1zM24 2h3v1h-3zM28 2h1v1h-1zM0 3h1v1h-1zM2 3h3v1h-3zM6 3h1v1h-1zM8 3h3v1h-3zM16 3h3v1h-3zM20 3h1v1h-1zM22 3h1v1h-1zM24 3h3v1h-3zM28 3h1v1h-1zM0 4h1v1h-1zM2 4h3v1h-3zM6 4h1v1h-1zM8 4h1v1h-1zM13 4h3v1h-3zM19 4h2v1h-2zM22 4h1v1h-1zM24 4h3v1h-3zM28 4h1v1h-1zM0 5h1v1h-1zM6 5h1v1h-1zM8 5h1v1h-1zM11 5h6v1h-6zM22 5h1v1h-1zM28 5h1v1h-1zM0 6h7v1h-7zM8 6h1v1h-1zM10 6h1v1h-1zM12 6h1v1h-1zM14 6h1v1h-1zM16 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM22 6h7v1h-7zM8 7h1v1h-1zM13 7h1v1h-1zM15 7h1v1h-1zM18 7h1v1h-1zM0 8h1v1h-1zM2 8h5v1h-5zM9 8h1v1h-1zM14 8h2v1h-2zM17 8h2v1h-2zM20 8h1v1h-1zM22 8h5v1h-5zM0 9h1v1h-1zM3 9h2v1h-2zM10 9h2v1h-2zM15 9h2v1h-2zM19 9h2v1h-2zM22 9h3v1h-3zM28 9h1v1h-1zM3 10h2v1h-2zM6 10h2v1h-2zM9 10h1v1h-1zM11 10h2v1h-2zM18 10h1v1h-1zM24 10h1v1h-1zM1 11h1v1h-1zM7 11h1v1h-1zM14 11h1v1h-1zM16 11h1v1h-1zM19 11h1v1h-1zM25 11h1v1h-1zM27 11h1v1h-1zM0 12h1v1h-1zM3 12h7v1h-7zM12 12h1v1h-1zM17 12h2v1h-2zM25 12h2v1h-2zM1 13h2v1h-2zM4 13h1v1h-1zM7 13h1v1h-1zM10 13h6v1h-6zM19 13h6v1h-6zM28 13h1v1h-1zM2 14h1v1h-1zM6 14h1v1h-1zM10 14h2v1h-2zM13 14h3v1h-3zM20 14h2v1h-2zM24 14h3v1h-3zM1 15h1v1h-1zM3 15h3v1h-3zM8 15h4v1h-4zM13 15h1v1h-1zM15 15h1v1h-1zM18 15h7v1h-7zM27 15h1v1h-1zM0 16h3v1h-3zM4 16h1v1h-1zM6 16h3v1h-3zM11 16h1v1h-1zM14 16h6v1h-6zM21 16h1v1h-1zM23 16h1v1h-1zM25 16h2v1h-2zM0 17h2v1h-2zM3 17h1v1h-1zM5 17h1v1h-1zM7 17h1v1h-1zM9 17h3v1h-3zM16 17h4v1h-4zM21 17h4v1h-4zM26 17h1v1h-1zM28 17h1v1h-1zM0 18h1v1h-1zM4 18h3v1h-3zM9 18h2v1h-2zM12 18h1v1h-1zM15 18h3v1h-3zM20 18h1v1h-1zM22 18h3v1h-3zM26 18h1v1h-1zM0 19h1v1h-1zM4 19h1v1h-1zM7 19h5v1h-5zM14 19h3v1h-3zM20 19h3v1h-3zM27 19h1v1h-1zM0 20h1v1h-1zM2 20h2v1h-2zM5 20h4v1h-4zM15 20h3v1h-3zM19 20h6v1h-6zM26 20h3v1h-3zM8 21h2v1h-2zM13 21h2v1h-2zM16 21h3v1h-3zM20 21h1v1h-1zM24 21h5v1h-5zM0 22h7v1h-7zM11 22h6v1h-6zM19 22h2v1h-2zM22 22h1v1h-1zM24 22h3v1h-3zM0 23h1v1h-1zM6 23h1v1h-1zM8 23h2v1h-2zM12 23h2v1h-2zM15 23h2v1h-2zM18 23h3v1h-3zM24 23h1v1h-1zM0 24h1v1h-1zM2 24h3v1h-3zM6 24h1v1h-1zM8 24h5v1h-5zM14 24h2v1h-2zM17 24h1v1h-1zM20 24h5v1h-5zM26 24h2v1h-2zM0 25h1v1h-1zM2 25h3v1h-3zM6 25h1v1h-1zM8 25h1v1h-1zM10 25h1v1h-1zM13 25h1v1h-1zM16 25h5v1h-5zM25 25h4v1h-4zM0 26h1v1h-1zM2 26h3v1h-3zM6 26h1v1h-1zM8 26h2v1h-2zM11 26h1v1h-1zM13 26h3v1h-3zM18 26h10v1h-10zM0 27h1v1h-1zM6 27h1v1h-1zM9 27h1v1h-1zM12 27h3v1h-3zM16 27h1v1h-1zM20 27h2v1h-2zM23 27h1v1h-1zM25 27h1v1h-1zM27 27h1v1h-1zM0 28h7v1h-7zM8 28h1v1h-1zM10 28h1v1h-1zM12 28h1v1h-1zM14 28h1v1h-1zM17 28h1v1h-1zM19 28h1v1h-1zM22 28h3v1h-3zM26 28h1v1h-1z'; +// An keeps at most 1024 floats of path ops (ERUI_VECTOR_TAPE_MAX) and drops the rest without an error. Each +// module rectangle is 13 (move, three lines, close), so the code is drawn as several stacked s of 60 each. +const RECTS = PATH.split('M').slice(1).map(rect => `M${rect}`); +export const PATHS = Array.from({length: Math.ceil(RECTS.length / 60)}, (_, i) => RECTS.slice(i * 60, i * 60 + 60).join('')); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3aa7c20..a807bf3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,9 +75,9 @@ carry `X-Lineup: 1`, so a web page cannot trigger one with a plain form. | `POST /row?user=<10 digits>[&tab=games\|media]` | body `LIN1\n` then ` [ pin]\n` per app, in the new order, with the index as read; ` pin` pins the app at that place. `{"ok","backup","integrity"}` | | `POST /pins/off?user=<10 digits>` | `{"ok","backup"}`: unpins that profile's games and drops its triggers | | `POST /pins/uninstall` | `{"ok","backup"}`: removes every pin, trigger and table Lineup added to `app.db` and `appinfo.db`, brings back what it hid, and leaves moved apps on their tab | -| `GET /hidden?user=<10 digits>` | `{"hidden":[{"titleId","name"}],"system":[{"titleId","name","tab","visible","shown"}]}`: what Lineup hid on that profile (`lineup_hidden`), and the system tiles it manages: those showing on that profile, plus the PS Store, the Media Gallery and Welcome whatever their state (never Game Library or App Library). `tab` is the tile's tab from its `dispLocation` bits, `games`, `media` or `none`; `visible` that it shows on that profile (the app lists the other showing tiles to hide); `shown` that Lineup shows it, which is Welcome only (the app offers Hide, else Show) | +| `GET /hidden?user=<10 digits>` | `{"hidden":[{"titleId","name"}],"system":[{"titleId","name","tab","visible","shown"}]}`: what Lineup hid on that profile (`lineup_hidden`), and the system tiles it manages: those showing on a tab of that profile (a `dispLocation` bit `0x02` or `0x04`), plus the PS Store, the Media Gallery, App Library and Welcome whatever their state (never Game Library). `tab` is the tile's tab from its `dispLocation` bits, `games`, `media` or `none`; `visible` that it shows on that profile (the app lists the other showing tiles to hide); `shown` that Lineup shows it, which is Welcome only (the app offers Hide, else Show) | | `POST /app?user=<10 digits>&title=&action=hide\|show\|games\|media` | `{"ok","backup"}`: `hide` and `show` set `visible` on that profile, and `lineup_hide_[ui]_` triggers keep it at 0, since the console sets it back to 1 on a restart. Welcome (`NPXS40063`) is the exception: its `show` and `hide` act on every profile, whichever `user` is given (see Moving system tiles). `games` and `media` move homebrew, an emulator or the Media Gallery on every profile: `tbl_iconinfo_`, `tbl_contentinfo` with its `AppInfoJson`, and `appinfo.db`, in one transaction, and keep it there (see Moving apps between tabs and Moving system tiles) | -| `POST /tabshifter/remove` | `{"ok","removed","backup"}`: after backing up both databases, takes every row of TabShifter's `TBSH00001` tile and its concept out of them, then its folders. Its `nsx_content_flag_*` rows stay, since the system writes rows of that name too | +| `POST /tabshifter/remove` | `{"ok","removed","folders","backup"}`: after backing up both databases, takes every row of TabShifter's `TBSH00001` tile and its concept out of them, then its folders, even when no row was left. `removed` counts the rows, `folders` the folders that were there and went. Its `nsx_content_flag_*` rows stay, since the system writes rows of that name too | | `POST /report` | `{"ok","path"}`: writes `/data/lineup/report.json` with `app.db`'s triggers and, per profile, the apps on the Games (138) and Media (188) tabs, plus `tbl_contentinfo`, only the columns that tell the two tabs apart | | `POST /quit` | `{"ok":true}`, then exits | | `GET /update/install` | `{"kind":"folder"\|"image","path","previous"}`: the one `PPSA99077` folder or `Lineup-*.exfat` image, and the version kept in `/data/lineup/update/previous/` | @@ -189,8 +189,10 @@ console (firmware 13.60): 1. `PRAGMA quick_check`. If it fails, nothing is written. 2. `VACUUM INTO /data/lineup/backups/app-