diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1fa96..ecb8bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.2.0 + +- Profile pictures: in the header, under Options, and next to each name in Switch profile. A profile + without one shows its initial. +- Updates from the app: Lineup checks for a new release once a day, shows its notes and installs it + when you choose Update, then opens again. Options → Updates turns the daily check off, checks now, + or goes back to the previous version. Works for folder and exFAT image installs; updating from 1.1.0 + still needs a manual install. +- Releases also ship `update.json`, the manifest the app reads. + ## 1.1.0 First public release. diff --git a/README.md b/README.md index 935b73a..78d3fec 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,16 @@ out on the TV. Its local service then writes the new order where the home screen - **Lift, move, drop.** ✕ lifts the focused game, the D-pad carries it and ✕ drops it. ◯ while carrying puts it back. -- **Every profile.** Each console profile has its own row. Switch from the Options menu. +- **Every profile.** Each console profile has its own row, shown with its picture. Switch from the + Options menu. - **Home screen edge.** The home screen shows only the first 12 games. A divider in the row and a mark on the ruler show where that edge falls, and games past it read "not on the home screen". - **Sort A to Z**, discard changes, and a count of the games that moved. - **Applies on exit.** Saving closes the app, and the home screen comes back with the new order. No restart needed. +- **Updates in the app.** Lineup checks for a new release once a day (Options → Updates turns that off), + downloads it when you choose **Update**, and opens again on the new version. Options → Updates can + also go back to the previous version. This works from 1.2.0 on. - **English and Brazilian Portuguese**, chosen from the console's system language. ![The Options menu](docs/images/options.png) diff --git a/README.pt-BR.md b/README.pt-BR.md index f98e5e6..45a441b 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -15,13 +15,17 @@ ordem na TV. Depois, o serviço local dele grava a nova ordem onde a tela inicia - **Levantar, mover, soltar.** ✕ levanta o jogo em foco, o direcional leva e ✕ solta. ◯ durante o movimento devolve o jogo ao lugar. -- **Todos os perfis.** Cada perfil do console tem sua fileira. Troque pelo menu Opções. +- **Todos os perfis.** Cada perfil do console tem sua fileira, mostrada com a foto dele. Troque pelo + menu Opções. - **Limite da tela inicial.** A tela inicial mostra só os 12 primeiros jogos. Um traço na fileira e uma marca na régua mostram onde fica esse limite, e os jogos depois dele aparecem como "fora da tela inicial". - **Ordenar de A a Z**, descartar mudanças e contagem de jogos que mudaram de lugar. - **Aplica ao sair.** Salvar fecha o app, e a tela inicial volta com a nova ordem. Não precisa reiniciar. +- **Atualização no app.** O Lineup procura uma versão nova uma vez por dia (Opções → Atualizações + desliga isso), baixa quando você escolhe **Atualizar** e abre de novo na versão nova. Em Opções → + Atualizações também dá para voltar à versão anterior. Vale a partir da 1.2.0. - **Inglês e português do Brasil**, escolhidos pelo idioma do sistema. ![O menu Opções](docs/images/options.png) diff --git a/app/api.js b/app/api.js index 880f364..5feb4e9 100644 --- a/app/api.js +++ b/app/api.js @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Talks to lineupd (payloads/lineupd.c) on 127.0.0.1:9077 and starts it when it is not running. -import {Http, Payloads} from '@ps5-react/core'; +import {Archives, Downloads, FileSystem, Http, Payloads} from '@ps5-react/core'; import {T} from './i18n.js'; -export const VERSION = '1.1.0'; +export const VERSION = '1.2.0'; const BASE = 'http://127.0.0.1:9077'; /** An error the app shows as is: a short title and what to do about it. */ @@ -38,7 +38,7 @@ async function call(path, {method = 'GET', body, timeoutMs} = {}) { try { const response = await Http.request(BASE + path, { method, body, maxBytes: 1024 * 1024, signal: limit?.signal, - headers: method === 'POST' ? {'X-Lineup': '1', 'Content-Type': 'text/plain; charset=utf-8'} : undefined, + headers: method === 'POST' ? {'X-Lineup': '1', 'Content-Type': 'text/plain; charset=utf-8'} : {'X-Lineup': '1'}, }); let data = {}; try { @@ -99,6 +99,7 @@ async function read(path, what) { export const profiles = async () => (await read('/profiles', T.service.profiles)).profiles ?? []; export const row = async user => (await read(`/row?user=${user}`, T.service.row)).games ?? []; export const iconUrl = game => `${BASE}/icon/${game.titleId}?m=${game.icon}`; +export const avatarUrl = profile => `${BASE}/avatar/${profile.userId}?m=${profile.avatar}`; /** * Saves `order` (titleIds) for `user`, sending each game's index as it was read. Resolves to @@ -117,3 +118,143 @@ export async function save(user, order, games) { const detail = reply.data.error ? ` (${reply.data.error})` : ''; return {ok: false, stale: false, message: T.service.saveStatus(reply.status) + detail}; } + +// ---- Updates ---- + +const UPDATE_DIR = '/data/lineup/update'; +const SETTINGS = '/data/lineup/settings.json'; +const MANIFEST = 'https://github.com/bps2414/lineup/releases/latest/download/update.json'; +const RELEASES = 'https://github.com/bps2414/lineup/releases/download'; + +// "1.2.0", or param.json's "01.002.000", as one comparable number; -1 when malformed. +const versionNumber = text => (/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(text ?? '') + ? text.split('.').reduce((n, part) => n * 1000 + Number(part), 0) : -1); + +export function settings() { + try { + return {checkOnOpen: true, lastCheck: 0, ...JSON.parse(FileSystem.readFile(SETTINGS))}; + } catch { + return {checkOnOpen: true, lastCheck: 0}; + } +} + +export function saveSettings(next) { + try { + FileSystem.mkdir('/data/lineup', {recursive: true}); + FileSystem.writeFile(SETTINGS, JSON.stringify(next)); + } catch { + // Settings that do not stick are not worth stopping for. + } +} + +/** The release manifest when it offers a version newer than this one, else null. Throws when it cannot be read. */ +export async function checkUpdate() { + const limit = deadline(20000); + let response; + let manifest; + try { + response = await Http.request(MANIFEST, {maxBytes: 64 * 1024, signal: limit.signal}); + if (response.status === 404) return null; + manifest = response.ok ? await response.json() : null; + } catch { + throw new Error(T.update.offline); + } finally { + limit.clear(); + } + if (!manifest) throw new Error(T.update.checkStatus(response.status)); + // A version at or below this one is never offered: going back is only through the previous copy. + return versionNumber(manifest.version) > versionNumber(VERSION) ? manifest : null; +} + +/** How Lineup is installed: {kind: 'folder' | 'image', path, previous}. Throws with the service's detail. */ +export async function install() { + let reply; + try { + reply = await call('/update/install', {timeoutMs: 8000}); + } catch { + throw new Error(T.update.noAnswer); + } + if (!reply.ok) throw new Error(T.update.noInstall(reply.data.error ?? T.service.status(reply.status))); + return reply.data; +} + +/** + * Downloads `manifest`'s file for this kind of install into /data/lineup/update// and, for a folder, + * unpacks and checks it. `onStep(text)` reports progress. + */ +export async function download(manifest, kind, onStep) { + const {version} = manifest; + const file = kind === 'image' ? manifest.exfat : manifest.zip; + if (versionNumber(version) < 0 || !file || !/^[\w.-]+$/.test(file.name ?? '') || !/^[0-9a-f]{64}$/i.test(file.sha256 ?? '') + || !Number.isSafeInteger(file.size) || file.size <= 0) { + throw new Error(T.update.badManifest); + } + const dir = `${UPDATE_DIR}/${version}`; + // lineupd looks for exactly these names. + const destination = `${dir}/Lineup-${version}.${kind === 'image' ? 'exfat' : 'zip'}`; + FileSystem.mkdir(dir, {recursive: true}); + const task = Downloads.enqueue({url: `${RELEASES}/v${version}/${encodeURIComponent(file.name)}`, destination, + sha256: file.sha256.toLowerCase(), expectedBytes: file.size, recoverCompleted: true}); + const stop = task.subscribe(({written}) => onStep(T.update.downloading(Math.floor(100 * written / file.size)))); + try { + await task.done; + } catch (error) { + throw new Error(T.update.downloadFailed(error?.message ?? String(error))); + } finally { + stop(); + } + if (kind === 'image') { + if (Archives.inspect([destination]).kind !== 'exfat') throw new Error(T.update.badPackage); + return; + } + onStep(T.update.extracting); + const stage = `${dir}/stage`; + try { + await Archives.extract({sources: [destination], destination: stage, maxBytes: 1024 ** 3}).done; + } catch (error) { + throw new Error(T.update.extractFailed(error?.message ?? String(error))); + } + const app = `${stage}/PPSA99077`; + let param = {}; + try { + param = JSON.parse(FileSystem.readFile(`${app}/sce_sys/param.json`)); + } catch { + // Checked below. + } + if (param.titleId !== 'PPSA99077' || versionNumber(param.contentVersion) !== versionNumber(version) + || !FileSystem.exists(`${app}/eboot.bin`) || !FileSystem.exists(`${app}/lineupd.elf`)) { + throw new Error(T.update.badPackage); + } +} + +/** Hands the swap to lineupd; `version` 'previous' goes back. The app must close right after. */ +export async function apply(version) { + let reply; + try { + reply = await call(`/update/apply?version=${version}`, {method: 'POST', timeoutMs: 8000}); + } catch { + throw new Error(T.update.noAnswer); + } + if (!reply.ok) throw new Error(T.update.applyFailed(reply.data.error ?? T.service.status(reply.status))); +} + +/** After an update: what to tell the user, once (and ok- is written), or ''. */ +export function updateOutcome() { + try { + const error = `${UPDATE_DIR}/error`; + if (FileSystem.exists(error)) { + const detail = FileSystem.readFile(error).trim(); + FileSystem.remove(error); + return T.update.failed(detail); + } + const dir = `${UPDATE_DIR}/${VERSION}`; + const ok = `${UPDATE_DIR}/ok-${VERSION}`; + if (!FileSystem.exists(dir) || FileSystem.exists(ok)) return ''; + FileSystem.writeFile(ok, ''); + // The download and its unpacked copy are not needed any more. + FileSystem.removeAsync(dir).catch(() => {}); + return T.update.done(VERSION); + } catch { + return ''; + } +} diff --git a/app/app.json b/app/app.json index e0a3fde..a3578f1 100644 --- a/app/app.json +++ b/app/app.json @@ -3,7 +3,7 @@ "titleId": "PPSA99077", "contentId": "UP9000-PPSA99077_00-LINEUP0000000000", "name": "Lineup", - "version": "01.001.000", + "version": "01.002.000", "render": { "width": 1920, "height": 1080 diff --git a/app/i18n.js b/app/i18n.js index c5c88a0..aea169c 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -51,6 +51,36 @@ const en = { profiles: 'Switch profile', changes: n => (n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'no changes'), profileCount: n => `${n} profiles`, + updates: 'Updates', updateTo: v => `Update to ${v}`, + }, + updates: { + title: 'Updates', checkOnOpen: 'Check when opening', checkNow: 'Check now', + rollback: 'Go back to the previous version', + }, + update: { + available: v => `Lineup ${v} is available.`, + upToDate: 'Lineup is up to date.', + title: v => `Lineup ${v}`, + size: bytes => `Download: ${(bytes / 1048576).toFixed(1)} MB`, + install: 'Update', later: 'Not now', + preparing: 'Getting ready', + downloading: n => `Downloading the update, ${n}%`, + extracting: 'Unpacking the update', + applying: 'Closing to update', + offline: 'Could not check for updates. Check the connection and try again.', + checkStatus: code => `Could not check for updates. GitHub answered ${code}.`, + badManifest: 'The update information is incomplete. Try again later.', + noAnswer: 'The service did not answer. Open the app again and try again.', + noInstall: detail => `Could not update: ${detail}`, + downloadFailed: detail => `The download failed (${detail}). Try again to resume it.`, + extractFailed: detail => `Could not unpack the update (${detail}).`, + badPackage: 'The download is not a Lineup release. Nothing changed.', + applyFailed: detail => `Could not start the update (${detail}). Nothing changed.`, + failed: detail => `The update did not finish: ${detail}`, + done: v => `Updated to ${v}.`, + rollbackTitle: v => `Go back to ${v}?`, + rollbackBody: 'The app closes and opens again on the previous version.', + rollbackConfirm: 'Go back', cancel: 'Cancel', }, profilesTitle: 'Switch profile', profilesSubtitle: 'Each profile has its own row', @@ -108,6 +138,36 @@ const pt = { profiles: 'Trocar perfil', changes: n => (n ? `${n} ${n === 1 ? 'mudança' : 'mudanças'}` : 'sem mudanças'), profileCount: n => `${n} perfis`, + updates: 'Atualizações', updateTo: v => `Atualizar para ${v}`, + }, + updates: { + title: 'Atualizações', checkOnOpen: 'Procurar ao abrir', checkNow: 'Procurar agora', + rollback: 'Voltar à versão anterior', + }, + update: { + available: v => `Lineup ${v} disponível.`, + upToDate: 'O Lineup está atualizado.', + title: v => `Lineup ${v}`, + size: bytes => `Download: ${(bytes / 1048576).toFixed(1).replace('.', ',')} MB`, + install: 'Atualizar', later: 'Agora não', + preparing: 'Preparando', + downloading: n => `Baixando a atualização, ${n}%`, + extracting: 'Abrindo a atualização', + applying: 'Fechando para atualizar', + offline: 'Não consegui procurar atualizações. Confira a conexão e tente de novo.', + checkStatus: code => `Não consegui procurar atualizações. O GitHub respondeu ${code}.`, + badManifest: 'As informações da atualização estão incompletas. Tente mais tarde.', + noAnswer: 'O serviço não respondeu. Abra o app de novo e tente outra vez.', + noInstall: detail => `Não deu para atualizar: ${detail}`, + downloadFailed: detail => `O download falhou (${detail}). Tente de novo para continuar de onde parou.`, + extractFailed: detail => `Não consegui abrir a atualização (${detail}).`, + badPackage: 'O arquivo baixado não é uma versão do Lineup. Nada mudou.', + applyFailed: detail => `Não consegui começar a atualização (${detail}). Nada mudou.`, + failed: detail => `A atualização não terminou: ${detail}`, + done: v => `Atualizado para ${v}.`, + rollbackTitle: v => `Voltar para a ${v}?`, + rollbackBody: 'O app fecha e abre de novo na versão anterior.', + rollbackConfirm: 'Voltar', cancel: 'Cancelar', }, profilesTitle: 'Trocar perfil', profilesSubtitle: 'Cada perfil tem a sua fileira', @@ -131,4 +191,6 @@ function systemLanguage() { } } -export const T = systemLanguage().toLowerCase().startsWith('pt') ? pt : en; +/** The interface language as the update notes are keyed: 'pt-BR' or 'en'. */ +export const LANGUAGE = systemLanguage().toLowerCase().startsWith('pt') ? 'pt-BR' : 'en'; +export const T = LANGUAGE === 'pt-BR' ? pt : en; diff --git a/app/index.jsx b/app/index.jsx index ad9dbf6..3a6e5a0 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -11,9 +11,9 @@ import {AnimatePresence, AppRegistry, BackHandler, Controller, FocusScope, Image motion, useFocusActions} from '@ps5-react/core'; import {useInterfaceSounds} from './sounds.js'; import * as api from './api.js'; -import {T} from './i18n.js'; +import {LANGUAGE, T} from './i18n.js'; import {BOTTOM, DIGITS, EM, TOP} from './digits.js'; -import {Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash, +import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash, px} from './ui.jsx'; const LEFT = 64; // the focused game's left edge @@ -200,12 +200,13 @@ function Position({s}) { ); } -function Header({name}) { +function Header({profile}) { return ( Lineup - {name ? {name} : null} + {profile ? : null} + {profile ? {profile.name} : null} ); } @@ -217,7 +218,9 @@ const PICK_HINTS = [['cross', H.choose], ['circle', H.back]]; const START = {phase: 'loading', step: T.service.looking, error: null, profiles: [], user: null, games: {}, home: {}, base: [], order: [], focus: 0, dir: 1, lifted: false, liftFrom: 0, layer: null, - target: null, toast: null, pending: null}; + target: null, toast: null, pending: null, progress: null, settings: null, update: null, install: null}; + +const DAY = 24 * 60 * 60 * 1000; function App() { const [s, setS] = useState(START); @@ -284,18 +287,83 @@ function App() { if (!profiles.length) { throw new api.Problem(...T.noProfiles); } + // Decoded ahead at the profile list's size, so the list opens with its pictures. + for (const profile of profiles) { + if (profile.avatar) Image.prefetch(api.avatarUrl(profile), {width: px(40), height: px(40)}).catch(() => {}); + } const foreground = Users.getForeground(); const id = foreground ? String(foreground.id >>> 0).padStart(10, '0') : ''; await loadRow((profiles.find(profile => profile.userId === id) ?? profiles[0]).userId, profiles); } catch (error) { patch({phase: 'error', error: error instanceof api.Problem ? error : {title: T.openFailed, detail: String(error?.message ?? error)}}); + return; } + afterOpen(); }; useEffect(() => { boot(); }, []); + const keepSettings = settings => { + api.saveSettings(settings); + patch({settings}); + }; + + // In the background once the row is up: how the last update went, then a check at most once a day. + const afterOpen = async () => { + const outcome = api.updateOutcome(); + if (outcome) toast(outcome); + const settings = api.settings(); + patch({settings}); + api.install().then(install => patch({install}), error => patch({install: {error: error.message}})); + if (!settings.checkOnOpen || Date.now() - settings.lastCheck < DAY) return; + try { + const update = await api.checkUpdate(); + keepSettings({...ref.current.settings, lastCheck: Date.now()}); + if (update) { + patch({update}); + toast(T.update.available(update.version)); + } + } catch { + // Offline, say: the next opening tries again. + } + }; + + const checkNow = async () => { + patch({layer: 'busy'}); + try { + const update = await api.checkUpdate(); + keepSettings({...ref.current.settings, lastCheck: Date.now()}); + if (update) patch({update, layer: 'update'}); + else { + close(); + toast(T.update.upToDate); + } + } catch (error) { + close(); + toast(error.message); + } + }; + + // Downloads and prepares the update, hands the swap to lineupd and closes; lineupd opens the app again. + const runUpdate = async version => { + const {update, install} = ref.current; + light(false); + patch({lifted: false, layer: 'busy', progress: T.update.preparing}); + try { + if (!install || install.error) throw new Error(install?.error ?? T.update.noAnswer); + if (version !== 'previous') await api.download(update, install.kind, progress => patch({progress})); + patch({progress: T.update.applying}); + await api.apply(version); + exit(); + } catch (error) { + patch({progress: null}); + close(); + toast(error.message); + } + }; + const switchTo = async user => { patch({layer: 'busy'}); try { @@ -421,7 +489,7 @@ function App() { return ( -
+
true}> {saved ? ( } title={T.saved[0]} @@ -442,23 +510,36 @@ function App() { const count = changed(s); const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : ROW_HINTS; + const settings = s.settings ?? {checkOnOpen: true, lastCheck: 0}; + const file = s.update?.[s.install?.kind === 'image' ? 'exfat' : 'zip']; + const notes = [s.update?.notes?.[LANGUAGE] ?? s.update?.notes?.en, file?.size ? T.update.size(file.size) : ''] + .filter(Boolean).join('\n\n'); return ( -
+
- {s.layer === 'menu' || s.layer === 'profiles' ? ( - // One sheet for both pages, so it stays put and still knows which game to give focus back to. + {s.layer === 'menu' || s.layer === 'profiles' || s.layer === 'updates' ? ( + // One sheet for every page, so it stays put and still knows which game to give focus back to. (s.layer === 'profiles' ? patch({layer: 'menu'}) : close())} - title={s.layer === 'menu' ? T.menu.title : T.profilesTitle} - subtitle={s.layer === 'menu' ? profile?.name : T.profilesSubtitle}> - {s.layer === 'menu' ? [ + onBack={() => (s.layer === 'menu' ? close() : patch({layer: 'menu'}))} + title={s.layer === 'menu' ? T.menu.title : s.layer === 'profiles' ? T.profilesTitle : T.updates.title} + subtitle={s.layer === 'menu' ? profile?.name : s.layer === 'profiles' ? T.profilesSubtitle : `Lineup ${api.VERSION}`} + leading={s.layer === 'menu' && profile ? : null}> + {s.layer === 'updates' ? [ + keepSettings({...settings, checkOnOpen: !settings.checkOnOpen})} />, + , + s.install?.previous ? ( + patch({layer: 'rollback'})} /> + ) : null, + ] : s.layer === 'menu' ? [ save()} detail={T.menu.changes(count)} />, , @@ -466,8 +547,13 @@ function App() { , patch({layer: 'profiles'})} detail={s.profiles.length > 1 ? T.menu.profileCount(s.profiles.length) : ''} />, + s.update ? ( + patch({layer: 'update'})} /> + ) : null, + patch({layer: 'updates'})} />, ] : s.profiles.map(item => ( } autoFocus={item.userId === s.user} onPress={() => pickProfile(item.userId)} /> ))} @@ -487,12 +573,25 @@ function App() {