- 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
133 lines
7.6 KiB
TypeScript
133 lines
7.6 KiB
TypeScript
// Where the storebound desktop app comes from and where it lives once installed. The app is a signed download from
|
|
// storebound.dev (DMG, Windows installer, AppImage / deb) with the same version number as the npm CLI; `storebound
|
|
// desktop` (cli/desktop.ts) installs, updates and opens it, and the app can update itself. The npm package carries no
|
|
// app binaries.
|
|
//
|
|
// 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/#download';
|
|
export const MANIFEST: Record<'stable' | 'beta', string> = {
|
|
stable: 'https://storebound.dev/desktop/latest.json',
|
|
beta: 'https://storebound.dev/desktop/beta/latest.json',
|
|
};
|
|
/** The manifest of one version (written next to its files on publish): `storebound desktop` installs the CLI's own
|
|
* version from it, so the app and the CLI have one version number. */
|
|
export const versionManifest = (version: string): string => `https://storebound.dev/desktop/dl/${encodeURIComponent(version)}/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');
|