// 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; downloads?: Partial>; } export const DOWNLOAD_LABEL: Record = { '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 => !!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/.../`); 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 = 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');