storebound/cli/lib/desktop-install.ts
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

186 lines
11 KiB
TypeScript

// What `storebound desktop` does on this machine, as data: the decision (open, install, update, downgrade) from the
// installed app's version and the CLI's (one product, one version number), and the install steps per OS as a plan
// that cli/desktop.ts runs and the tests read. Installs are per user and never need admin rights:
// macOS the .app from the DMG (mounted read-only, never shown in Finder) into /Applications when that is writable,
// else ~/Applications; an existing install is replaced in place. Nothing touches extended attributes: the
// installer was downloaded by this process (no quarantine flag), its SHA-256 and minisign signature are
// checked, and a release build is signed and notarized.
// Windows the NSIS installer, silent (/S), per user (%LOCALAPPDATA%\storebound), which also registers storebound://.
// Linux the AppImage into ~/.local/bin (or where the existing one is) plus ~/.local/share/applications/
// storebound.desktop with the storebound:// handler, registered when update-desktop-database / xdg-mime
// exist. A .deb install needs root, so it is printed, never run.
import { join } from 'node:path';
import type { DownloadKey, Installed } from './desktop-dist';
/** -1, 0, 1 like a comparator; a pre-release sorts before its release (0.8.0-beta.1 < 0.8.0). */
export function compareVersions(a: string, b: string): number {
const [ma, pa = ''] = a.split('-', 2) as [string, string?];
const [mb, pb = ''] = b.split('-', 2) as [string, string?];
const na = ma.split('.').map(Number), nb = mb.split('.').map(Number);
for (let i = 0; i < 3; i++) { const d = (na[i] ?? 0) - (nb[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
if (pa === pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
return pa < pb ? -1 : 1;
}
export type Action = 'open' | 'install' | 'update' | 'downgrade' | 'unavailable';
export interface Decision { action: Action; from: string | null; to: string | null; notes: string[] }
export interface DecideIn {
installed: Installed | null;
/** the installed app's version; null when it could not be read */
installedVersion: string | null;
cli: string;
/** the version storebound.dev offers for this system: the CLI's own when published, else the channel's newest */
available: string | null;
/** why nothing is available (no build for this system, no release yet, offline) */
unavailableReason?: string;
force: boolean;
}
/**
* The one rule: the app should have the CLI's version. Same: open. Missing: install. Older: update (to the CLI's
* version, or the newest published when the CLI's is not out yet). Newer: open and say so; a downgrade only with --force.
*/
export function decide(i: DecideIn): Decision {
const notes: string[] = [];
const offer = i.available;
if (offer && offer !== i.cli) notes.push(`storebound.dev has desktop ${offer} for this system, this CLI is ${i.cli}`);
if (!i.installed) {
if (!offer) return { action: 'unavailable', from: null, to: null, notes: [i.unavailableReason ?? 'no desktop build for this system'] };
return { action: 'install', from: null, to: offer, notes };
}
const have = i.installedVersion;
if (!have) return { action: 'open', from: null, to: null, notes: ['the installed app\'s version could not be read'] };
if (have === i.cli) return { action: 'open', from: have, to: null, notes: [] };
if (compareVersions(have, i.cli) < 0) {
if (offer && compareVersions(offer, have) > 0) return { action: 'update', from: have, to: offer, notes: [`the app is ${have}, this CLI is ${i.cli}`, ...notes] };
return { action: 'open', from: have, to: null, notes: [`the app is ${have}, this CLI is ${i.cli}: no newer app is published yet${i.unavailableReason ? ` (${i.unavailableReason})` : ''}`] };
}
// the app is newer than this CLI
if (i.force && offer && compareVersions(offer, have) < 0) return { action: 'downgrade', from: have, to: offer, notes: [`--force: replacing the newer app ${have} with ${offer}`] };
return { action: 'open', from: have, to: null, notes: [`the app (${have}) is newer than this CLI (${i.cli}): update the CLI (npx storebound@latest, or bun add -d storebound@latest in a project that pins it). Nothing was downgraded (--force installs ${offer ?? i.cli})`] };
}
/** One step of an install. `run` is an argv (never a shell); `always` steps run even after a failure (unmount). */
export type Step =
| { what: string; run: string[]; timeoutMs: number; always?: boolean; optional?: boolean }
| { what: string; mkdir: string }
| { what: string; copy: { from: string; to: string; mode?: number } }
| { what: string; rename: { from: string; to: string } }
| { what: string; remove: string }
| { what: string; write: { path: string; content: string; mode?: number } }
| { what: string; expectVersion: { app: string; version: string } };
export interface Plan {
/** where the app ends up (the .app, the .exe, the AppImage) */
target: string;
/** a short place for the question ("~/Applications") */
place: string;
steps: Step[];
/** the plan cannot run (a .deb needs root): what to tell the owner instead */
manual?: string;
}
export interface PlanIn {
platform: NodeJS.Platform;
kind: DownloadKey;
/** the verified installer on disk */
file: string;
version: string;
home: string;
/** the app already there (an update replaces it in place) */
existing: Installed | null;
/** /Applications is writable for this user (macOS) */
systemAppsWritable: boolean;
/** a private temp folder for the mount point / staging */
tmp: string;
env: Record<string, string | undefined>;
/** which optional Linux tools exist */
has: (tool: string) => boolean;
}
const tildeOf = (p: string, home: string) => (p.startsWith(home) ? '~' + p.slice(home.length) : p);
export function installPlan(i: PlanIn): Plan {
if (i.platform === 'darwin') {
const dir = i.existing?.how === 'bundle' ? join(i.existing.path, '..') : i.systemAppsWritable ? '/Applications' : join(i.home, 'Applications');
const target = join(dir, 'storebound.app');
const mnt = join(i.tmp, 'mnt');
const staged = join(dir, '.storebound.app.new');
const old = join(dir, '.storebound.app.old');
const steps: Step[] = [
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
{ what: 'a private mount point', mkdir: mnt },
{ what: 'mount the disk image read-only (not shown in Finder)', run: ['hdiutil', 'attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mnt, i.file], timeoutMs: 120_000 },
{ what: 'copy storebound.app', run: ['ditto', join(mnt, 'storebound.app'), staged], timeoutMs: 300_000 },
{ what: 'unmount the disk image', run: ['hdiutil', 'detach', mnt, '-quiet'], timeoutMs: 60_000, always: true },
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
...(i.existing?.how === 'bundle' ? [{ what: 'move the old app aside', rename: { from: target, to: old } } as Step] : []),
{ what: `put storebound.app in ${tildeOf(dir, i.home)}`, rename: { from: staged, to: target } },
...(i.existing?.how === 'bundle' ? [{ what: 'remove the old app', remove: old } as Step] : []),
];
return { target, place: tildeOf(dir, i.home), steps };
}
if (i.platform === 'win32') {
const base = i.env.LOCALAPPDATA ?? join(i.home, 'AppData', 'Local');
const target = `${base}\\storebound\\storebound-desktop.exe`;
return {
target, place: '%LOCALAPPDATA%\\storebound',
steps: [
{ what: 'run the installer silently, for this user only (no admin rights)', run: [i.file, '/S'], timeoutMs: 300_000 },
{ what: `check the installed app is ${i.version}`, expectVersion: { app: target, version: i.version } },
],
};
}
// Linux
if (i.kind === 'linux-x86_64-deb' || i.existing?.how === 'deb') {
return { target: '/usr/bin/storebound-desktop', place: 'the system (.deb)', steps: [], manual: `a .deb installs system-wide and needs root: sudo apt install ${i.file}` };
}
const dir = i.existing?.how === 'appimage' ? join(i.existing.path, '..') : join(i.home, '.local', 'bin');
const target = i.existing?.how === 'appimage' ? i.existing.path : join(dir, 'storebound.AppImage');
const staged = join(dir, '.storebound.AppImage.new');
const apps = join(i.env.XDG_DATA_HOME ?? join(i.home, '.local', 'share'), 'applications');
const entry = [
'[Desktop Entry]', 'Type=Application', 'Name=storebound', 'Comment=Every game\'s releases on both stores in one window',
`Exec="${target}" %u`, 'Terminal=false', 'Categories=Development;', 'MimeType=x-scheme-handler/storebound;', '',
].join('\n');
const steps: Step[] = [
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
{ what: 'copy the AppImage', copy: { from: i.file, to: staged, mode: 0o755 } },
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
{ what: `put it at ${tildeOf(target, i.home)}`, rename: { from: staged, to: target } },
{ what: `make ${tildeOf(apps, i.home)}`, mkdir: apps },
{ what: 'menu entry and the storebound:// link handler', write: { path: join(apps, 'storebound.desktop'), content: entry, mode: 0o644 } },
];
if (i.has('update-desktop-database')) steps.push({ what: 'refresh the menu', run: ['update-desktop-database', apps], timeoutMs: 30_000, optional: true });
if (i.has('xdg-mime')) steps.push({ what: 'register storebound:// links', run: ['xdg-mime', 'default', 'storebound.desktop', 'x-scheme-handler/storebound'], timeoutMs: 30_000, optional: true });
return { target, place: tildeOf(dir, i.home), steps };
}
/** The command that tells whether the app is running (exit 0 = running), so an update never replaces a running app. */
export function runningCheck(platform: NodeJS.Platform): string[] {
if (platform === 'win32') return ['tasklist', '/FI', 'IMAGENAME eq storebound-desktop.exe', '/NH'];
return ['pgrep', '-x', 'storebound-desktop'];
}
/** Is the tasklist / pgrep result "running"? (tasklist exits 0 either way and prints "INFO: No tasks ..."). */
export function isRunning(platform: NodeJS.Platform, code: number | null, stdout: string): boolean {
if (platform === 'win32') return /storebound-desktop\.exe/i.test(stdout);
return code === 0;
}
/** The question in a terminal; the default is yes. */
export function question(d: Decision, sizeBytes: number, place: string): string {
const mb = `${Math.round(sizeBytes / 1e6)} MB`;
if (d.action === 'install') return `Install storebound desktop ${d.to} (${mb}) to ${place}? [Y/n] `;
if (d.action === 'update') return `Update storebound desktop ${d.from} to ${d.to} (${mb}) in ${place}? [Y/n] `;
return `Replace storebound desktop ${d.from} with the older ${d.to} (${mb}) in ${place}? [Y/n] `;
}
/** How an install / update gets its yes: --yes, the question in a terminal, or nobody to ask (exit 3, the command
* printed), like every other confirmation of storebound. */
export function consent(yes: boolean, tty: boolean): 'yes' | 'ask' | 'declined' {
return yes ? 'yes' : tty ? 'ask' : 'declined';
}