Files
orca/src/cli/runtime/launch.ts
T

337 lines
12 KiB
TypeScript

import { spawn, type SpawnOptions } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { runProcessSync, spawnProcess } from '@orca/process-host'
import type { ProcessSpec } from '@orca/process-host/process-spec'
import {
SERVE_UPDATE_HANDOFF_PATH_ENV,
getServeUpdateHandoffPath
} from '../../shared/serve-update-handoff'
import {
pinLaunchUserDataPath,
pinServeUserDataPath,
resolveLaunchUserDataPath
} from './launch-user-data-path'
import { foregroundServeIsolatedProfileSwitches } from '../../shared/electron-isolated-profile-switches'
import { getMacAppBundlePath } from './mac-app-update-bundle'
import { getPlatformUserDataPath } from './metadata'
import { isSameUserDataPath } from '../../shared/serve-user-data-path'
import {
readServeUpdateHandoffSync,
resumeInterruptedServeUpdate,
superviseForegroundServe
} from './serve-update-supervisor'
import { RuntimeClientError } from './types'
import { SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import {
resolveLocalServeRuntime,
serveOptionArgs,
serveWithOrcad,
type ServeOrcaAppArgs
} from './serve-orcad-launch'
import { waitForRecipeJson } from './serve-recipe-json'
import {
preflightElectronServe,
recordElectronServeStartupAbort,
refuseIfProfileServed
} from './serve-electron-preflight'
import { MacServeStartupAbortError } from './serve-signal-exit-diagnostic'
import { superviseServeService } from './serve-supervised-service'
import { isSupervisedSpawnHeld } from './serve-supervised-stop'
import { openServeBundlePark } from './serve-bundle-park'
import {
redactingSpawn,
shouldRedactSupervisedStdout,
type ServeSpawnChild
} from './serve-pairing-redaction'
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000
const STARTUP_ABORT_MS = 30_000
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
spawnDetached(overrideCommand, [], { shell: true })
return
}
// Why: `openOrca` waits on this profile's metadata, so the app must start on it too.
const userDataPath = resolveLaunchUserDataPath()
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
const pinned = pinLaunchUserDataPath(
getExecutableAppArgs(overrideExecutable),
stripElectronRunAsNode(process.env),
userDataPath
)
spawnDetached(overrideExecutable, pinned.args, {
...getExecutableSpawnOptions(overrideExecutable),
env: pinned.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', getMacOpenArgs(appBundlePath, userDataPath), {
env: stripElectronRunAsNode(process.env)
})
return
}
}
const pinned = pinLaunchUserDataPath(
getExecutableAppArgs(process.execPath),
stripElectronRunAsNode(process.env),
userDataPath
)
spawnDetached(process.execPath, pinned.args, { env: pinned.env })
return
}
throw new RuntimeClientError(
'runtime_open_failed',
'Could not determine how to launch Orca. Start Orca manually and try again.'
)
}
/**
* Plain `open` activates whichever instance already runs, which is the default profile's.
* Why `-n` only off the default: `open` drops our env, and `--args` reach only a new instance.
*/
export function getMacOpenArgs(appBundlePath: string, userDataPath: string): string[] {
if (isSameUserDataPath(userDataPath, getPlatformUserDataPath())) {
return [appBundlePath]
}
return ['-n', appBundlePath, '--args', `--user-data-dir=${userDataPath}`]
}
function spawnDetached(command: string, args: string[], options: SpawnOptions): void {
const child = spawn(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: ServeOrcaAppArgs = {}): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
// Why one value: the selector, orcad and Electron must all serve the profile the caller chose.
const userDataPath = resolveLaunchUserDataPath()
if (args.supervise) {
let attempt = 0
return superviseServeService({
userDataPath,
executable,
appRoot: resolveAppRoot(),
force: args.force === true,
park: openServeBundlePark({ executable }),
// Why first only: --force overrides a remembered abort once; the brake owns later attempts.
runOnce: () =>
serveOnce(
executable,
{ ...args, force: args.force === true && attempt++ === 0 },
userDataPath
)
})
}
return serveOnce(executable, args, userDataPath)
}
function serveOnce(
executable: string,
args: ServeOrcaAppArgs,
userDataPath: string
): Promise<number> {
// Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking.
if (process.env[SERVE_RUNTIME_ENV] === SERVE_RUNTIME_ELECTRON) {
return serveWithElectron(executable, args, userDataPath)
}
return serveWithSelectedRuntime(executable, args, userDataPath)
}
async function serveWithSelectedRuntime(
executable: string,
args: ServeOrcaAppArgs,
userDataPath: string
): Promise<number> {
const selection = await resolveLocalServeRuntime({
executable,
appRoot: resolveAppRoot(),
userDataPath,
usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null
})
if (selection.kind === 'orcad') {
// Why only supervised: orcad refuses a held profile as a configuration fault (exit 78), which
// the brake would count; exit 3 here lets the supervisor wait for the other Orca instead.
const refusal = args.supervise ? await refuseIfProfileServed(args, userDataPath) : null
if (refusal !== null) {
return refusal
}
process.stderr.write(`[serve] running on orcad ${selection.version}\n`)
return serveWithOrcad(selection, args, userDataPath, stripElectronRunAsNode(process.env))
}
if (selection.reason) {
process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`)
}
return serveWithElectron(executable, args, userDataPath)
}
function serveWithElectron(
executable: string,
args: ServeOrcaAppArgs,
userDataPath: string
): Promise<number> {
const preflight = preflightElectronServe(args, userDataPath)
if (preflight === null) {
return spawnElectronServe(executable, args, userDataPath)
}
return Promise.resolve(preflight).then(
(refusal) => refusal ?? spawnElectronServe(executable, args, userDataPath)
)
}
function spawnElectronServe(
executable: string,
args: ServeOrcaAppArgs,
userDataPath: string
): Promise<number> {
const pinned = pinServeUserDataPath(
getExecutableAppArgs(executable),
stripElectronRunAsNode(process.env),
userDataPath
)
const childArgs = pinned.args
childArgs.push('--serve', ...serveOptionArgs(args, '--serve-'))
// Why: the isolated profile has no login keychain, and the probe that discovers this blocks the
// foreground process on the Keychain authorization prompt before it advertises readiness (#25453).
childArgs.push(...foregroundServeIsolatedProfileSwitches())
const handoffPath =
args.recipeJson !== true && getMacAppBundlePath(executable)
? getServeUpdateHandoffPath(userDataPath)
: null
const childEnv = pinned.env
if (handoffPath) {
childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath
}
// Why process-host: it encodes a Windows `.cmd`/`.bat` executable's arguments instead of a raw shell.
const spec: ProcessSpec = {
program: executable,
args: childArgs,
detached: args.recipeJson === true,
cwd: resolveAppRoot(),
stdio:
args.recipeJson === true
? ['ignore', 'pipe', 'inherit']
: handoffPath
? ['inherit', 'inherit', 'inherit', 'ipc']
: 'inherit',
env: childEnv
}
const spawnServeChild: ServeSpawnChild = shouldRedactSupervisedStdout(args.supervise === true)
? redactingSpawn(spawnProcess)
: spawnProcess
const interruptedHandoff = handoffPath ? readServeUpdateHandoffSync(handoffPath) : null
if (interruptedHandoff?.phase === 'install-requested') {
// Why: the node-mode CLI is not an NSRunningApplication, so it can retain launchd ownership while ShipIt swaps the app.
return resumeInterruptedServeUpdate({
spec,
spawnChild: spawnServeChild,
handoffPath: handoffPath!,
handoff: interruptedHandoff
})
}
if (isSupervisedSpawnHeld()) {
return Promise.resolve(0)
}
const child = spawnServeChild(spec)
const spawnedAt = Date.now()
if (args.recipeJson) {
return waitForRecipeJson(child)
}
return superviseForegroundServe({
spec,
spawnChild: spawnServeChild,
child,
handoffPath,
expectedHandoff: null
}).catch((error: unknown) => {
// Why only a prompt abort: the registration abort kills the child before any JS runs.
if (error instanceof MacServeStartupAbortError && Date.now() - spawnedAt < STARTUP_ABORT_MS) {
recordElectronServeStartupAbort(userDataPath, error.message)
}
throw error
})
}
export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
args.push('--no-sandbox')
}
return args
}
function shouldDisableExtractedAppImageSandbox(executable: string): boolean {
if (process.platform !== 'linux' || !existsSync(join(dirname(executable), 'AppRun'))) {
return false
}
// An extracted AppImage has no root-owned setuid sandbox; mirror AppRun's userns fallback.
if (process.getuid?.() === 0) {
return true
}
try {
return (
runProcessSync({
program: 'unshare',
args: ['-Ur', 'true'],
stdio: 'ignore',
timeoutMs: USER_NAMESPACE_PROBE_TIMEOUT_MS
}).code !== 0
)
} catch {
return true
}
}
function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shell'> {
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
export 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, '../../..')
}
export 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.'
)
}
export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}