Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
147 changes: 144 additions & 3 deletions app/api.js
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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/<version>/ 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-<version> 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 '';
}
}
2 changes: 1 addition & 1 deletion app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 63 additions & 1 deletion app/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Loading
Loading