import { execFileSync, spawn } from 'child_process' import { existsSync, readdirSync } from 'fs' import { dirname, join, resolve } from 'path' import { fileURLToPath } from 'url' // resolved lazily — sootsim-engine is a workspace package in dev, but absent // from the published npm tarball. don't crash module load just because the // engine package isn't installed; commands that don't need the dev-electron // path (most of them) shouldn't care. let _sootsimRoot: string | null | undefined function getSootsimRoot(): string | null { if (_sootsimRoot !== undefined) return _sootsimRoot try { _sootsimRoot = dirname( fileURLToPath(import.meta.resolve('sootsim-engine/package.json')), ) } catch { _sootsimRoot = null } return _sootsimRoot } const SOOTSIM_BUNDLE_ID = 'dev.sootsim.simulator' export interface DesktopCompanionInstall { path: string platform: NodeJS.Platform kind: 'mac-app' | 'linux-appimage' | 'linux-binary' | 'win-exe' | 'dev-electron' // present for dev-electron: the sootsim-engine dir that electron treats // as the app root (its package.json "main" points at dist-electron/main.cjs). engineDir?: string } export interface DesktopCompanionLaunchResult { launched: boolean via?: 'mac-app' | 'linux-appimage' | 'linux-binary' | 'win-exe' | 'dev-electron' target?: string } // dev-electron: when running from inside the Contrast checkout with sootsim-engine // built and the electron dev dep present, prefer launching the local unpacked // electron instead of the stale packaged /Applications/rnx.app. // // rationale: in dev we want electron to hit http://localhost:5173 // (src-electron/main.ts does this when !app.isPackaged). // using the local `node_modules/.bin/electron` binary against the engine dir // gives us that: isPackaged=false → loads the live dev server. function findDevElectron(): DesktopCompanionInstall | null { // engine dir must have a built main.cjs for electron to boot. the dev build // chain (`bun run dev:electron` / `bun run build:electron-main`) produces // this; bun dev keeps it fresh via a one-shot step added to run-all. const engineDir = getSootsimRoot() if (!engineDir) return null const engineMain = join(engineDir, 'dist-electron/main.cjs') if (!existsSync(engineMain)) return null // walk up from engine dir looking for a node_modules/.bin/electron. // the repo layout is packages/sootsim-engine next to a root node_modules, // but also works when installed as a dependency. const electronBin = findElectronBin(engineDir) if (!electronBin) return null return { path: electronBin, platform: process.platform, kind: 'dev-electron', engineDir, } } // detect whether the sootsim-engine we resolved is the in-repo workspace // checkout (has src-electron/main.ts) vs something pulled in as a dep. // this matters because repo development has one supported path: dev electron // against the live :5173 renderer. function isInRepoCheckout(): boolean { const root = getSootsimRoot() if (!root) return false return existsSync(join(root, 'src-electron/main.ts')) } function findElectronBin(startDir: string): string | null { let dir = startDir for (let i = 0; i < 6; i++) { const candidate = join(dir, 'node_modules/.bin/electron') if (existsSync(candidate)) return candidate const parent = dirname(dir) if (parent === dir) break dir = parent } return null } function findMacApp(): DesktopCompanionInstall | null { const root = getSootsimRoot() const candidates = [ '/Applications/rnx.app', resolve(process.env.HOME || '', 'Applications/rnx.app'), ...(root ? [resolve(root, 'app/rnx.app')] : []), ] const found = candidates.find((candidate) => existsSync(candidate)) if (found) { return { path: found, platform: 'darwin', kind: 'mac-app', } } try { const result = execFileSync( 'mdfind', [`kMDItemCFBundleIdentifier == "${SOOTSIM_BUNDLE_ID}"`], { encoding: 'utf8', timeout: 3000 }, ).trim() if (result) { return { path: result.split('\n')[0], platform: 'darwin', kind: 'mac-app', } } } catch {} return null } function findFirstAppImage(dir: string): string | null { if (!existsSync(dir)) return null const match = readdirSync(dir) .filter((entry) => entry.startsWith('rnx') && entry.endsWith('.AppImage')) .sort() .reverse()[0] return match ? join(dir, match) : null } function findLinuxInstall(): DesktopCompanionInstall | null { const root = getSootsimRoot() const directCandidates = [ resolve(process.env.HOME || '', 'Applications/rnx.AppImage'), resolve(process.env.HOME || '', '.local/bin/rnx.AppImage'), '/opt/rnx/rnx.AppImage', '/opt/rnx/rnx', ...(root ? [resolve(root, 'app/rnx.AppImage'), resolve(root, 'release/linux-unpacked/rnx')] : []), ] const directMatch = directCandidates.find((candidate) => existsSync(candidate)) if (directMatch) { return { path: directMatch, platform: 'linux', kind: directMatch.endsWith('.AppImage') ? 'linux-appimage' : 'linux-binary', } } const scannedMatch = root ? findFirstAppImage(resolve(root, 'app')) || findFirstAppImage(resolve(root, 'release')) : null if (!scannedMatch) return null return { path: scannedMatch, platform: 'linux', kind: 'linux-appimage', } } function findWindowsInstall(): DesktopCompanionInstall | null { for (const hive of ['HKCU', 'HKLM']) { try { const output = execFileSync( 'reg', ['query', `${hive}\\Software\\Classes\\rnx\\shell\\open\\command`, '/ve'], { encoding: 'utf8', timeout: 3000 }, ) const executable = /"([^"\r\n]+\.exe)"/i.exec(output)?.[1] if (executable && existsSync(executable)) { return { path: executable, platform: 'win32', kind: 'win-exe' } } } catch {} } return null } export function findDesktopCompanion(): DesktopCompanionInstall | null { const dev = findDevElectron() if (dev) return dev if (isInRepoCheckout()) { const engineDir = getSootsimRoot()! const engineMain = join(engineDir, 'dist-electron/main.cjs') if (!existsSync(engineMain)) { console.error( ` dist-electron/main.cjs missing in ${engineDir}.\n` + ` run: bun run --cwd packages/sootsim-engine build:electron-main\n` + ` (or keep it fresh with: bun run watch:sootsim:electron-main)`, ) } else { console.error( ` no node_modules/.bin/electron found near ${engineDir}. run \`bun install\`.`, ) } return null } if (process.platform === 'darwin') return findMacApp() if (process.platform === 'linux') return findLinuxInstall() if (process.platform === 'win32') return findWindowsInstall() return null } async function spawnDetached( command: string, args: string[], opts: { inheritStdio?: boolean } = {}, ) { // dev-electron streams main-process console.log + renderer crash dumps to // the terminal so the developer sees them. inherit stdout/stderr but // ignore stdin so the child doesn't fight the shell for keystrokes. // detached + unref means the CLI still returns the prompt immediately; // electron keeps writing to the inherited fds until the user quits it // or closes the terminal. const stdio: 'ignore' | ['ignore', 'inherit', 'inherit'] = opts.inheritStdio ? ['ignore', 'inherit', 'inherit'] : 'ignore' await new Promise((resolve, reject) => { const child = spawn(command, args, { detached: true, stdio }) child.once('error', reject) child.once('spawn', () => { child.unref() resolve() }) }) } export interface LaunchCompanionOptions { // seeds the initial device model for the first-created window. wins // over any persisted window state; read by main.ts readCliDeviceOverride. device?: string profileId?: string ephemeralProfile?: boolean // set by `rnx open`, whose native window belongs to the launching // session. `rnx desktop` omits it and remains a persistent desktop app. ownerPid?: number } export async function launchDesktopCompanion( url?: string, install = findDesktopCompanion(), opts: LaunchCompanionOptions = {}, ): Promise { if (!install) return { launched: false } // electron argv extra: main.ts reads --device from process.argv // and uses it as the seed for the first createWindow. // // ⚠ chromium reorders argv before electron's main handler (and before any // `second-instance` event) sees it: `--flag value` becomes the bare switch // `--flag` followed by `value` as a stray positional. it also injects its // own switches (e.g. `--allow-file-access-from-files`) between the no-value // flags, so the original pairing is lost entirely. anything that takes a // value MUST use the `--flag=value` single-arg form so chromium preserves // it intact. ephemeral-profile has no value so it survives as-is. const extraArgs: string[] = [] if (opts.device) extraArgs.push(`--device=${opts.device}`) if (opts.profileId) extraArgs.push(`--profile=${opts.profileId}`) if (opts.ephemeralProfile) extraArgs.push('--ephemeral-profile') if (opts.ownerPid && opts.ownerPid > 1) extraArgs.push(`--owner-pid=${opts.ownerPid}`) if (install.kind === 'mac-app') { // `open -a` splits app args before and after `--args`. anything after // --args is forwarded to electron's process.argv. const openArgs: string[] = ['-g', '-a', install.path] if (url) openArgs.push(buildMacLaunchUrl(url, opts)) if (extraArgs.length > 0) openArgs.push('--args', ...extraArgs) await spawnDetached('open', openArgs) return { launched: true, via: 'mac-app', target: install.path } } if (install.kind === 'dev-electron') { // electron loads the dir's package.json "main" (dist-electron/main.cjs). // main.ts checks app.isPackaged and loads http://localhost:5173 in dev. // local dev shells commonly run under sandbox-exec/safehouse. chromium's // own sandbox cannot initialize inside that outer sandbox, so pass the // standard electron switch for the unpacked dev app only. const args: string[] = ['--no-sandbox', install.engineDir || getSootsimRoot()!] if (url) args.push(url) if (extraArgs.length > 0) args.push(...extraArgs) await spawnDetached(install.path, args, { inheritStdio: true }) return { launched: true, via: 'dev-electron', target: install.path } } const binaryArgs: string[] = [] if (url) binaryArgs.push(url) if (extraArgs.length > 0) binaryArgs.push(...extraArgs) await spawnDetached(install.path, binaryArgs) return { launched: true, via: install.kind, target: install.path, } } function buildMacLaunchUrl(url: string, opts: LaunchCompanionOptions): string { if (!opts.profileId && !opts.ephemeralProfile && !opts.ownerPid) return url const deepLink = new URL('rnx://dev') deepLink.searchParams.set('url', url) if (opts.profileId) deepLink.searchParams.set('profile', opts.profileId) if (opts.ephemeralProfile) deepLink.searchParams.set('ephemeral', '1') if (opts.ownerPid && opts.ownerPid > 1) { deepLink.searchParams.set('ownerPid', String(opts.ownerPid)) } return deepLink.toString() }