// rnx desktop install — download and install the optional desktop GUI // // the CLI + daemon are the canonical rnx surface; this command is only // for users who want the Electron-based GUI companion on top of that. // // fetches the right platform artifact from https://contrast.dev/api/electron-release/ // and installs it into the OS-native location that findDesktopCompanion() looks for: // macos: ~/Applications/rnx.app (or /Applications if writable) // linux: ~/Applications/rnx.AppImage // win: spawns the NSIS installer // // post-install the electron-updater feed (same URL) handles upgrades inside the // running app, so this command is a first-install bootstrap, not an update path. import { spawn, spawnSync } from 'child_process' import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync, statSync, unlinkSync, } from 'fs' import { tmpdir } from 'os' import { dirname, join, resolve } from 'path' import { findDesktopCompanion } from '../desktop-companion' import { confirm } from '../prompt' import { rnxExit } from '../run-rnx' import { accent, bold, failure, muted, success, withSpinner } from '../ui' const DOWNLOAD_BASE = 'https://contrast.dev/api/electron-release' type TargetPlatform = 'mac-arm64' | 'mac-x64' | 'win-x64' | 'linux-x64' | 'linux-arm64' interface Target { platform: TargetPlatform filename: string url: string install: (downloaded: string) => Promise } function detectTarget(): Target | null { const arch = process.arch if (process.platform === 'darwin') { const platform: TargetPlatform = arch === 'arm64' ? 'mac-arm64' : 'mac-x64' const filename = `sootsim-latest-${platform}.dmg` return { platform, filename, url: `${DOWNLOAD_BASE}/${filename}`, install: installMacDmg, } } if (process.platform === 'linux') { // ship arch-appropriate AppImages. on Apple Silicon Macs (the default // host for orb/multipass VMs) the linux VM is arm64, so the x64 // AppImage fails at runtime with "Dynamic loader not found: // /lib64/ld-linux-x86-64.so.2". detect the running arch and pick the // matching artifact. const platform: TargetPlatform = arch === 'arm64' ? 'linux-arm64' : 'linux-x64' const filename = `sootsim-latest-${platform}.AppImage` return { platform, filename, url: `${DOWNLOAD_BASE}/${filename}`, install: installLinuxAppImage, } } if (process.platform === 'win32') { const filename = `sootsim-latest-win-x64.exe` return { platform: 'win-x64', filename, url: `${DOWNLOAD_BASE}/${filename}`, install: installWindowsExe, } } return null } interface DownloadOptions { url: string dest: string onProgress?: (received: number, total: number | null) => void } async function download({ url, dest, onProgress }: DownloadOptions): Promise { const response = await fetch(url, { redirect: 'follow' }) if (!response.ok) { throw new Error(`${response.status} ${response.statusText} (${url})`) } const contentLength = response.headers.get('content-length') const total = contentLength ? Number(contentLength) : null if (!response.body) { throw new Error(`empty response body (${url})`) } mkdirSync(dirname(dest), { recursive: true }) const file = createWriteStream(dest) const reader = response.body.getReader() let received = 0 try { while (true) { const { done, value } = await reader.read() if (done) break file.write(Buffer.from(value)) received += value.byteLength onProgress?.(received, total) } } finally { await new Promise((resolve, reject) => { file.end((err?: Error | null) => (err ? reject(err) : resolve())) }) } } function renderProgress(received: number, total: number | null): string { const mb = (received / (1024 * 1024)).toFixed(1) if (!total) return ` ${mb} MB` const pct = Math.min(100, Math.round((received / total) * 100)) const totalMb = (total / (1024 * 1024)).toFixed(1) const barWidth = 24 const filled = Math.round((pct / 100) * barWidth) const bar = accent('█'.repeat(filled)) + muted('░'.repeat(barWidth - filled)) return ` ${bar} ${pct}% ${mb} / ${totalMb} MB` } async function installMacDmg(dmgPath: string): Promise { const mountOutput = spawnSync( 'hdiutil', ['attach', '-nobrowse', '-readonly', dmgPath], { encoding: 'utf8', }, ) if (mountOutput.status !== 0) { throw new Error( `hdiutil attach failed: ${mountOutput.stderr || mountOutput.stdout}`.trim(), ) } const mountLines = mountOutput.stdout.trim().split('\n') const mountLine = mountLines[mountLines.length - 1] const mountPoint = mountLine.split('\t').pop()?.trim() if (!mountPoint || !existsSync(mountPoint)) { throw new Error(`could not determine dmg mount point (output: ${mountOutput.stdout})`) } const appSource = join(mountPoint, 'rnx.app') if (!existsSync(appSource)) { spawnSync('hdiutil', ['detach', '-force', mountPoint]) throw new Error(`rnx.app not found inside ${mountPoint}`) } // prefer /Applications if writable, otherwise ~/Applications (same paths // findDesktopCompanion checks, so no extra config needed post-install). const systemApps = '/Applications' const userApps = resolve(process.env.HOME || '', 'Applications') let destRoot = systemApps try { const probe = join(systemApps, `.rnx-write-probe-${process.pid}`) spawnSync('touch', [probe]) if (existsSync(probe)) rmSync(probe, { force: true }) else destRoot = userApps } catch { destRoot = userApps } if (destRoot === userApps) { mkdirSync(destRoot, { recursive: true }) } const appDest = join(destRoot, 'rnx.app') // copy alongside the destination and swap, rather than deleting the installed // app first. removing it up front means a failed copy leaves the user with no // app at all, having destroyed a working install to do it. the staging path is // in the same directory so the swap is a rename on one volume. const staging = join(destRoot, `.rnx-incoming-${process.pid}.app`) rmSync(staging, { recursive: true, force: true }) const copy = spawnSync('cp', ['-R', appSource, staging]) if (copy.status !== 0) { spawnSync('hdiutil', ['detach', '-force', mountPoint]) rmSync(staging, { recursive: true, force: true }) throw new Error(`copy to ${destRoot} failed: ${copy.stderr?.toString() || ''}`.trim()) } const detach = spawnSync('hdiutil', ['detach', '-force', mountPoint]) const previous = join(destRoot, `.rnx-previous-${process.pid}.app`) if (existsSync(appDest)) { const park = spawnSync('mv', [appDest, previous]) if (park.status !== 0) { rmSync(staging, { recursive: true, force: true }) throw new Error( `could not replace ${appDest}: ${park.stderr?.toString() || ''}`.trim(), ) } } const swap = spawnSync('mv', [staging, appDest]) if (swap.status !== 0) { // put the working install back before reporting failure if (existsSync(previous)) spawnSync('mv', [previous, appDest]) rmSync(staging, { recursive: true, force: true }) throw new Error( `could not move into ${appDest}: ${swap.stderr?.toString() || ''}`.trim(), ) } rmSync(previous, { recursive: true, force: true }) if (detach.status !== 0) { console.warn( ` warning: failed to unmount ${mountPoint} (${detach.stderr?.toString().trim() || 'unknown error'})`, ) } // clear the quarantine attribute so gatekeeper doesn't nag on first launch; // the app is already notarized so this is safe. spawnSync('xattr', ['-dr', 'com.apple.quarantine', appDest]) return appDest } async function installLinuxAppImage(appImagePath: string): Promise { const destDir = resolve(process.env.HOME || '', 'Applications') mkdirSync(destDir, { recursive: true }) const dest = join(destDir, 'rnx.AppImage') if (existsSync(dest)) rmSync(dest, { force: true }) // copyFile + unlink instead of rename: /tmp and ~/Applications are // routinely on separate mounts in containers / VMs, where renameSync // fails EXDEV. copy is portable across mounts and the unlink at the // end keeps the staging dir clean. copyFileSync(appImagePath, dest) unlinkSync(appImagePath) chmodSync(dest, 0o755) return dest } async function installWindowsExe(exePath: string): Promise { // nsis installer prompts and self-elevates; run it and let it take over. await new Promise((resolvePromise, rejectPromise) => { const child = spawn('cmd', ['/c', 'start', '""', '/wait', exePath], { stdio: 'inherit', }) child.once('error', rejectPromise) child.once('exit', (code) => { if (code === 0) resolvePromise() else rejectPromise(new Error(`installer exited with code ${code}`)) }) }) const installed = findDesktopCompanion() if (!installed) { throw new Error('the installer finished but the rnx application was not registered') } return installed.path } export async function runInstallDesktop(args: string[]) { if (args.includes('--help') || args.includes('-h')) { console.log(` rnx desktop install Download and install the optional rnx Desktop app. The CLI works without it. Choose Desktop when you want a dedicated native window. usage: rnx desktop install [options] options: -y, --yes skip confirmation and install immediately --force reinstall even if the companion is already present examples: rnx desktop install rnx desktop install --yes `) rnxExit(0) } const skipConfirm = args.includes('--yes') || args.includes('-y') || process.env.RNX_NO_PROMPT === '1' || process.env.CI === '1' || !process.stdin.isTTY const force = args.includes('--force') const existing = findDesktopCompanion() if (existing && !force) { console.log(` ${success('✓')} Desktop app is already installed at ${existing.path}`) console.log(` ${muted('Pass --force to reinstall.')}`) return } const target = detectTarget() if (!target) { console.error( ` ${failure('×')} No desktop build is available for ${process.platform}/${process.arch}.`, ) console.error( ' Supported: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64', ) rnxExit(1) } console.log(` ${bold('Desktop app')}`) console.log(` Platform ${target.platform}`) console.log(` Download ${target.url}`) console.log() if (!skipConfirm) { const ok = await confirm('Download and install now?', true) if (!ok) { console.log(` ${muted('Cancelled.')}`) return } console.log() } const workDir = join(tmpdir(), `rnx-install-${Date.now()}`) const downloadPath = join(workDir, target.filename) let lastDraw = 0 process.stdout.write(` Downloading ${target.filename}...\n`) try { await download({ url: target.url, dest: downloadPath, onProgress: (received, total) => { const now = Date.now() if (now - lastDraw < 100 && received < (total ?? Infinity)) return lastDraw = now if (process.stdout.isTTY) { process.stdout.write(`\r\x1b[2K${renderProgress(received, total)}`) } }, }) } catch (err) { rmSync(workDir, { recursive: true, force: true }) const message = err instanceof Error ? err.message : String(err) console.error(`\n ${failure('×')} Download failed: ${message}`) console.error( ` The ${target.platform} desktop build may not be published yet, or your`, ) console.error( ' network blocked the request. Check your connection and try again later.', ) console.error(' The CLI and daemon work without the desktop app. Run `rnx --help`.') rnxExit(1) } if (process.stdout.isTTY) process.stdout.write('\n') const size = statSync(downloadPath).size console.log(` ${success('✓')} Downloaded ${(size / (1024 * 1024)).toFixed(1)} MB`) console.log() try { const installedAt = await withSpinner('Installing the desktop app', () => target.install(downloadPath), ) console.log(` Installed at ${installedAt}`) rmSync(workDir, { recursive: true, force: true }) } catch (err) { console.error( ` ${failure('×')} Install failed: ${err instanceof Error ? err.message : String(err)}`, ) // the download is good, only placing the app failed. on macOS the disk // image installs itself perfectly well by hand, so open it and let the user // drag rather than leaving them at a dead end. keep workDir for that. if (process.platform === 'darwin') { spawnSync('open', [downloadPath]) console.error(` Opened ${target.filename} — drag rnx to Applications to finish.`) } else { console.error(` Keeping the download at ${downloadPath} for manual installation.`) } rnxExit(1) } console.log() console.log(` ${bold('Ready')}`) console.log(' rnx desktop Launch the desktop app') console.log(' rnx open Open a demo or bundle in it') }