storebound desktop: open the desktop app (with the current app folder) or show the download for this system; --download saves it to ~/Downloads after checking SHA-256 and the minisign signature, never installs
This commit is contained in:
parent
c68ef8acd0
commit
10423ae915
6 changed files with 437 additions and 0 deletions
|
|
@ -183,6 +183,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
|
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
|
||||||
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
|
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
|
||||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||||
|
| `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) |
|
||||||
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
||||||
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||||
|
|
||||||
|
|
|
||||||
107
cli/desktop.ts
Normal file
107
cli/desktop.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// `storebound desktop`: the bridge from the terminal to the desktop app. The app is a separate signed download from
|
||||||
|
// storebound.dev (it updates itself and brings its own copy of this CLI); the npm package never contains it.
|
||||||
|
// storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that
|
||||||
|
// folder (storebound://open?path=..., the app asks before adding)
|
||||||
|
// storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app
|
||||||
|
// storebound desktop --download not installed: download the installer for this system into ~/Downloads,
|
||||||
|
// check its SHA-256 and signature, and print where it is. It never installs.
|
||||||
|
// --channel beta, --format deb (Linux), --json (print what it would do, change nothing)
|
||||||
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import type { Args } from './lib/args';
|
||||||
|
import { CONFIG_NAMES } from './lib/config';
|
||||||
|
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY } from './lib/desktop-dist';
|
||||||
|
import { verify } from './lib/minisign';
|
||||||
|
|
||||||
|
type Fetch = (url: string) => Promise<Response>;
|
||||||
|
|
||||||
|
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
|
||||||
|
const r = await get(MANIFEST[channel]);
|
||||||
|
if (!r.ok) throw new Error(`${MANIFEST[channel]}: HTTP ${r.status}`);
|
||||||
|
const m = await r.json() as unknown;
|
||||||
|
const problems = manifestProblems(m);
|
||||||
|
if (problems.length) throw new Error(`latest.json: ${problems.join('; ')}`);
|
||||||
|
return m as Manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the installer for this system: the first of downloadsFor() the release has (or the asked format). */
|
||||||
|
export function pick(m: Manifest, keys: DownloadKey[], format?: string): [DownloadKey, Download] | null {
|
||||||
|
const want = format ? keys.filter(k => k.endsWith(`-${format}`)) : keys;
|
||||||
|
for (const k of want) { const d = m.downloads?.[k]; if (d) return [k, d]; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloads one installer and keeps it only when its size, SHA-256 and minisign signature are right. */
|
||||||
|
export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY): Promise<string> {
|
||||||
|
const r = await get(d.url);
|
||||||
|
if (!r.ok) throw new Error(`${d.url}: HTTP ${r.status}`);
|
||||||
|
const data = new Uint8Array(await r.arrayBuffer());
|
||||||
|
if (data.length !== d.size) throw new Error(`${d.file}: ${data.length} bytes, latest.json says ${d.size}; not kept`);
|
||||||
|
const sha = createHash('sha256').update(data).digest('hex');
|
||||||
|
if (sha !== d.sha256) throw new Error(`${d.file}: SHA-256 ${sha} does not match latest.json; not kept`);
|
||||||
|
const v = verify(data, d.signature, publicKey);
|
||||||
|
if (!v.ok) throw new Error(`${d.file}: signature check failed (${v.reason}); not kept`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const out = join(dir, d.file);
|
||||||
|
const tmp = `${out}.part`;
|
||||||
|
writeFileSync(tmp, data);
|
||||||
|
renameSync(tmp, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(args: Args): Promise<void> {
|
||||||
|
const platform = process.platform;
|
||||||
|
const channel = args.get('channel') === 'beta' ? 'beta' : 'stable';
|
||||||
|
const dir = resolve(args.str('dir', '.'));
|
||||||
|
const isApp = !args.has('no-folder') && CONFIG_NAMES.some(n => existsSync(join(dir, n)));
|
||||||
|
const installed = findInstalled(platform);
|
||||||
|
|
||||||
|
if (installed && !args.has('download')) {
|
||||||
|
const link = isApp ? openFolderLink(dir) : null;
|
||||||
|
const cmd = openCommand(platform, installed, link);
|
||||||
|
if (args.has('json')) { console.log(JSON.stringify({ installed, link, command: cmd }, null, 2)); return; }
|
||||||
|
const p = Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const code = await p.exited;
|
||||||
|
if (code !== 0) throw new Error(`open -b ${BUNDLE_ID} failed: ${(await new Response(p.stderr).text()).trim()}`);
|
||||||
|
} else p.unref();
|
||||||
|
console.log(link ? `storebound desktop: opening ${dir} (the app asks before it adds a new folder)` : 'storebound desktop: opened');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = downloadsFor(platform, process.arch);
|
||||||
|
let manifest: Manifest | null = null;
|
||||||
|
let error = '';
|
||||||
|
try { manifest = await fetchManifest(channel); } catch (e) { error = (e as Error).message; }
|
||||||
|
const chosen = manifest ? pick(manifest, keys, args.get('format')) : null;
|
||||||
|
|
||||||
|
if (args.has('json')) {
|
||||||
|
console.log(JSON.stringify({ installed, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!args.has('download')) {
|
||||||
|
console.log(installed ? 'storebound desktop is installed.' : 'storebound desktop is not installed on this machine.');
|
||||||
|
if (chosen) {
|
||||||
|
console.log(`\n ${DOWNLOAD_LABEL[chosen[0]]}, version ${manifest!.version}${chosen[1].codeSigned ? '' : ' (unsigned preview)'}:\n ${chosen[1].url}\n SHA-256 ${chosen[1].sha256}`);
|
||||||
|
} else if (!keys.length) {
|
||||||
|
console.log(`\n There is no desktop build for ${platform} ${process.arch} yet.`);
|
||||||
|
} else if (error) {
|
||||||
|
console.log(/HTTP 404/.test(error) ? '\n No desktop release is published yet.' : `\n (could not read the release list: ${error})`);
|
||||||
|
}
|
||||||
|
console.log(`\n Every download and its checksum: ${PAGE}`);
|
||||||
|
if (chosen) console.log(' Or: storebound desktop --download (saves the installer in ~/Downloads after checking its signature; it does not install it)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!manifest) throw new Error(`could not read the release list: ${error}`);
|
||||||
|
if (!chosen) throw new Error(keys.length ? `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet; see ${PAGE}` : `no desktop build for ${platform} ${process.arch}; see ${PAGE}`);
|
||||||
|
const [kind, d] = chosen;
|
||||||
|
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]})`);
|
||||||
|
const target = downloadsDir();
|
||||||
|
let out: string;
|
||||||
|
try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; }
|
||||||
|
console.log(`saved ${out}\n SHA-256 and signature checked (key of storebound desktop). Not installed: open it yourself to install.`);
|
||||||
|
if (!d.codeSigned) console.log(platform === 'darwin'
|
||||||
|
? ' This preview is not signed by Apple yet: after copying storebound to Applications, open it once, then System Settings > Privacy & Security > Open Anyway.'
|
||||||
|
: platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : '');
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
||||||
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
||||||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'],
|
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'],
|
||||||
|
'desktop': [() => import('./desktop'), 'open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, --download saves it to ~/Downloads after checking its signature (never installs)'],
|
||||||
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
||||||
'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios <path>)'],
|
'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios <path>)'],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
129
cli/lib/desktop-dist.ts
Normal file
129
cli/lib/desktop-dist.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Where the storebound desktop app comes from and where it lives once installed. The app is a separate signed
|
||||||
|
// download from storebound.dev (DMG, Windows installer, AppImage / deb) that updates itself; the npm package stays
|
||||||
|
// the CLI, MCP server and skills and carries no app binaries. `storebound desktop` (cli/desktop.ts) uses this.
|
||||||
|
//
|
||||||
|
// storebound.dev/desktop/latest.json is Tauri's updater manifest (version, notes, pub_date, platforms: the update
|
||||||
|
// bundles with their minisign signatures) plus a `downloads` map of the installers people download by hand, each
|
||||||
|
// with its URL, SHA-256, size and minisign signature (the same key as the updater). Pure functions, tested in
|
||||||
|
// tests/desktop-dist.test.ts.
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const BUNDLE_ID = 'dev.storebound.desktop';
|
||||||
|
export const PAGE = 'https://storebound.dev/desktop/';
|
||||||
|
export const MANIFEST: Record<'stable' | 'beta', string> = {
|
||||||
|
stable: 'https://storebound.dev/desktop/latest.json',
|
||||||
|
beta: 'https://storebound.dev/desktop/beta/latest.json',
|
||||||
|
};
|
||||||
|
/** The updater public key of the desktop app (tauri.conf.json plugins.updater.pubkey; a test keeps them equal). */
|
||||||
|
export const PUBLIC_KEY = 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK';
|
||||||
|
|
||||||
|
export type DownloadKey = 'macos-universal-dmg' | 'windows-x86_64-nsis' | 'linux-x86_64-appimage' | 'linux-x86_64-deb';
|
||||||
|
export interface Download {
|
||||||
|
file: string;
|
||||||
|
url: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
/** Tauri's form of the minisign signature (base64 of the .sig file). */
|
||||||
|
signature: string;
|
||||||
|
/** Code signing by the OS vendor's scheme (Developer ID + notarization, Authenticode); false = an unsigned preview. */
|
||||||
|
codeSigned: boolean;
|
||||||
|
}
|
||||||
|
export interface Manifest {
|
||||||
|
version: string;
|
||||||
|
notes?: string;
|
||||||
|
pub_date?: string;
|
||||||
|
platforms: Record<string, { signature: string; url: string }>;
|
||||||
|
downloads?: Partial<Record<DownloadKey, Download>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOWNLOAD_LABEL: Record<DownloadKey, string> = {
|
||||||
|
'macos-universal-dmg': 'macOS 11+ (Apple silicon and Intel), .dmg',
|
||||||
|
'windows-x86_64-nsis': 'Windows 10/11 x64, installer (.exe, per user)',
|
||||||
|
'linux-x86_64-appimage': 'Linux x64, AppImage',
|
||||||
|
'linux-x86_64-deb': 'Linux x64 (Debian, Ubuntu), .deb',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The installers for this OS and CPU, best first; [] when there is no build for it yet. */
|
||||||
|
export function downloadsFor(platform: NodeJS.Platform, arch: string): DownloadKey[] {
|
||||||
|
if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) return ['macos-universal-dmg'];
|
||||||
|
if (platform === 'win32' && arch === 'x64') return ['windows-x86_64-nsis'];
|
||||||
|
if (platform === 'linux' && arch === 'x64') return ['linux-x86_64-appimage', 'linux-x86_64-deb'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHA256 = /^[0-9a-f]{64}$/;
|
||||||
|
const SAFE_FILE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
/** Checks the shape of latest.json and that every URL is on storebound.dev over HTTPS. Returns the problems. */
|
||||||
|
export function manifestProblems(m: unknown): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x);
|
||||||
|
if (!isObj(m)) return ['latest.json is not an object'];
|
||||||
|
if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(m.version)) out.push('version is not a version number');
|
||||||
|
const ours = (u: unknown) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.startsWith('/desktop/'); } catch { return false; } };
|
||||||
|
if (!isObj(m.platforms)) out.push('platforms is missing');
|
||||||
|
else for (const [k, p] of Object.entries(m.platforms)) {
|
||||||
|
if (!isObj(p) || typeof p.signature !== 'string' || !p.signature || !ours(p.url)) out.push(`platforms.${k}: needs a signature and a storebound.dev URL`);
|
||||||
|
}
|
||||||
|
if (m.downloads !== undefined) {
|
||||||
|
if (!isObj(m.downloads)) out.push('downloads is not an object');
|
||||||
|
else for (const [k, d] of Object.entries(m.downloads)) {
|
||||||
|
if (!(k in DOWNLOAD_LABEL)) { out.push(`downloads.${k}: unknown kind`); continue; }
|
||||||
|
if (!isObj(d)) { out.push(`downloads.${k}: not an object`); continue; }
|
||||||
|
if (typeof d.file !== 'string' || !SAFE_FILE.test(d.file)) out.push(`downloads.${k}: bad file name`);
|
||||||
|
if (!ours(d.url) || typeof d.file !== 'string' || !String(d.url).endsWith(`/${d.file}`)) out.push(`downloads.${k}: URL must be storebound.dev/desktop/.../<file>`);
|
||||||
|
if (typeof d.sha256 !== 'string' || !SHA256.test(d.sha256)) out.push(`downloads.${k}: sha256`);
|
||||||
|
if (typeof d.size !== 'number' || !(d.size > 0)) out.push(`downloads.${k}: size`);
|
||||||
|
if (typeof d.signature !== 'string' || !d.signature) out.push(`downloads.${k}: signature`);
|
||||||
|
if (typeof d.codeSigned !== 'boolean') out.push(`downloads.${k}: codeSigned`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deep link that makes the app add (after its own dialog) or select an app folder. */
|
||||||
|
export function openFolderLink(dir: string): string {
|
||||||
|
return `storebound://open?path=${encodeURIComponent(dir)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' | 'deb' }
|
||||||
|
|
||||||
|
/** Where the app is installed on this machine, or null. `exists` and `home` are injectable for tests. */
|
||||||
|
export function findInstalled(platform: NodeJS.Platform, env: Record<string, string | undefined> = process.env,
|
||||||
|
exists: (p: string) => boolean = existsSync, home = homedir(),
|
||||||
|
list: (dir: string) => string[] = d => { try { return readdirSync(d); } catch { return []; } }): Installed | null {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
for (const dir of ['/Applications', join(home, 'Applications')]) {
|
||||||
|
const app = join(dir, 'storebound.app');
|
||||||
|
if (exists(join(app, 'Contents/MacOS/storebound-desktop'))) return { path: app, how: 'bundle' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
// NSIS per-user install: %LOCALAPPDATA%\storebound\storebound-desktop.exe (all-users: %ProgramFiles%\storebound)
|
||||||
|
for (const base of [env.LOCALAPPDATA, env.ProgramFiles]) {
|
||||||
|
if (!base) continue;
|
||||||
|
const exe = `${base}\\storebound\\storebound-desktop.exe`;
|
||||||
|
if (exists(exe)) return { path: exe, how: 'exe' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (exists('/usr/bin/storebound-desktop')) return { path: '/usr/bin/storebound-desktop', how: 'deb' };
|
||||||
|
// an AppImage is wherever its owner put it: look in the usual folders (storebound_0.1.0_amd64.AppImage, renamed ones)
|
||||||
|
for (const dir of [join(home, 'Applications'), join(home, '.local/bin'), join(home, 'Downloads')]) {
|
||||||
|
const name = list(dir).filter(n => /^storebound(?:[-_][A-Za-z0-9._-]*)?\.AppImage$/i.test(n)).sort().at(-1);
|
||||||
|
if (name) return { path: join(dir, name), how: 'appimage' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The command that opens the installed app (with a deep link, or plain). Never through a shell. */
|
||||||
|
export function openCommand(platform: NodeJS.Platform, installed: Installed, link: string | null): string[] {
|
||||||
|
if (platform === 'darwin') return link ? ['open', '-b', BUNDLE_ID, link] : ['open', '-b', BUNDLE_ID];
|
||||||
|
// Windows and Linux: the app takes the link as its argument; a running app gets it through single-instance
|
||||||
|
return link ? [installed.path, link] : [installed.path];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadsDir = (home = homedir()): string => join(home, 'Downloads');
|
||||||
63
cli/lib/minisign.ts
Normal file
63
cli/lib/minisign.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// Minisign signature checks (the format of Tauri's updater signatures, `tauri signer sign`), with node:crypto only.
|
||||||
|
// A Tauri public key or `.sig` file is the base64 of the minisign text file:
|
||||||
|
// public key: "untrusted comment: ...\n" + base64("Ed" | key id (8) | Ed25519 public key (32))
|
||||||
|
// signature: "untrusted comment: ...\n" + base64(alg (2) | key id (8) | signature (64)) + "\n"
|
||||||
|
// "trusted comment: ...\n" + base64(global signature (64))
|
||||||
|
// alg "ED" signs the BLAKE2b-512 hash of the file (what Tauri writes), "Ed" the file itself. The global signature
|
||||||
|
// covers the signature and the trusted comment, so the comment (timestamp, file name) cannot be swapped either.
|
||||||
|
import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface PublicKey { keyId: string; key: Buffer }
|
||||||
|
export interface Signature { alg: 'Ed' | 'ED'; keyId: string; sig: Buffer; trustedComment: string; globalSig: Buffer }
|
||||||
|
|
||||||
|
const b64 = (s: string): Buffer => {
|
||||||
|
const t = s.trim();
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(t)) throw new Error('not base64');
|
||||||
|
return Buffer.from(t, 'base64');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The text of a minisign file, from the file itself or from Tauri's base64 wrapping of it. */
|
||||||
|
function text(input: string): string {
|
||||||
|
const t = input.trim();
|
||||||
|
if (t.startsWith('untrusted comment:')) return t;
|
||||||
|
return b64(t).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePublicKey(input: string): PublicKey {
|
||||||
|
const lines = text(input).split(/\r?\n/);
|
||||||
|
if (!lines[0]?.startsWith('untrusted comment:') || !lines[1]) throw new Error('minisign public key: unexpected format');
|
||||||
|
const raw = b64(lines[1]);
|
||||||
|
if (raw.length !== 42 || raw.subarray(0, 2).toString('latin1') !== 'Ed') throw new Error('minisign public key: not an Ed25519 key');
|
||||||
|
return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString('hex').toUpperCase(), key: raw.subarray(10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSignature(input: string): Signature {
|
||||||
|
const lines = text(input).split(/\r?\n/);
|
||||||
|
if (!lines[0]?.startsWith('untrusted comment:') || !lines[1] || !lines[2]?.startsWith('trusted comment: ') || !lines[3]) {
|
||||||
|
throw new Error('minisign signature: unexpected format');
|
||||||
|
}
|
||||||
|
const raw = b64(lines[1]);
|
||||||
|
const alg = raw.subarray(0, 2).toString('latin1');
|
||||||
|
if (raw.length !== 74 || (alg !== 'Ed' && alg !== 'ED')) throw new Error('minisign signature: unknown algorithm');
|
||||||
|
const globalSig = b64(lines[3]);
|
||||||
|
if (globalSig.length !== 64) throw new Error('minisign signature: bad global signature');
|
||||||
|
return { alg, keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString('hex').toUpperCase(), sig: raw.subarray(10), trustedComment: lines[2].slice('trusted comment: '.length), globalSig };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ed25519(key: Buffer) {
|
||||||
|
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: key.toString('base64url') }, format: 'jwk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerifyResult = { ok: true; trustedComment: string } | { ok: false; reason: string };
|
||||||
|
|
||||||
|
/** Checks `data` against a minisign signature and public key (either may be Tauri's base64 form). */
|
||||||
|
export function verify(data: Uint8Array, signature: string, publicKey: string): VerifyResult {
|
||||||
|
let pk: PublicKey; let s: Signature;
|
||||||
|
try { pk = parsePublicKey(publicKey); s = parseSignature(signature); } catch (e) { return { ok: false, reason: (e as Error).message }; }
|
||||||
|
if (pk.keyId !== s.keyId) return { ok: false, reason: `signed with key ${s.keyId}, expected ${pk.keyId}` };
|
||||||
|
const key = ed25519(pk.key);
|
||||||
|
const msg = s.alg === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data);
|
||||||
|
if (!edVerify(null, msg, key, s.sig)) return { ok: false, reason: 'the signature does not match the file' };
|
||||||
|
if (!edVerify(null, Buffer.concat([s.sig, Buffer.from(s.trustedComment, 'utf8')]), key, s.globalSig)) return { ok: false, reason: 'the trusted comment was changed' };
|
||||||
|
return { ok: true, trustedComment: s.trustedComment };
|
||||||
|
}
|
||||||
136
tests/desktop-dist.test.ts
Normal file
136
tests/desktop-dist.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// `storebound desktop`: minisign checks (Tauri's updater signatures), latest.json, the installer for each system,
|
||||||
|
// finding the installed app and the deep link it opens.
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||||
|
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { download, pick } from '../cli/desktop';
|
||||||
|
import { downloadsFor, findInstalled, type Manifest, manifestProblems, openCommand, openFolderLink, PUBLIC_KEY } from '../cli/lib/desktop-dist';
|
||||||
|
import { parsePublicKey, parseSignature, verify } from '../cli/lib/minisign';
|
||||||
|
|
||||||
|
/** A throwaway minisign key pair in Tauri's base64 form, and a signer like `tauri signer sign`. */
|
||||||
|
function keyPair() {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const raw = Buffer.from(publicKey.export({ format: 'jwk' }).x!, 'base64url');
|
||||||
|
const id = Buffer.from('0102030405060708', 'hex');
|
||||||
|
const pubText = `untrusted comment: minisign public key: ${Buffer.from(id).reverse().toString('hex').toUpperCase()}\n${Buffer.concat([Buffer.from('Ed'), id, raw]).toString('base64')}\n`;
|
||||||
|
const signer = (data: Uint8Array, alg: 'Ed' | 'ED' = 'ED', comment = 'timestamp:1\tfile:x') => {
|
||||||
|
const msg = alg === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data);
|
||||||
|
const sig = sign(null, msg, privateKey);
|
||||||
|
const global = sign(null, Buffer.concat([sig, Buffer.from(comment)]), privateKey);
|
||||||
|
const text = `untrusted comment: signature from tauri secret key\n${Buffer.concat([Buffer.from(alg), id, sig]).toString('base64')}\ntrusted comment: ${comment}\n${global.toString('base64')}\n`;
|
||||||
|
return Buffer.from(text).toString('base64');
|
||||||
|
};
|
||||||
|
return { pub: Buffer.from(pubText).toString('base64'), pubText, signer };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('minisign', () => {
|
||||||
|
const k = keyPair();
|
||||||
|
const data = new Uint8Array(Buffer.from('storebound installer bytes'));
|
||||||
|
test('prehashed (Tauri) and plain signatures verify, in both wrappings', () => {
|
||||||
|
expect(verify(data, k.signer(data, 'ED'), k.pub)).toEqual({ ok: true, trustedComment: 'timestamp:1\tfile:x' });
|
||||||
|
expect(verify(data, k.signer(data, 'Ed'), k.pubText).ok).toBe(true);
|
||||||
|
expect(parsePublicKey(k.pub).keyId).toBe('0807060504030201');
|
||||||
|
expect(parseSignature(k.signer(data)).alg).toBe('ED');
|
||||||
|
});
|
||||||
|
test('a changed file, comment, key or garbage fails', () => {
|
||||||
|
const changed = new Uint8Array(data); changed[0] = changed[0]! ^ 1;
|
||||||
|
expect(verify(changed, k.signer(data), k.pub)).toEqual({ ok: false, reason: 'the signature does not match the file' });
|
||||||
|
const sig = Buffer.from(k.signer(data), 'base64').toString().replace('file:x', 'file:y');
|
||||||
|
expect(verify(data, Buffer.from(sig).toString('base64'), k.pub)).toEqual({ ok: false, reason: 'the trusted comment was changed' });
|
||||||
|
const other = keyPair();
|
||||||
|
expect(verify(data, k.signer(data), other.pub).ok).toBe(false);
|
||||||
|
expect(verify(data, 'not a signature', k.pub).ok).toBe(false);
|
||||||
|
expect(verify(data, k.signer(data), 'AAAA').ok).toBe(false);
|
||||||
|
});
|
||||||
|
test('the CLI carries the desktop app\'s updater key', () => {
|
||||||
|
const conf = JSON.parse(readFileSync(join(import.meta.dir, '../apps/desktop/src-tauri/tauri.conf.json'), 'utf8'));
|
||||||
|
expect(PUBLIC_KEY).toBe(conf.plugins.updater.pubkey);
|
||||||
|
expect(parsePublicKey(PUBLIC_KEY).keyId).toMatch(/^[0-9A-F]{16}$/);
|
||||||
|
// the downloads page prints the minisign key line for people who verify by hand
|
||||||
|
const line = Buffer.from(PUBLIC_KEY, 'base64').toString().split('\n')[1]!;
|
||||||
|
expect(readFileSync(join(import.meta.dir, '../site/desktop/index.html'), 'utf8')).toContain(`<code class="dl-key">${line}</code>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function manifest(k: ReturnType<typeof keyPair>, data: Uint8Array): Manifest {
|
||||||
|
const sha256 = createHash('sha256').update(data).digest('hex');
|
||||||
|
const file = 'storebound_0.1.0_universal.dmg';
|
||||||
|
return {
|
||||||
|
version: '0.1.0', pub_date: '2026-10-11T00:00:00Z',
|
||||||
|
platforms: { 'darwin-aarch64': { signature: k.signer(data), url: 'https://storebound.dev/desktop/dl/0.1.0/storebound_0.1.0_universal.app.tar.gz' } },
|
||||||
|
downloads: {
|
||||||
|
'macos-universal-dmg': { file, url: `https://storebound.dev/desktop/dl/0.1.0/${file}`, sha256, size: data.length, signature: k.signer(data), codeSigned: false },
|
||||||
|
'linux-x86_64-deb': { file: 'storebound_0.1.0_amd64.deb', url: 'https://storebound.dev/desktop/dl/0.1.0/storebound_0.1.0_amd64.deb', sha256, size: data.length, signature: k.signer(data), codeSigned: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('latest.json and downloads', () => {
|
||||||
|
const k = keyPair();
|
||||||
|
const data = new Uint8Array(Buffer.from('a'.repeat(1000)));
|
||||||
|
test('installers per system', () => {
|
||||||
|
expect(downloadsFor('darwin', 'arm64')).toEqual(['macos-universal-dmg']);
|
||||||
|
expect(downloadsFor('darwin', 'x64')).toEqual(['macos-universal-dmg']);
|
||||||
|
expect(downloadsFor('win32', 'x64')).toEqual(['windows-x86_64-nsis']);
|
||||||
|
expect(downloadsFor('linux', 'x64')).toEqual(['linux-x86_64-appimage', 'linux-x86_64-deb']);
|
||||||
|
expect(downloadsFor('linux', 'arm64')).toEqual([]);
|
||||||
|
const m = manifest(k, data);
|
||||||
|
expect(pick(m, downloadsFor('darwin', 'arm64'))?.[0]).toBe('macos-universal-dmg');
|
||||||
|
expect(pick(m, downloadsFor('linux', 'x64'))?.[0]).toBe('linux-x86_64-deb');
|
||||||
|
expect(pick(m, downloadsFor('linux', 'x64'), 'appimage')).toBeNull();
|
||||||
|
expect(pick(m, downloadsFor('win32', 'x64'))).toBeNull();
|
||||||
|
});
|
||||||
|
test('only storebound.dev URLs, hashes and signatures pass', () => {
|
||||||
|
const m = manifest(k, data);
|
||||||
|
expect(manifestProblems(m)).toEqual([]);
|
||||||
|
const bad = structuredClone(m) as Manifest;
|
||||||
|
bad.downloads!['macos-universal-dmg']!.url = 'https://evil.test/desktop/storebound_0.1.0_universal.dmg';
|
||||||
|
bad.platforms['darwin-aarch64']!.url = 'http://storebound.dev/desktop/x.tar.gz';
|
||||||
|
bad.downloads!['linux-x86_64-deb']!.sha256 = 'abc';
|
||||||
|
expect(manifestProblems(bad)).toHaveLength(3);
|
||||||
|
expect(manifestProblems({ version: 'latest', platforms: {} })).toEqual(['version is not a version number']);
|
||||||
|
expect(manifestProblems([])).toEqual(['latest.json is not an object']);
|
||||||
|
});
|
||||||
|
test('download keeps the file only when size, SHA-256 and signature match', async () => {
|
||||||
|
const m = manifest(k, data);
|
||||||
|
const d = m.downloads!['macos-universal-dmg']!;
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sb-dl-'));
|
||||||
|
const serve = (body: Uint8Array) => async () => new Response(Buffer.from(body));
|
||||||
|
const out = await download(d, dir, serve(data), k.pub);
|
||||||
|
expect(readFileSync(out)).toEqual(Buffer.from(data));
|
||||||
|
const evil = new Uint8Array(data); evil[3] = evil[3]! ^ 1;
|
||||||
|
await expect(download({ ...d, file: 'b.dmg' }, dir, serve(evil), k.pub)).rejects.toThrow(/SHA-256/);
|
||||||
|
const sha = createHash('sha256').update(evil).digest('hex');
|
||||||
|
await expect(download({ ...d, file: 'c.dmg', sha256: sha }, dir, serve(evil), k.pub)).rejects.toThrow(/signature/);
|
||||||
|
await expect(download({ ...d, file: 'e.dmg' }, dir, serve(data.subarray(1)), k.pub)).rejects.toThrow(/bytes/);
|
||||||
|
await expect(download({ ...d, file: 'f.dmg' }, dir, async () => new Response('', { status: 404 }), k.pub)).rejects.toThrow(/404/);
|
||||||
|
for (const f of ['b.dmg', 'c.dmg', 'e.dmg', 'f.dmg']) expect(existsSync(join(dir, f))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the installed app', () => {
|
||||||
|
const at = (paths: string[]) => (p: string) => paths.includes(p);
|
||||||
|
test('found per system', () => {
|
||||||
|
expect(findInstalled('darwin', {}, at(['/Applications/storebound.app/Contents/MacOS/storebound-desktop']), '/Users/you'))
|
||||||
|
.toEqual({ path: '/Applications/storebound.app', how: 'bundle' });
|
||||||
|
expect(findInstalled('darwin', {}, at(['/Users/you/Applications/storebound.app/Contents/MacOS/storebound-desktop']), '/Users/you')?.path)
|
||||||
|
.toBe('/Users/you/Applications/storebound.app');
|
||||||
|
expect(findInstalled('darwin', {}, at([]), '/Users/you')).toBeNull();
|
||||||
|
expect(findInstalled('win32', { LOCALAPPDATA: 'C:\\Users\\you\\AppData\\Local' }, at(['C:\\Users\\you\\AppData\\Local\\storebound\\storebound-desktop.exe']), 'C:\\Users\\you'))
|
||||||
|
.toEqual({ path: 'C:\\Users\\you\\AppData\\Local\\storebound\\storebound-desktop.exe', how: 'exe' });
|
||||||
|
expect(findInstalled('linux', {}, at(['/usr/bin/storebound-desktop']), '/home/you')).toEqual({ path: '/usr/bin/storebound-desktop', how: 'deb' });
|
||||||
|
const list = (d: string) => (d === '/home/you/Applications' ? ['notes.txt', 'storebound_0.1.0_amd64.AppImage', 'storebound_0.2.0_amd64.AppImage'] : []);
|
||||||
|
expect(findInstalled('linux', {}, at([]), '/home/you', list)).toEqual({ path: '/home/you/Applications/storebound_0.2.0_amd64.AppImage', how: 'appimage' });
|
||||||
|
expect(findInstalled('linux', {}, at([]), '/home/you', () => ['storeboundish.AppImage'])).toBeNull();
|
||||||
|
});
|
||||||
|
test('opened with the folder as a deep link, never through a shell', () => {
|
||||||
|
const link = openFolderLink('/Users/you/games/Demo App & co');
|
||||||
|
expect(link).toBe('storebound://open?path=%2FUsers%2Fyou%2Fgames%2FDemo App%20%26%20co');
|
||||||
|
expect(new URL(link).searchParams.get('path')).toBe('/Users/you/games/Demo App & co');
|
||||||
|
expect(openCommand('darwin', { path: '/Applications/storebound.app', how: 'bundle' }, link)).toEqual(['open', '-b', 'dev.storebound.desktop', link]);
|
||||||
|
expect(openCommand('darwin', { path: '/Applications/storebound.app', how: 'bundle' }, null)).toEqual(['open', '-b', 'dev.storebound.desktop']);
|
||||||
|
expect(openCommand('win32', { path: 'C:\\x\\storebound-desktop.exe', how: 'exe' }, link)).toEqual(['C:\\x\\storebound-desktop.exe', link]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue