orca/src/cli/runtime/launch.ts

170 lines
5.2 KiB
TypeScript

import { spawn as spawnProcess, type SpawnOptions } from 'child_process'
import { dirname, resolve } from 'path'
import { RuntimeClientError } from './types'
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
spawnDetached(overrideCommand, [], { shell: true })
return
}
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
spawnDetached(overrideExecutable, getExecutableAppArgs(), {
...getExecutableSpawnOptions(overrideExecutable),
env: stripElectronRunAsNode(process.env)
})
return
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
if (process.platform === 'darwin') {
const appBundlePath = getMacAppBundlePath(process.execPath)
if (appBundlePath) {
// Why: launching the inner MacOS binary directly can trigger macOS app
// launch failures and bypass normal bundle lifecycle. The public
// packaged CLI should re-open the .app the same way Finder does.
spawnDetached('open', [appBundlePath], {
env: stripElectronRunAsNode(process.env)
})
return
}
}
spawnDetached(process.execPath, [], {
env: stripElectronRunAsNode(process.env)
})
return
}
throw new RuntimeClientError(
'runtime_open_failed',
'Could not determine how to launch Orca. Start Orca manually and try again.'
)
}
function spawnDetached(command: string, args: string[], options: SpawnOptions): void {
const child = spawnProcess(command, args, {
detached: true,
stdio: 'ignore',
...options
})
// Why: detached launch errors are reported asynchronously after this function
// returns; openOrca already reports the user-facing timeout if startup fails.
child.once('error', () => {})
child.unref()
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
} = {}
): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
const childArgs = [...getExecutableAppArgs(), '--serve']
if (args.json) {
childArgs.push('--serve-json')
}
if (args.port) {
childArgs.push('--serve-port', args.port)
}
if (args.pairingAddress) {
childArgs.push('--serve-pairing-address', args.pairingAddress)
}
if (args.noPairing) {
childArgs.push('--serve-no-pairing')
}
if (args.mobilePairing) {
childArgs.push('--serve-mobile-pairing')
}
const child = spawnProcess(executable, childArgs, {
cwd: resolveAppRoot(),
stdio: 'inherit',
...getExecutableSpawnOptions(executable),
env: stripElectronRunAsNode(process.env)
})
return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const forwardSignal = (signal: NodeJS.Signals): void => {
child.kill(signal)
forceKillTimer ??= setTimeout(() => {
child.kill('SIGKILL')
}, 5000)
}
const cleanup = (): void => {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)
child.once('error', (error) => {
cleanup()
reject(error)
})
child.once('exit', (code, signal) => {
cleanup()
if (typeof code === 'number') {
resolve(code)
return
}
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
})
})
}
function getExecutableAppArgs(): string[] {
return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
}
function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shell'> {
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
return process.execPath
}
throw new RuntimeClientError(
'runtime_serve_failed',
'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
)
}
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}
function getMacAppBundlePath(execPath: string): string | null {
if (process.platform !== 'darwin') {
return null
}
const macOsDir = dirname(execPath)
const contentsDir = dirname(macOsDir)
const appBundlePath = dirname(contentsDir)
return appBundlePath.endsWith('.app') ? appBundlePath : null
}