diff --git a/resources/darwin/bin/orca b/resources/darwin/bin/orca index fd415b25ccc..accc7298f67 100755 --- a/resources/darwin/bin/orca +++ b/resources/darwin/bin/orca @@ -20,7 +20,6 @@ fi CONTENTS="$APP_PATH/Contents" ELECTRON="$CONTENTS/MacOS/Orca" -# The unpacked entrypoint runs directly in Electron's Node mode. CLI="$CONTENTS/Resources/app.asar.unpacked/out/cli/index.js" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" @@ -28,4 +27,49 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS unset NODE_REPL_EXTERNAL_MODULE +function plist_version() { + local file=$1 key=$2 line found=0 remaining=256 + REPLY= + [ -r "$file" ] || return 1 + while IFS= read -r line; do + [ "$remaining" -gt 0 ] || return 1 + remaining=$((remaining - 1)) + if [[ $line == *"$key"* ]]; then + found=1 + line=${line#*} + fi + if [ "$found" -eq 1 ]; then + if [[ $line =~ ^[[:space:]]*\([0-9]{1,3}\.[0-9]{1,3}(\.[0-9]{1,3})?)\[[:space:]]*$ ]]; then + REPLY=${BASH_REMATCH[1]} + return 0 + fi + [[ $line =~ ^[[:space:]]*$ ]] || return 1 + fi + done < "$file" + return 1 +} + +function supports_packaged_node() { + local minimum current index required actual + plist_version "$NODE_CONTENTS/Info.plist" LSMinimumSystemVersion || return 1 + IFS=. read -r -a minimum <<< "$REPLY" + plist_version /System/Library/CoreServices/SystemVersion.plist ProductVersion || return 1 + IFS=. read -r -a current <<< "$REPLY" + for index in 0 1 2; do + required=$((10#${minimum[$index]:-0})) + actual=$((10#${current[$index]:-0})) + [ "$actual" -gt "$required" ] && return 0 + [ "$actual" -lt "$required" ] && return 1 + done + return 0 +} + +NODE_CONTENTS="$CONTENTS/Helpers/Orca Agents.app/Contents" +NODE="$NODE_CONTENTS/MacOS/Orca Agents" +if [ -x "$NODE" ] && supports_packaged_node; then + export ORCA_CLI_ELECTRON_EXECUTABLE="$ELECTRON" + unset ELECTRON_RUN_AS_NODE + exec "$NODE" "$CLI" "$@" +fi + ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@" diff --git a/src/cli/index.ts b/src/cli/index.ts index 862d3b443d1..b7d5e5c6ae6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import './runtime/packaged-cli-interpreter' import { findCommandSpec, isCommandGroup, diff --git a/src/cli/plugins/authoring/plugin-test-node.test.ts b/src/cli/plugins/authoring/plugin-test-node.test.ts index 9e4f174c6d7..da64d711343 100644 --- a/src/cli/plugins/authoring/plugin-test-node.test.ts +++ b/src/cli/plugins/authoring/plugin-test-node.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { selectPluginTestNode } from './plugin-test-node' +const { runDefault, packaged } = vi.hoisted(() => { + const packaged: { path: string | null } = { path: null } + return { runDefault: vi.fn(), packaged } +}) +vi.mock('@orca/process-host', () => ({ runProcess: runDefault })) +vi.mock('../../runtime/packaged-cli-interpreter', () => ({ + getPackagedElectronExecutable: () => packaged.path +})) +afterEach(() => { + packaged.path = null + vi.clearAllMocks() +}) + const ORCA = { execPath: '/Applications/Orca.app/Contents/MacOS/Orca', version: 'v22.9.0' } function answering(stdout: string | Error, code = 0) { @@ -17,9 +30,19 @@ function answering(stdout: string | Error, code = 0) { } describe('selectPluginTestNode', () => { + it('uses root Electron for the fallback child while the packaged CLI runs on Node', async () => { + packaged.path = '/Applications/Orca.app/Contents/MacOS/Orca' + runDefault.mockRejectedValue(new Error('spawn node ENOENT')) + expect(await selectPluginTestNode()).toMatchObject({ + program: '/Applications/Orca.app/Contents/MacOS/Orca', + env: { ELECTRON_RUN_AS_NODE: '1' }, + source: 'orca' + }) + }) + it('uses a new enough system node by the absolute path it reports, probed outside the plugin', async () => { const probe = answering('v20.6.0 /usr/local/bin/node\n') - const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run }) + const node = await selectPluginTestNode({ ...ORCA, electron: true, run: probe.run }) expect(node).toEqual({ program: '/usr/local/bin/node', env: {}, @@ -32,7 +55,7 @@ describe('selectPluginTestNode', () => { it('keeps a Windows path with spaces whole', async () => { const probe = answering('v22.1.0 C:\\Program Files\\nodejs\\node.exe\r\n') - const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run }) + const node = await selectPluginTestNode({ ...ORCA, electron: true, run: probe.run }) expect(node.program).toBe('C:\\Program Files\\nodejs\\node.exe') }) @@ -42,7 +65,7 @@ describe('selectPluginTestNode', () => { ['failing', answering('', 1)], ['answering garbage', answering('nope')] ])('falls back to Orca as Node when the system node is %s', async (_reason, probe) => { - const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run }) + const node = await selectPluginTestNode({ ...ORCA, electron: true, run: probe.run }) expect(node).toEqual({ program: ORCA.execPath, env: { ELECTRON_RUN_AS_NODE: '1' }, @@ -53,7 +76,7 @@ describe('selectPluginTestNode', () => { it('needs no Electron switch when the CLI itself runs on plain Node', async () => { const probe = answering(new Error('spawn node ENOENT')) - const node = await selectPluginTestNode({ ...ORCA, electron: undefined, run: probe.run }) + const node = await selectPluginTestNode({ ...ORCA, electron: false, run: probe.run }) expect(node.env).toEqual({}) }) }) diff --git a/src/cli/plugins/authoring/plugin-test-node.ts b/src/cli/plugins/authoring/plugin-test-node.ts index 7f774fdca08..0632d9b9030 100644 --- a/src/cli/plugins/authoring/plugin-test-node.ts +++ b/src/cli/plugins/authoring/plugin-test-node.ts @@ -1,3 +1,4 @@ +import { getPackagedElectronExecutable } from '../../runtime/packaged-cli-interpreter' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -21,15 +22,18 @@ type Dependencies = { /** The runtime this CLI runs on: Electron as Node in a packaged app, or Node in development. */ execPath: string version: string - electron: string | undefined + electron: boolean } -const defaults = (): Dependencies => ({ - run: runProcess, - execPath: process.execPath, - version: process.version, - electron: process.versions.electron -}) +const defaults = (): Dependencies => { + const packagedElectron = getPackagedElectronExecutable() + return { + run: runProcess, + execPath: packagedElectron ?? process.execPath, + version: process.version, + electron: Boolean(packagedElectron || process.versions.electron) + } +} function supports(version: string): boolean { const [major = 0, minor = 0] = version.replace(/^v/, '').split('.').map(Number) diff --git a/src/cli/profiles/profile-state.ts b/src/cli/profiles/profile-state.ts index 4b1b8cb7a2d..eeab936abcc 100644 --- a/src/cli/profiles/profile-state.ts +++ b/src/cli/profiles/profile-state.ts @@ -1,3 +1,4 @@ +import { reenterPackagedElectron } from '../runtime/packaged-cli-interpreter' import { z } from 'zod' import type { CommandHandler } from '../dispatch/dispatch' import { printResult } from '../output/command-result-output' @@ -119,6 +120,7 @@ export const PROFILE_STATE_HANDLERS: Record = { rejectProfileStateRemoteSelection(flags) const selector = parseSelector(flags) const profileId = parseProfileId(flags) + reenterPackagedElectron() const userDataPath = getDefaultUserDataPath() let result: ProfileStateRollbackResult if (canLaunchProfileStateRecovery()) { diff --git a/src/cli/runtime/cli-lifecycle-handlers.ts b/src/cli/runtime/cli-lifecycle-handlers.ts index ff339c7dd90..f826dd189b0 100644 --- a/src/cli/runtime/cli-lifecycle-handlers.ts +++ b/src/cli/runtime/cli-lifecycle-handlers.ts @@ -1,3 +1,4 @@ +import { reenterPackagedElectron } from './packaged-cli-interpreter' import { spawn } from 'node:child_process' import { resolve } from 'node:path' import type { CommandHandler } from '../dispatch/dispatch' @@ -138,6 +139,7 @@ export const CORE_HANDLERS: Record = { } const port = getOptionalServePort(flags) const pairingAddressValue = flags.get('pairing-address') + reenterPackagedElectron() const exitCode = await serveOrcaApp({ json, port, diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 4c07a3fb815..c0f8f2f45cf 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,3 +1,4 @@ +import { reenterPackagedElectron } from './packaged-cli-interpreter' import { randomUUID } from 'node:crypto' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { runtimeHostConnectionState } from '../../shared/remote-runtime/host-status/runtime-host-connection-state' @@ -307,6 +308,7 @@ export class RuntimeClient { if (this.remote) { return this.getCliStatus() } + reenterPackagedElectron() return openLocalOrca( () => this.getCliStatus(), (options) => this.call<{ requested: boolean }>('desktop.openWindowInBackground', {}, options), diff --git a/src/cli/runtime/packaged-cli-interpreter.test.ts b/src/cli/runtime/packaged-cli-interpreter.test.ts new file mode 100644 index 00000000000..d9fe68f1b82 --- /dev/null +++ b/src/cli/runtime/packaged-cli-interpreter.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { accessSync } from 'node:fs' +import type * as Fs from 'node:fs' + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + accessSync: vi.fn() +})) + +const platform = process.platform +const argv = process.argv +afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + process.argv = argv + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.mocked(accessSync).mockReset() + vi.resetModules() +}) + +it('consumes the shim marker before replacing a lifecycle command with root Electron', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + process.argv = ['/helper/node', '/app/cli.js', 'open', '--json'] + vi.stubEnv('ORCA_CLI_ELECTRON_EXECUTABLE', '/Applications/Orca.app/Contents/MacOS/Orca') + vi.stubEnv('ORCA_NODE_OPTIONS', '--max-old-space-size=2048') + const replaced = new Error('Process replaced') + let invocation: + | { file: string; args: readonly string[] | undefined; env: NodeJS.ProcessEnv | undefined } + | undefined + vi.spyOn(process, 'execve').mockImplementation((file, args, env) => { + invocation = { file, args, env } + throw replaced + }) + vi.resetModules() + const { reenterPackagedElectron } = await import('./packaged-cli-interpreter') + expect(process.env.ORCA_CLI_ELECTRON_EXECUTABLE).toBeUndefined() + expect(reenterPackagedElectron).toThrow(replaced) + expect(invocation).toMatchObject({ + file: '/Applications/Orca.app/Contents/MacOS/Orca', + args: ['/Applications/Orca.app/Contents/MacOS/Orca', '/app/cli.js', 'open', '--json'], + env: { ELECTRON_RUN_AS_NODE: '1', ORCA_NODE_OPTIONS: '--max-old-space-size=2048' } + }) + expect(invocation?.env).not.toHaveProperty('ORCA_CLI_ELECTRON_EXECUTABLE') +}) + +it('reports an unavailable app before attempting native process replacement', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + process.argv = ['/helper/node', '/app/cli.js', 'open'] + vi.stubEnv('ORCA_CLI_ELECTRON_EXECUTABLE', '/missing/Orca') + vi.mocked(accessSync).mockImplementation(() => { + throw new Error('ENOENT') + }) + vi.spyOn(process, 'execve').mockImplementation(() => { + throw new Error('Native replacement attempted') + }) + vi.resetModules() + const { reenterPackagedElectron } = await import('./packaged-cli-interpreter') + expect(reenterPackagedElectron).toThrow('The installed Orca app is unavailable') +}) diff --git a/src/cli/runtime/packaged-cli-interpreter.ts b/src/cli/runtime/packaged-cli-interpreter.ts new file mode 100644 index 00000000000..44d211a04e1 --- /dev/null +++ b/src/cli/runtime/packaged-cli-interpreter.ts @@ -0,0 +1,32 @@ +import { isAbsolute } from 'node:path' +import { accessSync, constants } from 'node:fs' +import { RuntimeClientError } from './types' + +const electron = process.env.ORCA_CLI_ELECTRON_EXECUTABLE +delete process.env.ORCA_CLI_ELECTRON_EXECUTABLE +const packagedElectron = + process.platform === 'darwin' && electron && isAbsolute(electron) ? electron : null + +export function getPackagedElectronExecutable(): string | null { + return packagedElectron +} + +/** App-owned operations retain the root app's update and process identity. */ +export function reenterPackagedElectron(): void { + if (!packagedElectron) { + return + } + try { + accessSync(packagedElectron, constants.X_OK) + accessSync(process.argv[1], constants.R_OK) + } catch { + throw new RuntimeClientError( + 'app_not_found', + 'The installed Orca app is unavailable. Reinstall Orca and try again.' + ) + } + process.execve(packagedElectron, [packagedElectron, ...process.argv.slice(1)], { + ...process.env, + ELECTRON_RUN_AS_NODE: '1' + }) +} diff --git a/src/cli/server/serve-service.ts b/src/cli/server/serve-service.ts index d1052fe34a0..167b2aec8b1 100644 --- a/src/cli/server/serve-service.ts +++ b/src/cli/server/serve-service.ts @@ -1,3 +1,4 @@ +import { reenterPackagedElectron } from '../runtime/packaged-cli-interpreter' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname } from 'node:path' @@ -268,7 +269,7 @@ export const SERVE_SERVICE_HANDLERS: Record = { 'Use either --allow-public or --refuse-public, not both.' ) } - const result = await installServeService({ + const options = { command: optionalString(flags.get('command')), // Validate before launchd can loop on a bad value and trip the supervisor brake. port: getOptionalServePort(flags), @@ -277,7 +278,9 @@ export const SERVE_SERVICE_HANDLERS: Record = { allowPublic: flags.get('allow-public') === true, refusePublic: flags.get('refuse-public') === true, dryRun: flags.get('dry-run') === true - }) + } + reenterPackagedElectron() + const result = await installServeService(options) emit(result, json, formatServeServiceInstall) }, 'serve service status': async ({ json }) => { diff --git a/src/main/cli/packaged-assets/packaged-mac-cli-interpreter.test.ts b/src/main/cli/packaged-assets/packaged-mac-cli-interpreter.test.ts new file mode 100644 index 00000000000..379b442cbd0 --- /dev/null +++ b/src/main/cli/packaged-assets/packaged-mac-cli-interpreter.test.ts @@ -0,0 +1,78 @@ +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, expect, it } from 'vitest' + +const run = promisify(execFile) +let root: string | undefined +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } +}) + +it.skipIf(process.platform === 'win32').each([ + ['13.5', 'node', true], + ['13.4', 'electron', true], + ['27.2', 'node', true], + ['unknown', 'electron', true], + ['13.5', 'electron', false] +] as const)( + 'runs the packaged CLI on %s with %s (helper executable: %s)', + async (version, runtime, executable) => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-cli-'))) + const contents = join(root, 'Test.app', 'Contents') + const bin = join(contents, 'Resources', 'bin') + const helper = join(contents, 'Helpers', 'Orca Agents.app', 'Contents') + await Promise.all([ + mkdir(bin, { recursive: true }), + mkdir(join(contents, 'MacOS'), { recursive: true }), + mkdir(join(helper, 'MacOS'), { recursive: true }) + ]) + const systemVersion = join(root, 'SystemVersion.plist') + await writeFile( + systemVersion, + `\nProductVersion\n${version}\n\n` + ) + await writeFile( + join(helper, 'Info.plist'), + '\nLSMinimumSystemVersion\n13.5\n\n' + ) + const shim = await readFile(join(__dirname, '../../../../resources/darwin/bin/orca'), 'utf8') + const launcher = join(bin, 'orca') + await writeFile( + launcher, + shim.replace('/System/Library/CoreServices/SystemVersion.plist', systemVersion) + ) + for (const [path, name] of [ + [join(contents, 'MacOS', 'Orca'), 'electron'], + [join(helper, 'MacOS', 'Orca Agents'), 'node'] + ]) { + await writeFile( + path, + `#!/bin/sh\nprintf '%s\\n' '${name}' "$@" "NODE_OPTIONS:\${NODE_OPTIONS-unset}" "ORCA_NODE_OPTIONS:\${ORCA_NODE_OPTIONS-unset}"\n` + ) + await chmod(path, name === 'node' && !executable ? 0o644 : 0o755) + } + const result = await run('/bin/bash', [launcher, 'status', '--json'], { + env: { + PATH: '/usr/bin:/bin', + HOME: root, + NODE_OPTIONS: '--max-old-space-size=2048', + ORCA_BACKGROUND_LAUNCH: '1' + } + }) + expect(result.stdout.split('\n')).toEqual([ + runtime, + join(contents, 'Resources', 'app.asar.unpacked', 'out', 'cli', 'index.js'), + 'status', + '--json', + 'NODE_OPTIONS:unset', + 'ORCA_NODE_OPTIONS:--max-old-space-size=2048', + '' + ]) + } +)