- 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
186 lines
11 KiB
TypeScript
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';
|
|
}
|