README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
129 lines
7.2 KiB
TypeScript
129 lines
7.2 KiB
TypeScript
// 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/#download';
|
|
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');
|