From ff4b313f04f2e4980aa1712113daebec3f95f189 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 03:28:13 -0700 Subject: [PATCH] feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11) (#24608) * feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11) * fix(serve): keep orcad selection app-side and wait out Windows temp cleanup * refactor(orcad): move the data-root privacy check out of the instance lock --------- Co-authored-by: m4air --- .../build-plugins/plain-node-entry-guard.ts | 1 + config/scripts/node-server-test-paths.mjs | 1 + electron.vite.config.ts | 5 + src/cli/runtime/launch.ts | 153 ++++------- src/cli/runtime/serve-orcad-launch.test.ts | 94 +++++++ src/cli/runtime/serve-orcad-launch.ts | 114 ++++++++ src/cli/runtime/serve-recipe-json.ts | 101 +++++++ src/main/orcad/main.ts | 15 +- src/main/orcad/orcad-command-arguments.ts | 14 + .../orcad-daemon-session-client-fixture.ts | 50 ++++ src/main/orcad/orcad-data-root-privacy.ts | 99 +++++++ src/main/orcad/orcad-entry.ts | 24 +- src/main/orcad/orcad-instance-lock.test.ts | 43 +++ src/main/orcad/orcad-instance-lock.ts | 132 ++------- src/main/orcad/orcad-launch-contract.test.ts | 19 ++ .../orcad-local-serve-selection-entry.ts | 44 +++ .../orcad/orcad-local-serve-selection.test.ts | 115 ++++++++ src/main/orcad/orcad-local-serve-selection.ts | 150 +++++++++++ .../orcad-serve-parity.integration.test.ts | 250 ++++++++++++++++++ src/main/server/serve-pairing-output.ts | 29 ++ ...rowser-process-user-agent-ordering.test.ts | 3 + .../desktop-profile-instance-lock.test.ts | 40 +++ .../startup/desktop-profile-instance-lock.ts | 41 +++ src/main/startup/main-process-preflight.ts | 9 + src/main/startup/main-process-serve.ts | 29 +- src/shared/orcad-local-serve-selection.ts | 58 ++++ src/shared/orcad-native-preflight-report.ts | 47 ++++ 27 files changed, 1434 insertions(+), 246 deletions(-) create mode 100644 src/cli/runtime/serve-orcad-launch.test.ts create mode 100644 src/cli/runtime/serve-orcad-launch.ts create mode 100644 src/cli/runtime/serve-recipe-json.ts create mode 100644 src/main/orcad/orcad-daemon-session-client-fixture.ts create mode 100644 src/main/orcad/orcad-data-root-privacy.ts create mode 100644 src/main/orcad/orcad-local-serve-selection-entry.ts create mode 100644 src/main/orcad/orcad-local-serve-selection.test.ts create mode 100644 src/main/orcad/orcad-local-serve-selection.ts create mode 100644 src/main/orcad/orcad-serve-parity.integration.test.ts create mode 100644 src/main/server/serve-pairing-output.ts create mode 100644 src/main/startup/desktop-profile-instance-lock.test.ts create mode 100644 src/main/startup/desktop-profile-instance-lock.ts create mode 100644 src/shared/orcad-local-serve-selection.ts create mode 100644 src/shared/orcad-native-preflight-report.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index e0765a4873b..e7cb9cee573 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -42,6 +42,7 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', + 'orcad/orcad-local-serve-selection-entry', ...CLI_MAIN_ENTRY_NAMES ] as const diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index f874071c660..45e3b973241 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -32,6 +32,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'src/main/orcad/orcad-node-launcher.integration.test.ts', 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', + 'src/main/orcad/orcad-serve-parity.integration.test.ts', 'config/scripts/zip-extractor-command.test.mjs' ] : []), diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 221ac77b69f..ca52ff93915 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -9,6 +9,7 @@ import { CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard' +import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from './src/shared/orcad-local-serve-selection' import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ @@ -265,6 +266,10 @@ export const electronViteConfig: UserConfig = { // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), + // Why: `orca serve` runs it under ELECTRON_RUN_AS_NODE so the CLI never bundles orcad prep. + [ORCAD_LOCAL_SERVE_SELECTION_ENTRY]: resolve( + 'src/main/orcad/orcad-local-serve-selection-entry.ts' + ), // Why: a worker thread survives the macOS 26 AppKit main-thread deadlock // without paying for another Electron process. 'main-thread-hang-watchdog-entry': resolve( diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index ebf10c2aaa9..0df2014ecb7 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,16 +1,11 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import { StringDecoder } from 'node:string_decoder' import { runProcessSync } from '../../shared/child-process/run-process' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath } from '../../shared/serve-update-handoff' -import { - getEphemeralVmRecipeResultConnection, - parseEphemeralVmRecipeResult -} from '../../shared/ephemeral-vm-recipes' import { getDefaultUserDataPath } from './metadata' import { getMacAppBundlePath } from './mac-app-update-bundle' import { @@ -19,8 +14,10 @@ import { superviseForegroundServe } from './serve-update-supervisor' import { RuntimeClientError } from './types' +import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' +import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch' +import { waitForRecipeJson } from './serve-recipe-json' -const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000 export function launchOrcaApp(): void { @@ -77,18 +74,49 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions): child.unref() } -export function serveOrcaApp( - args: { - json?: boolean - port?: string | null - pairingAddress?: string | null - noPairing?: boolean - mobilePairing?: boolean - recipeJson?: boolean - projectRoot?: string | null - } = {} -): Promise { +export type ServeOrcaAppArgs = { + json?: boolean + port?: string | null + pairingAddress?: string | null + noPairing?: boolean + mobilePairing?: boolean + recipeJson?: boolean + projectRoot?: string | null +} + +export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise { const executable = resolveForegroundOrcaExecutable() + // Why synchronous unless opted in: the default path must spawn Electron exactly as before. + if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') { + return serveWithElectron(executable, args) + } + if (args.recipeJson && !args.projectRoot) { + throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.') + } + return serveWithSelectedRuntime(executable, args) +} + +async function serveWithSelectedRuntime( + executable: string, + args: ServeOrcaAppArgs +): Promise { + const selection = await resolveLocalServeRuntime({ + executable, + appRoot: resolveAppRoot(), + userDataPath: getDefaultUserDataPath(), + usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null + }) + if (selection.kind === 'orcad') { + process.stderr.write(`[serve] running on orcad ${selection.version}\n`) + return serveWithOrcad(selection, args, getDefaultUserDataPath(), spawnProcess) + } + if (selection.reason) { + process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`) + } + return serveWithElectron(executable, args) +} + +function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise { const childArgs = [...getExecutableAppArgs(executable)] childArgs.push('--serve') if (args.json) { @@ -164,97 +192,6 @@ export function serveOrcaApp( }) } -function waitForRecipeJson(child: ReturnType): Promise { - return new Promise((resolve, reject) => { - let output = '' - let settled = false - const timeout = setTimeout(() => { - finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) - child.kill('SIGTERM') - }, 60000) - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - child.stdout?.off('data', onData) - child.off('error', onError) - child.off('close', onClose) - if (error) { - reject(error) - return - } - child.stdout?.destroy?.() - child.unref() - resolve(0) - } - const writeIgnoredRecipeStdout = (): void => { - // Why: non-readiness child stdout is untrusted and cannot be safely - // redacted, including schema-valid results with arbitrary user data. - process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) - } - const processRecipeOutputLine = (line: string): void => { - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line - if (!normalizedLine.trim()) { - return - } - const parsed = parseEphemeralVmRecipeResult(normalizedLine) - if (!parsed.ok) { - writeIgnoredRecipeStdout() - return - } - if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { - writeIgnoredRecipeStdout() - return - } - process.stdout.write(`${normalizedLine.trim()}\n`) - finish() - } - const stdoutDecoder = new StringDecoder('utf8') - const onData = (chunk: Buffer | string): void => { - output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) - while (!settled) { - const newlineIndex = output.indexOf('\n') - if (newlineIndex === -1) { - return - } - const line = output.slice(0, newlineIndex) - output = output.slice(newlineIndex + 1) - processRecipeOutputLine(line) - } - } - const onError = (error: Error): void => { - finish(error) - } - const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if (settled) { - return - } - output += stdoutDecoder.end() - if (output.trim()) { - processRecipeOutputLine(output) - } - if (settled) { - return - } - finish( - new RuntimeClientError( - 'runtime_serve_failed', - typeof code === 'number' - ? `Orca serve exited before printing valid recipe JSON with code ${code}.` - : `Orca serve exited before printing valid recipe JSON via ${signal}.` - ) - ) - } - child.stdout?.on('data', onData) - child.once('error', onError) - // Why: `exit` can precede the final piped stdout data. `close` waits until - // stdio closes so a last recipe chunk is not mistaken for missing output. - child.once('close', onClose) - }) -} - export function getExecutableAppArgs(executable: string): string[] { const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] if (shouldDisableExtractedAppImageSandbox(executable)) { diff --git a/src/cli/runtime/serve-orcad-launch.test.ts b/src/cli/runtime/serve-orcad-launch.test.ts new file mode 100644 index 00000000000..dcb25b03cdc --- /dev/null +++ b/src/cli/runtime/serve-orcad-launch.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import { formatServeRuntimeSelection } from '../../shared/orcad-local-serve-selection' +import type { runProcess } from '../../shared/child-process/run-process' +import { orcadServeArgs, resolveLocalServeRuntime } from './serve-orcad-launch' + +type RunProcess = typeof runProcess + +function answering(stdout: string, code = 0): RunProcess { + return vi.fn(async () => ({ + code, + signal: null, + stdout, + stderr: '', + timedOut: false + })) +} + +const options = { + executable: '/Applications/Orca.app/Contents/MacOS/Orca', + appRoot: '/Applications/Orca.app/Contents/Resources/app.asar', + userDataPath: '/Users/u/Library/Application Support/orca', + usesMacUpdateHandoff: false +} + +describe('orca serve asking the app which host to run', () => { + it("runs the app's own selection entry as plain Node and reads its answer", async () => { + const selection = { + kind: 'orcad' as const, + runtime: '/rt/node', + entry: '/slot/orcad.js', + version: '1' + } + const run = answering(`noise\n${formatServeRuntimeSelection(selection)}\n`) + expect(await resolveLocalServeRuntime({ ...options, usesMacUpdateHandoff: true }, run)).toEqual( + selection + ) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + program: options.executable, + args: [ + `${options.appRoot}/out/main/orcad/orcad-local-serve-selection-entry.js`, + '--user-data', + options.userDataPath, + '--app-root', + options.appRoot, + '--mac-update-handoff' + ], + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }) + ) + }) + + it('serves on Electron, and says why, when the app gives no answer', async () => { + expect(await resolveLocalServeRuntime(options, answering('', 1))).toEqual({ + kind: 'electron', + reason: expect.stringContaining('did not answer') + }) + const failing = vi.fn(async () => { + throw new Error('spawn ENOENT') + }) + expect(await resolveLocalServeRuntime(options, failing)).toEqual({ + kind: 'electron', + reason: expect.stringContaining('spawn ENOENT') + }) + }) + + it('forwards every desktop serve flag, binding wide as Electron serve does', () => { + expect( + orcadServeArgs({ + json: true, + port: '6768', + pairingAddress: '10.0.0.5', + noPairing: true, + mobilePairing: true, + recipeJson: true, + projectRoot: '/work/app' + }) + ).toEqual([ + '--bind', + '0.0.0.0', + '--json', + '--port', + '6768', + '--pairing-address', + '10.0.0.5', + '--no-pairing', + '--mobile-pairing', + '--recipe-json', + '--project-root', + '/work/app' + ]) + expect(orcadServeArgs({})).toEqual(['--bind', '0.0.0.0']) + }) +}) diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts new file mode 100644 index 00000000000..9d3889724a4 --- /dev/null +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -0,0 +1,114 @@ +/** + * `orca serve` on this machine's orcad slot. Whether to (and the slot itself) is decided + * app-side by `src/main/orcad/orcad-local-serve-selection.ts`; the CLI only asks and runs. + */ +import { dirname, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + ORCAD_LOCAL_SERVE_SELECTION_ENTRY, + ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS, + parseServeRuntimeSelection, + type ServeRuntimeSelection +} from '../../shared/orcad-local-serve-selection' +import type { ServeOrcaAppArgs } from './launch' +import { waitForRecipeJson } from './serve-recipe-json' +import { superviseForegroundServe } from './serve-update-supervisor' + +type SupervisorArgs = Parameters[0] + +/** A first run may download and verify the pinned Node; bound it well past that. */ +const SELECTION_TIMEOUT_MS = 10 * 60_000 + +/** Asks the app's own entry, run on the app's executable as plain Node, which host to serve on. */ +export async function resolveLocalServeRuntime( + options: { + executable: string + appRoot: string + userDataPath: string + usesMacUpdateHandoff: boolean + }, + run: typeof runProcess = runProcess +): Promise { + const entry = join(options.appRoot, 'out', 'main', `${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`) + try { + const result = await run({ + program: options.executable, + args: [ + entry, + FLAGS.userData, + options.userDataPath, + FLAGS.appRoot, + options.appRoot, + ...(options.usesMacUpdateHandoff ? [FLAGS.macUpdateHandoff] : []) + ], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeoutMs: SELECTION_TIMEOUT_MS + }) + return ( + parseServeRuntimeSelection(result.stdout) ?? { + kind: 'electron', + reason: `the app did not answer which serve host to use (exit ${String(result.code)})` + } + ) + } catch (error) { + return { + kind: 'electron', + reason: `the app could not check orcad: ${error instanceof Error ? error.message : String(error)}` + } + } +} + +/** Electron serve binds every interface (`exposeNetworkByDefault`); orcad does it on request. */ +export function serveWithOrcad( + selection: Extract, + args: ServeOrcaAppArgs, + userDataPath: string, + spawnProcess: SupervisorArgs['spawnChild'] +): Promise { + const childArgs = [selection.entry, ...orcadServeArgs(args)] + const spawnOptions: SupervisorArgs['spawnOptions'] = { + detached: args.recipeJson === true, + cwd: dirname(selection.entry), + stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit', + env: { + ...withoutElectronRunAsNode(process.env), + // The desktop's profile: its instance lock makes the two refuse each other. + ORCA_USER_DATA: userDataPath, + ORCA_VERSION: selection.version + } + } + const child = spawnProcess(selection.runtime, childArgs, spawnOptions) + if (args.recipeJson) { + return waitForRecipeJson(child) + } + return superviseForegroundServe({ + executable: selection.runtime, + childArgs, + spawnOptions, + spawnChild: spawnProcess, + child, + handoffPath: null, + expectedHandoff: null + }) +} + +export function orcadServeArgs(args: ServeOrcaAppArgs): string[] { + return [ + '--bind', + '0.0.0.0', + ...(args.json ? ['--json'] : []), + ...(args.port ? ['--port', args.port] : []), + ...(args.pairingAddress ? ['--pairing-address', args.pairingAddress] : []), + ...(args.noPairing ? ['--no-pairing'] : []), + ...(args.mobilePairing ? ['--mobile-pairing'] : []), + ...(args.recipeJson && args.projectRoot + ? ['--recipe-json', '--project-root', args.projectRoot] + : []) + ] +} + +function withoutElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env } + delete next.ELECTRON_RUN_AS_NODE + return next +} diff --git a/src/cli/runtime/serve-recipe-json.ts b/src/cli/runtime/serve-recipe-json.ts new file mode 100644 index 00000000000..70899c8f31f --- /dev/null +++ b/src/cli/runtime/serve-recipe-json.ts @@ -0,0 +1,101 @@ +import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import { StringDecoder } from 'node:string_decoder' +import { + getEphemeralVmRecipeResultConnection, + parseEphemeralVmRecipeResult +} from '../../shared/ephemeral-vm-recipes' +import { RuntimeClientError } from './types' + +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + +/** Relays the one recipe line a detached serve child prints, then lets the CLI exit. */ +export function waitForRecipeJson(child: ChildProcessHandle): Promise { + return new Promise((resolve, reject) => { + let output = '' + let settled = false + const timeout = setTimeout(() => { + finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) + child.kill('SIGTERM') + }, 60000) + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + child.stdout?.off('data', onData) + child.off('error', onError) + child.off('close', onClose) + if (error) { + reject(error) + return + } + child.stdout?.destroy?.() + child.unref() + resolve(0) + } + const writeIgnoredRecipeStdout = (): void => { + // Why: non-readiness child stdout is untrusted and cannot be safely + // redacted, including schema-valid results with arbitrary user data. + process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) + } + const processRecipeOutputLine = (line: string): void => { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine.trim()) { + return + } + const parsed = parseEphemeralVmRecipeResult(normalizedLine) + if (!parsed.ok) { + writeIgnoredRecipeStdout() + return + } + if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { + writeIgnoredRecipeStdout() + return + } + process.stdout.write(`${normalizedLine.trim()}\n`) + finish() + } + const stdoutDecoder = new StringDecoder('utf8') + const onData = (chunk: Buffer | string): void => { + output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) + while (!settled) { + const newlineIndex = output.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = output.slice(0, newlineIndex) + output = output.slice(newlineIndex + 1) + processRecipeOutputLine(line) + } + } + const onError = (error: Error): void => { + finish(error) + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if (settled) { + return + } + output += stdoutDecoder.end() + if (output.trim()) { + processRecipeOutputLine(output) + } + if (settled) { + return + } + finish( + new RuntimeClientError( + 'runtime_serve_failed', + typeof code === 'number' + ? `Orca serve exited before printing valid recipe JSON with code ${code}.` + : `Orca serve exited before printing valid recipe JSON via ${signal}.` + ) + ) + } + child.stdout?.on('data', onData) + child.once('error', onError) + // Why: `exit` can precede the final piped stdout data. `close` waits until + // stdio closes so a last recipe chunk is not mistaken for missing output. + child.once('close', onClose) + }) +} diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index b89b8f6acd9..325ada702d6 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,6 +1,7 @@ /** Executable entry for `orcad`. See `./orcad-entry.ts`. */ import process from 'node:process' import { main, resolveOrcadExitCode } from './orcad-entry' +import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' import { runOrcadNativePreflight } from './orcad-native-preflight' import { ORCAD_PROFILE_PREFLIGHT_FLAG, @@ -8,6 +9,10 @@ import { } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { + formatOrcadNativePreflightReport, + ORCAD_NATIVE_PREFLIGHT_FLAG +} from '../../shared/orcad-native-preflight-report' import { ORCAD_CANCEL_MANAGED_STOP_FLAG, ORCAD_COMPLETE_MANAGED_STOP_FLAG @@ -57,7 +62,13 @@ function startOrcadProcess(): void { try { if (!handoffToBundledOrcad()) { const flag = process.argv[2] - if ( + if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) { + void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => { + const verdict = checkNodePtyPrecondition() + const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null) + process.stdout.write(`${report}\n`, () => process.exit(0)) + }, failStartup) + } else if ( (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && process.argv.length === 4 ) { @@ -68,6 +79,8 @@ function startOrcadProcess(): void { .then(() => process.stdout.write('', () => process.exit(0))) .catch(failStartup) } else { + // Why: stdout is the serve readiness API; incidental diagnostics go to stderr. + reserveServeStdoutForReadiness() void preflightBundledOrcadStartup() .then(() => { runOrcadNativePreflight() diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index f4fd748de61..8077b2cb898 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -21,6 +21,17 @@ export function parseArgs(argv: string[]): OrcadOptions { options.json = true } else if (arg === '--no-pairing') { options.noPairing = true + } else if (arg === '--mobile-pairing') { + options.mobilePairing = true + } else if (arg === '--recipe-json') { + options.recipeJson = true + } else if (arg === '--project-root') { + const value = argv[i + 1] + if (!value) { + throw new Error('--project-root expects a value') + } + options.projectRoot = value + i += 1 } else if (arg === '--bind') { const value = argv[i + 1] if (value === undefined) { @@ -39,5 +50,8 @@ export function parseArgs(argv: string[]): OrcadOptions { throw new Error(`Unknown argument: ${arg}`) } } + if (options.recipeJson && !options.projectRoot) { + throw new Error('--recipe-json requires --project-root') + } return options } diff --git a/src/main/orcad/orcad-daemon-session-client-fixture.ts b/src/main/orcad/orcad-daemon-session-client-fixture.ts new file mode 100644 index 00000000000..a421ddd00aa --- /dev/null +++ b/src/main/orcad/orcad-daemon-session-client-fixture.ts @@ -0,0 +1,50 @@ +// Test fixture: a terminal client that talks to a profile's terminal daemon directly, the way a +// paired client's terminal does, so a test can prove a session outlives a serve-host switch. +import { build } from 'esbuild' + +export type DaemonSessionClientResult = { pid: number; isReattach: boolean; output: boolean } + +/** Bundles the client to `outfile`; run it with Node as ` `. */ +export async function buildDaemonSessionClient(outfile: string): Promise { + await build({ + stdin: { + contents: ` + import { DaemonPtyAdapter } from './src/main/daemon/daemon-pty-adapter' + import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './src/main/daemon/daemon-spawner' + import { setAppEnvironment } from './src/shared/app-environment' + const [op, runtimeDir, sessionId, marker, cwd] = process.argv.slice(2) + setAppEnvironment({ + getPath: () => cwd, getAppPath: () => cwd, getVersion: () => 'test', isPackaged: () => true, + onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] + }) + const adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(runtimeDir), tokenPath: getDaemonTokenPath(runtimeDir), + pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir + }) + let output = '' + adapter.onData(event => { if (event.id === sessionId) output += event.data }) + const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000) + ;(async () => { + const spawned = await adapter.spawn(op === 'create' + ? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' } + : { sessionId, cols: 80, rows: 24 }) + adapter.write(spawned.id, "printf 'ORCA_SERVE_%s\\\\n' " + marker + "\\r") + while (!output.includes('ORCA_SERVE_' + marker)) await new Promise(r => setTimeout(r, 50)) + clearTimeout(deadline) + await adapter.disconnectOnly() + console.log(JSON.stringify({ pid: spawned.pid, isReattach: spawned.isReattach === true, output: true })) + process.exit(0) + })().catch(error => { console.error(error); process.exit(1) }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external: ['electron', 'node-pty', '@parcel/watcher', '*.node'], + outfile, + logLevel: 'silent' + }) +} diff --git a/src/main/orcad/orcad-data-root-privacy.ts b/src/main/orcad/orcad-data-root-privacy.ts new file mode 100644 index 00000000000..beb0f8f7a1a --- /dev/null +++ b/src/main/orcad/orcad-data-root-privacy.ts @@ -0,0 +1,99 @@ +import { chmodSync, statSync } from 'node:fs' +import process from 'node:process' +import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl' + +export type OrcadInstanceLockCode = + | 'orcad_data_root_unusable' + | 'orcad_data_root_wrong_owner' + | 'orcad_data_root_shared' + | 'orcad_instance_lock_held' + | 'orcad_instance_lock_foreign_identity' + | 'orcad_instance_lock_unreadable' + +export class OrcadInstanceLockError extends Error { + constructor( + readonly code: OrcadInstanceLockCode, + message: string + ) { + super(message) + this.name = 'OrcadInstanceLockError' + } +} + +export type OrcadDataRootPrivacyHooks = { + platform?: NodeJS.Platform + /** Windows: restrict the data root's ACL to this user; false when it could not be applied. */ + restrictWindowsDataRoot?: (dataRoot: string) => boolean +} + +/** + * Fail closed on a data root other identities can read or write. + * + * Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS + * keyring on this host), so a group- or world-accessible root is a real exposure — but if + * we own the directory, tightening it is strictly better than refusing to start. We refuse + * only when the permissions are not ours to fix. + */ +export function assertOrcadDataRootIsPrivate( + dataRoot: string, + hooks: OrcadDataRootPrivacyHooks +): void { + // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a + // synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell). + if ((hooks.platform ?? process.platform) === 'win32') { + const restrict = + hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true)) + if (!restrict(dataRoot)) { + throw new OrcadInstanceLockError( + 'orcad_data_root_shared', + `Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` + + 'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' + + 'directory this account owns.' + ) + } + return + } + let stats + try { + stats = statSync(dataRoot) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + throw new OrcadInstanceLockError( + 'orcad_data_root_wrong_owner', + `The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` + + 'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.' + ) + } + if ((stats.mode & 0o077) === 0) { + return + } + try { + chmodSync(dataRoot, 0o700) + } catch { + // Fall through to the re-stat, which produces the actionable message. + } + let mode: number + try { + mode = statSync(dataRoot).mode + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + if ((mode & 0o077) !== 0) { + throw new OrcadInstanceLockError( + 'orcad_data_root_shared', + `The orcad data root ${dataRoot} is accessible to other users (mode ` + + `${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` + + 'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' + + 'at a private directory.' + ) + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b7..8b00a816b6f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -100,6 +100,11 @@ export type OrcadOptions = { json?: boolean noPairing?: boolean pairingAddress?: string + /** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */ + mobilePairing?: boolean + /** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */ + recipeJson?: boolean + projectRoot?: string /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ bind?: string } @@ -145,6 +150,8 @@ async function startOrcadRuntime( closeOrcadObservability = installOrcadObservability() const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') + const { assertServeProjectRoot, renderServePairingQr } = + await import('../server/serve-pairing-output') const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') @@ -344,8 +351,8 @@ async function startOrcadRuntime( } as const) : rpc.createPairingOffer({ address: options.pairingAddress, - name: `CLI ${new Date().toLocaleDateString()}`, - scope: 'runtime' + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' }) const readiness: ServeReadiness = { @@ -362,8 +369,8 @@ async function startOrcadRuntime( endpoint: offer.endpoint, deviceId: offer.deviceId, webClientUrl: offer.webClientUrl, - scope: 'runtime', - qr: null + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: options.mobilePairing ? await renderServePairingQr(offer.pairingUrl) : null } : offer, // Why in the readiness payload: this is the one message a supervisor and a deploy @@ -372,9 +379,12 @@ async function startOrcadRuntime( health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority) } - await new ServeReadinessPublisher().publish(readiness, { - mode: options.json ? 'json' : 'human' - }) + await new ServeReadinessPublisher().publish( + readiness, + options.recipeJson && options.projectRoot + ? { mode: 'recipe-json', projectRoot: assertServeProjectRoot(options.projectRoot) } + : { mode: options.json ? 'json' : 'human' } + ) return { readiness } } diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 664626ec29f..2c5071a6874 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -254,4 +254,47 @@ describe('acquireOrcadInstanceLock', () => { lock.release() } ) + + it('makes the desktop app and orcad refuse each other on one profile', () => { + const root = makeRoot() + const desktop = acquireOrcadInstanceLock(root, hooks({ role: 'desktop' })) + expect(JSON.parse(readFileSync(desktop.path, 'utf8')).role).toBe('desktop') + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + expect.objectContaining({ + code: 'orcad_instance_lock_held', + message: expect.stringContaining('The Orca desktop app') + }) + ) + desktop.release() + const orcad = acquireOrcadInstanceLock(root, hooks()) + expect(() => + acquireOrcadInstanceLock(root, hooks({ role: 'desktop', processIsAlive: () => true })) + ).toThrow( + expect.objectContaining({ + code: 'orcad_instance_lock_held', + message: expect.stringContaining('Another orcad') + }) + ) + orcad.release() + }) + + it.runIf(process.platform !== 'win32')( + "leaves the desktop profile's permissions as they were", + () => { + const root = makeRoot() + chmodSync(root, 0o755) + const restricted: string[] = [] + acquireOrcadInstanceLock(root, hooks({ role: 'desktop' })).release() + acquireOrcadInstanceLock( + root, + hooks({ + role: 'desktop', + platform: 'win32', + restrictWindowsDataRoot: (path) => restricted.push(path) > 0 + }) + ).release() + expect(restricted).toEqual([]) + expect(statSync(root).mode & 0o777).toBe(0o755) + } + ) }) diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index a4aee6c32b7..1c8cbb88387 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -13,15 +13,7 @@ * it says nothing about the daemon, which is what makes a non-destructive restart possible. */ import { randomUUID } from 'node:crypto' -import { - chmodSync, - linkSync, - mkdirSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { userInfo } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -30,29 +22,17 @@ import { orcadProcessStartTimeMatches, readOrcadProcessStartedAtMs } from './orcad-process-start-time' -import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl' +import { + assertOrcadDataRootIsPrivate, + OrcadInstanceLockError, + type OrcadDataRootPrivacyHooks +} from './orcad-data-root-privacy' import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' const MAX_ORCAD_LOCK_BYTES = 64 * 1024 -export type OrcadInstanceLockCode = - | 'orcad_data_root_unusable' - | 'orcad_data_root_wrong_owner' - | 'orcad_data_root_shared' - | 'orcad_instance_lock_held' - | 'orcad_instance_lock_foreign_identity' - | 'orcad_instance_lock_unreadable' - -export class OrcadInstanceLockError extends Error { - constructor( - readonly code: OrcadInstanceLockCode, - message: string - ) { - super(message) - this.name = 'OrcadInstanceLockError' - } -} +export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy' const OrcadLockRecordSchema = z.object({ pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), @@ -63,7 +43,9 @@ const OrcadLockRecordSchema = z.object({ version: z.string().min(1).max(255), acquiredAt: z.iso.datetime({ offset: true }), /** Distinguishes our record from a replacement written after we lost the race. */ - nonce: z.string().min(1).max(255) + nonce: z.string().min(1).max(255), + /** Absent in records orcad wrote before the desktop app shared this lock. */ + role: z.enum(['orcad', 'desktop']).optional() }) export type OrcadLockRecord = z.infer @@ -79,7 +61,7 @@ export type OrcadInstanceLock = { release(): void } -export type OrcadInstanceLockHooks = { +export type OrcadInstanceLockHooks = OrcadDataRootPrivacyHooks & { identity?: () => string version?: () => string now?: () => Date @@ -87,9 +69,8 @@ export type OrcadInstanceLockHooks = { processIsAlive?: (pid: number) => boolean startedAtMs?: (pid: number) => number | null startTimeMatches?: (pid: number, expected: number | null) => boolean - platform?: NodeJS.Platform - /** Windows: restrict the data root's ACL to this user; false when it could not be applied. */ - restrictWindowsDataRoot?: (dataRoot: string) => boolean + /** Who takes the profile. The desktop app takes it too, so the two refuse each other. */ + role?: 'orcad' | 'desktop' } function defaultIdentity(): string { @@ -123,75 +104,6 @@ export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | } } -/** - * Fail closed on a data root other identities can read or write. - * - * Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS - * keyring on this host), so a group- or world-accessible root is a real exposure — but if - * we own the directory, tightening it is strictly better than refusing to start. We refuse - * only when the permissions are not ours to fix. - */ -function assertDataRootIsPrivate(dataRoot: string, hooks: OrcadInstanceLockHooks): void { - // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a - // synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell). - if ((hooks.platform ?? process.platform) === 'win32') { - const restrict = - hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true)) - if (!restrict(dataRoot)) { - throw new OrcadInstanceLockError( - 'orcad_data_root_shared', - `Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` + - 'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' + - 'directory this account owns.' - ) - } - return - } - let stats - try { - stats = statSync(dataRoot) - } catch (error) { - throw new OrcadInstanceLockError( - 'orcad_data_root_unusable', - `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` - ) - } - const uid = process.getuid?.() - if (uid !== undefined && stats.uid !== uid) { - throw new OrcadInstanceLockError( - 'orcad_data_root_wrong_owner', - `The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` + - 'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.' - ) - } - if ((stats.mode & 0o077) === 0) { - return - } - try { - chmodSync(dataRoot, 0o700) - } catch { - // Fall through to the re-stat, which produces the actionable message. - } - let mode: number - try { - mode = statSync(dataRoot).mode - } catch (error) { - throw new OrcadInstanceLockError( - 'orcad_data_root_unusable', - `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` - ) - } - if ((mode & 0o077) !== 0) { - throw new OrcadInstanceLockError( - 'orcad_data_root_shared', - `The orcad data root ${dataRoot} is accessible to other users (mode ` + - `${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` + - 'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' + - 'at a private directory.' - ) - } -} - /** * Take the lock, or throw an `OrcadInstanceLockError` naming why. * @@ -215,7 +127,10 @@ export function acquireOrcadInstanceLock( `Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}` ) } - assertDataRootIsPrivate(dataRoot, hooks) + // The desktop's profile keeps the permissions it was created with; orcad tightens its own. + if ((hooks.role ?? 'orcad') === 'orcad') { + assertOrcadDataRootIsPrivate(dataRoot, hooks) + } const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME) const record: OrcadLockRecord = { @@ -224,7 +139,8 @@ export function acquireOrcadInstanceLock( identity, version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(), acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(), - nonce: randomUUID() + nonce: randomUUID(), + role: hooks.role ?? 'orcad' } const serialized = JSON.stringify(record) @@ -268,9 +184,9 @@ export function acquireOrcadInstanceLock( if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { throw new OrcadInstanceLockError( 'orcad_instance_lock_held', - `Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` + - `owns the data root ${dataRoot}. Stop it before starting another, or use a different ` + - 'ORCA_USER_DATA.' + `${describeLockHolder(existing)} (pid ${existing.pid}, started ` + + `${existing.acquiredAt || 'unknown'}) already owns the data root ${dataRoot}. Stop it ` + + 'before starting another, or use a different ORCA_USER_DATA.' ) } // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact @@ -315,6 +231,10 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } +function describeLockHolder(record: OrcadLockRecord): string { + return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad' +} + /** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */ function restoreDisplacedLock( claimPath: string, diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 26f2f28cbed..f8f7b2c85fa 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -26,6 +26,25 @@ describe('parseArgs', () => { }) }) + it('takes the desktop serve flags orca serve forwards', () => { + expect( + parseArgs([ + '--mobile-pairing', + '--recipe-json', + '--project-root', + '/work/app', + '--no-pairing' + ]) + ).toEqual({ + mobilePairing: true, + recipeJson: true, + projectRoot: '/work/app', + noPairing: true + }) + expect(() => parseArgs(['--recipe-json'])).toThrow('--recipe-json requires --project-root') + expect(() => parseArgs(['--project-root'])).toThrow('--project-root expects a value') + }) + it('rejects --bind with no value rather than silently binding the default', () => { expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value') expect(() => parseArgs(['--bind', '--json'])).not.toThrow() diff --git a/src/main/orcad/orcad-local-serve-selection-entry.ts b/src/main/orcad/orcad-local-serve-selection-entry.ts new file mode 100644 index 00000000000..6d90e7c65c3 --- /dev/null +++ b/src/main/orcad/orcad-local-serve-selection-entry.ts @@ -0,0 +1,44 @@ +/** + * `orca serve`'s app-side question, run by the CLI with the app's own executable under + * ELECTRON_RUN_AS_NODE: prepare this machine's orcad slot if it can serve, and print one + * `ORCA_SERVE_RUNTIME` line saying which host to run. Diagnostics go to stderr. + */ +import { join } from 'node:path' +import process from 'node:process' +import { + formatServeRuntimeSelection, + ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS +} from '../../shared/orcad-local-serve-selection' +import { selectServeRuntime } from './orcad-local-serve-selection' + +function flagValue(argv: readonly string[], flag: string): string | null { + const index = argv.indexOf(flag) + return index === -1 ? null : (argv[index + 1] ?? null) +} + +async function run(argv: readonly string[]): Promise { + const userDataPath = flagValue(argv, FLAGS.userData) + const appRoot = flagValue(argv, FLAGS.appRoot) + if (!userDataPath || !appRoot) { + throw new Error(`${FLAGS.userData} and ${FLAGS.appRoot} are required`) + } + const selection = await selectServeRuntime({ + env: process.env, + platform: process.platform, + userDataPath, + templateDirs: [ + ...(process.resourcesPath ? [join(process.resourcesPath, 'orcad-template')] : []), + join(appRoot, 'out', 'orcad-template') + ], + usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff) + }) + process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0)) +} + +run(process.argv.slice(2)).catch((error: unknown) => { + // Any failure here is a reason to serve on Electron, never to fail `orca serve`. + const reason = `orcad selection failed: ${error instanceof Error ? error.message : String(error)}` + process.stdout.write(`${formatServeRuntimeSelection({ kind: 'electron', reason })}\n`, () => + process.exit(0) + ) +}) diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts new file mode 100644 index 00000000000..4a90bc1455f --- /dev/null +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import { + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, + ORCAD_VERSION_FILENAME, + orcadNodeRuntimeRelativePath +} from '../../shared/orcad-artifacts' +import { formatOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report' +import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' +import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection' + +const TARGET = 'linux-x64-glibc' +const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 +let root = '' + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-serve-runtime-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** A materialized slot as the template would leave it: no runtime of its own yet. */ +function slotFixture(): string { + const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc') + mkdirSync(slot, { recursive: true }) + writeFileSync(join(slot, ORCAD_SERVER_TARGET_FILENAME), `${TARGET}\n`) + writeFileSync(join(slot, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${SHA}\n`) + writeFileSync(join(slot, ORCAD_VERSION_FILENAME), '0.1.0+abc\n') + writeFileSync(join(slot, 'orcad.js'), '') + return slot +} + +function input(overrides: Partial = {}): ServeRuntimeSelectionInput { + const template = join(root, 'orcad-template') + mkdirSync(template, { recursive: true }) + const cachedNode = join(root, 'cached-node') + writeFileSync(cachedNode, '#!/bin/sh\n') + return { + env: { [SERVE_RUNTIME_ENV]: 'orcad' }, + platform: 'linux', + userDataPath: root, + templateDirs: [join(root, 'missing-template'), template], + usesMacUpdateHandoff: false, + hostTarget: () => TARGET, + materializeSlot: vi.fn(async () => slotFixture()), + materializeRuntime: vi.fn(async () => cachedNode), + nativePreflight: async () => `${formatOrcadNativePreflightReport('ok', null)}\n`, + ...overrides + } +} + +describe('orca serve runtime selection', () => { + it('stays on Electron, silently, unless orcad is asked for', async () => { + expect(await selectServeRuntime(input({ env: {} }))).toEqual({ + kind: 'electron', + reason: null + }) + }) + + it('runs the local slot on its pinned Node, linked into userData beside it', async () => { + const options = input() + const selection = await selectServeRuntime(options) + const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc') + const runtime = join(slot, ...orcadNodeRuntimeRelativePath(TARGET, SHA)) + expect(selection).toEqual({ + kind: 'orcad', + runtime, + entry: join(slot, 'orcad.js'), + version: '0.1.0+abc' + }) + expect(existsSync(runtime)).toBe(true) + expect(runtime.startsWith(join(root, 'orcad-artifacts'))).toBe(true) + expect(options.materializeSlot).toHaveBeenCalledWith(TARGET, { + templateDir: join(root, 'orcad-template'), + cacheRoot: join(root, 'orcad-artifacts') + }) + }) + + it.each([ + ['Windows', { platform: 'win32' as const }, 'local orcad on Windows is not enabled'], + ['packaged macOS', { usesMacUpdateHandoff: true }, 'no macOS app-update handoff'], + ['an unsupported host', { hostTarget: () => 'linux-riscv64-glibc' }, 'no orcad build exists'], + ['an install without the template', { templateDirs: [] }, 'carries no orcad template'], + [ + 'an offline first run', + { + materializeRuntime: vi.fn(async (): Promise => { + throw new Error('fetch failed') + }) + }, + 'could not be prepared: fetch failed' + ], + [ + 'a host whose node-pty cannot load', + { + nativePreflight: async () => formatOrcadNativePreflightReport('blocked', 'load_crashed') + }, + 'cannot run terminals here (blocked: load_crashed)' + ], + [ + 'a silent preflight', + { nativePreflight: async () => '' }, + 'did not answer its native preflight' + ] + ])('falls back to Electron on %s and says why', async (_name, overrides, reason) => { + const selection = await selectServeRuntime(input(overrides)) + expect(selection).toEqual({ kind: 'electron', reason: expect.stringContaining(reason) }) + }) +}) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts new file mode 100644 index 00000000000..ec36a29c0a8 --- /dev/null +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -0,0 +1,150 @@ +/** + * Which host runs `orca serve`: orcad on this machine's packaged slot, or Electron `--serve`. + * App-side on purpose: the CLI runs it through `orcad-local-serve-selection-entry.ts`, so the + * CLI bundle never carries the materializers. + * + * Opt-in while orcad serve is new: `ORCA_SERVE_RUNTIME=orcad`. Anything that stops orcad from + * serving this machine (no slot for the target, no pinned Node, a native module it cannot load, + * a host or packaging path it does not cover yet) falls back to Electron with one stderr line + * saying why. Everything this writes stays inside the desktop's userData (design D7): the slot + * under `orcad-artifacts/`, assembled from the template inside the app bundle. + */ +import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' +import { + ORCAD_NATIVE_PREFLIGHT_FLAG, + parseOrcadNativePreflightReport +} from '../../shared/orcad-native-preflight-report' +import { + SERVE_RUNTIME_ENV, + type ServeRuntimeSelection +} from '../../shared/orcad-local-serve-selection' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { detectNativeHostAbi, nativeSlotName } from './native-host-abi' +import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' +import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer' + +const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000 + +export type ServeRuntimeSelectionInput = { + env: NodeJS.ProcessEnv + platform: NodeJS.Platform + userDataPath: string + templateDirs: readonly string[] + /** Packaged macOS serve keeps Electron: orcad has no app-update handoff yet. */ + usesMacUpdateHandoff: boolean + hostTarget?: () => string + materializeSlot?: typeof materializeOrcadArtifact + materializeRuntime?: typeof materializeCachedNodeRuntime + nativePreflight?: (runtime: string, entry: string) => Promise +} + +function isServerTarget(value: string): value is ServerTarget { + return Object.keys(NODE_RUNTIME_ASSETS).includes(value) +} + +function electron(reason: string): ServeRuntimeSelection { + return { kind: 'electron', reason } +} + +export async function selectServeRuntime( + input: ServeRuntimeSelectionInput +): Promise { + if (input.env[SERVE_RUNTIME_ENV] !== 'orcad') { + return { kind: 'electron', reason: null } + } + if (input.platform === 'win32') { + return electron('local orcad on Windows is not enabled in this build') + } + if (input.usesMacUpdateHandoff) { + return electron( + 'orcad has no macOS app-update handoff yet; packaged macOS serve stays on Electron' + ) + } + const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))() + if (!isServerTarget(target)) { + return electron(`no orcad build exists for this host (${target})`) + } + const templateDir = input.templateDirs.find((candidate) => existsSync(candidate)) + if (!templateDir) { + return electron('this Orca install carries no orcad template') + } + const cacheRoot = join(input.userDataPath, 'orcad-artifacts') + let slotDir: string + try { + slotDir = await (input.materializeSlot ?? materializeOrcadArtifact)(target, { + templateDir, + cacheRoot + }) + await placeSlotRuntime(slotDir, target, cacheRoot, input.materializeRuntime) + } catch (error) { + return electron(`the orcad slot for ${target} could not be prepared: ${errorText(error)}`) + } + let runtime: string | null + try { + runtime = resolveBundledOrcadRuntime(slotDir) + } catch (error) { + return electron(`the orcad slot is incomplete: ${errorText(error)}`) + } + if (!runtime) { + return electron('the orcad slot names no pinned runtime') + } + const entry = join(slotDir, 'orcad.js') + const report = parseOrcadNativePreflightReport( + await (input.nativePreflight ?? runNativePreflight)(runtime, entry).catch(() => '') + ) + if (!report) { + return electron('orcad did not answer its native preflight') + } + if (report.status === 'blocked' || report.status === 'degraded') { + return electron( + `orcad cannot run terminals here (${report.status}: ${report.reason ?? 'unknown'})` + ) + } + return { + kind: 'orcad', + runtime, + entry, + version: readFileSync(join(slotDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + } +} + +/** The slot references its runtime beside it; the cached pinned Node is linked into place. */ +async function placeSlotRuntime( + slotDir: string, + target: ServerTarget, + cacheRoot: string, + materializeRuntime: typeof materializeCachedNodeRuntime = materializeCachedNodeRuntime +): Promise { + const destination = join( + slotDir, + ...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256) + ) + if (existsSync(destination)) { + return + } + const cached = await materializeRuntime(target, cacheRoot, { fetcher: fetch }) + mkdirSync(dirname(destination), { recursive: true }) + try { + linkSync(cached, destination) + } catch { + copyFileSync(cached, destination) + chmodSync(destination, 0o755) + } +} + +async function runNativePreflight(runtime: string, entry: string): Promise { + const result = await runProcess({ + program: runtime, + args: [entry, ORCAD_NATIVE_PREFLIGHT_FLAG], + timeoutMs: NATIVE_PREFLIGHT_TIMEOUT_MS + }) + return result.stdout +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/orcad/orcad-serve-parity.integration.test.ts b/src/main/orcad/orcad-serve-parity.integration.test.ts new file mode 100644 index 00000000000..98e640fcb5b --- /dev/null +++ b/src/main/orcad/orcad-serve-parity.integration.test.ts @@ -0,0 +1,250 @@ +/** + * `orca serve` on orcad, against the packaged slot: the native preflight the launcher asks + * first, desktop-serve flag parity on stdout (the readiness contract), and the shared-profile + * refusal while the desktop app holds the profile. + */ +import { existsSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs' +import { tmpdir, userInfo } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { readDaemonPidRecord } from '../daemon/daemon-endpoint-incarnation' +import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' +import { + ORCAD_NATIVE_PREFLIGHT_FLAG, + parseOrcadNativePreflightReport +} from '../../shared/orcad-native-preflight-report' +import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { skipForMissingInputs } from './orcad-node-slot-fixture' +import { buildDaemonSessionClient } from './orcad-daemon-session-client-fixture' + +const slotDir = resolve('out/orcad') +const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + ? resolveBundledOrcadRuntime(slotDir) + : null +const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot in out/orcad']) +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + // The terminal daemon outlives orcad by design; on Windows its open files pin the profile. + stopProfileDaemons(root) + removeTreeSync(root) + } +}) + +function stopProfileDaemons(userData: string): void { + const daemonDir = join(userData, 'daemon') + for (const name of existsSync(daemonDir) ? readdirSync(daemonDir) : []) { + const pid = /^daemon-v\d+\.pid$/u.test(name) + ? readDaemonPidRecord(join(daemonDir, name))?.pid + : undefined + try { + if (pid) { + process.kill(pid, 'SIGKILL') + } + } catch {} + } +} + +/** Kills a serve child and waits until it has really exited, so nothing holds its files. */ +async function killAndWait(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = new Promise((settle) => child.once('exit', settle)) + child.kill('SIGKILL') + await exited +} + +/** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */ +function serveEnv(userData: string): NodeJS.ProcessEnv { + return { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('VITEST') && key !== 'NODE_ENV') + ), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA: userData + } +} + +function profile(): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-serve-parity-')) + roots.push(root) + return root +} + +describe.skipIf(skip)('orca serve on orcad', () => { + it('answers the launcher native preflight with one report line', async () => { + const result = await runProcess({ + program: runtime!, + args: [join(slotDir, 'orcad.js'), ORCAD_NATIVE_PREFLIGHT_FLAG], + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(parseOrcadNativePreflightReport(result.stdout)?.status).toMatch(/^(ok|unverifiable)$/u) + }) + + it('prints only the recipe line, exactly as Electron serve does', async () => { + const projectRoot = profile() + const child = spawnProcess({ + program: runtime!, + args: [ + join(slotDir, 'orcad.js'), + '--bind', + '127.0.0.1', + '--port', + '0', + '--recipe-json', + '--project-root', + projectRoot + ], + env: serveEnv(profile()), + timeoutMs: null + }) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + try { + await new Promise((resolveLine, reject) => { + const timer = setTimeout(() => reject(new Error(`no recipe line: ${stderr}`)), 120_000) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + if (stdout.includes('\n')) { + clearTimeout(timer) + resolveLine() + } + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`orcad exited ${String(code)}: ${stderr}`)) + }) + }) + // Anything after the recipe line would break a reader of this contract. + await new Promise((settle) => setTimeout(settle, 1_000)) + } finally { + await killAndWait(child) + } + const lines = stdout.split('\n').filter(Boolean) + expect(lines, stderr).toHaveLength(1) + expect(JSON.parse(lines[0]!)).toEqual({ + schemaVersion: 1, + pairingCode: expect.any(String), + projectRoot + }) + }, 150_000) + + it('refuses with exit 78 while the desktop app holds the profile', async () => { + const userData = profile() + writeFileSync( + join(userData, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ + pid: process.pid, + startedAtMs: null, + identity: process.platform === 'win32' ? userInfo().username : String(process.getuid?.()), + version: 'desktop-test', + acquiredAt: new Date().toISOString(), + nonce: 'desktop', + role: 'desktop' + }) + ) + const result = await runProcess({ + program: runtime!, + args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + env: serveEnv(userData), + timeoutMs: 60_000 + }) + expect(result.code).toBe(78) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('The Orca desktop app') + }, 90_000) + + // POSIX: the client spawns /bin/sh, and a short /tmp root keeps the daemon socket path legal. + it.runIf(process.platform !== 'win32')( + 'keeps a live terminal across a serve restart on the shared profile (D7)', + async () => { + const userData = realpathSync(mkdtempSync('/tmp/orca-serve-d7-')) + roots.push(userData) + const client = join(userData, 'daemon-client.cjs') + await buildDaemonSessionClient(client) + const session = async (op: 'create' | 'attach', marker: string) => { + const result = await runProcess({ + program: runtime!, + args: [client, op, join(userData, 'daemon'), 'serve-d7', marker, userData], + env: serveEnv(userData), + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') + } + const first = await serveOnce(userData) + let daemonPid: number | null = first + try { + const created = await session('create', 'BEFORE') + expect(created).toMatchObject({ isReattach: false, output: true }) + await stopServe(userData) + // The same daemon, in the same \`/daemon\` Electron serve uses, is adopted. + expect(await serveOnce(userData)).toBe(first) + expect(await session('attach', 'AFTER')).toEqual({ + pid: created.pid, + isReattach: true, + output: true + }) + } finally { + await stopServe(userData) + if (daemonPid) { + try { + process.kill(daemonPid, 'SIGKILL') + } catch {} + daemonPid = null + } + } + }, + 300_000 + ) +}) + +const running = new Map>() + +/** Starts `orca serve --json` on orcad and returns the terminal daemon pid its readiness names. */ +async function serveOnce(userData: string): Promise { + const child = spawnProcess({ + program: runtime!, + args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + env: serveEnv(userData), + timeoutMs: null + }) + running.set(userData, child) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + const line = await new Promise((resolveLine, reject) => { + const timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 120_000) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + const newline = stdout.indexOf('\n') + if (newline !== -1) { + clearTimeout(timer) + resolveLine(stdout.slice(0, newline)) + } + }) + }) + const daemon = JSON.parse(line).health?.terminalDaemon + expect(daemon?.state, stderr).toBe('live') + return daemon.pid +} + +/** SIGTERM is serve's graceful stop on POSIX; it leaves the daemon running by design. */ +async function stopServe(userData: string): Promise { + const child = running.get(userData) + running.delete(userData) + if (!child || child.exitCode !== null) { + return + } + const exited = new Promise((settle) => child.once('exit', settle)) + child.kill('SIGTERM') + await exited +} diff --git a/src/main/server/serve-pairing-output.ts b/src/main/server/serve-pairing-output.ts new file mode 100644 index 00000000000..52189705b3f --- /dev/null +++ b/src/main/server/serve-pairing-output.ts @@ -0,0 +1,29 @@ +/** Serve-readiness pieces shared by the desktop `--serve` host and orcad, so their stdout matches. */ +import { statSync } from 'node:fs' +import { isAbsolute } from 'node:path' + +export async function renderServePairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +/** The recipe line names this root, so it must be a real absolute directory. */ +export function assertServeProjectRoot(projectRoot: string): string { + if (!isAbsolute(projectRoot)) { + throw new Error(`--serve-project-root must be absolute: ${projectRoot}`) + } + if (!statSync(projectRoot).isDirectory()) { + throw new Error(`--serve-project-root must be a directory: ${projectRoot}`) + } + return projectRoot +} diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 9ab3f44a85f..bb6fd261ba4 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -114,6 +114,9 @@ vi.mock('../server/serve-stdout-boundary') vi.mock('./serve-desktop-activation', () => ({ createServeDesktopActivationGate: () => ({}) })) +vi.mock('./desktop-profile-instance-lock', () => ({ + acquireDesktopProfileInstanceLock: vi.fn(() => ({ state: 'unavailable' })) +})) vi.mock('./single-instance-lock', () => ({ shouldBypassSingleInstanceLock: () => false, shouldSkipSingleInstanceLock: () => false, diff --git a/src/main/startup/desktop-profile-instance-lock.test.ts b/src/main/startup/desktop-profile-instance-lock.test.ts new file mode 100644 index 00000000000..7f304d0635f --- /dev/null +++ b/src/main/startup/desktop-profile-instance-lock.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcadInstanceLockError, type OrcadInstanceLock } from '../orcad/orcad-instance-lock' +import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock' + +describe('the desktop share of the profile instance lock', () => { + it('takes the lock as the desktop', () => { + const lock: OrcadInstanceLock = Object.assign(Object.create(null), { release: vi.fn() }) + const acquire = vi.fn(() => lock) + expect(acquireDesktopProfileInstanceLock('/profile', acquire)).toEqual({ + state: 'acquired', + lock + }) + expect(acquire).toHaveBeenCalledWith('/profile', { role: 'desktop' }) + }) + + it('refuses while a live orcad serves the profile, and says so', () => { + const write = vi.fn() + const result = acquireDesktopProfileInstanceLock( + '/profile', + () => { + throw new OrcadInstanceLockError('orcad_instance_lock_held', 'Another orcad (pid 7) ...') + }, + write + ) + expect(result).toEqual({ + state: 'held', + message: '[single-instance] Another orcad (pid 7) ...' + }) + expect(write).toHaveBeenCalledWith(2, '[single-instance] Another orcad (pid 7) ...\n') + }) + + it('still starts when the lock cannot be taken for any other reason', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect( + acquireDesktopProfileInstanceLock('/profile', () => { + throw new OrcadInstanceLockError('orcad_instance_lock_unreadable', 'unreadable') + }) + ).toEqual({ state: 'unavailable' }) + }) +}) diff --git a/src/main/startup/desktop-profile-instance-lock.ts b/src/main/startup/desktop-profile-instance-lock.ts new file mode 100644 index 00000000000..4ff961482b2 --- /dev/null +++ b/src/main/startup/desktop-profile-instance-lock.ts @@ -0,0 +1,41 @@ +/** + * The desktop app takes orcad's instance lock on its userData profile, so `orca serve` on + * orcad and the desktop refuse each other instead of both writing one profile. + * + * Electron's single-instance lock only fences other Electron launches; orcad is a plain Node + * process that cannot see it. Sharing orcad's lock file gives both hosts one owner record. + */ +import { + acquireOrcadInstanceLock, + OrcadInstanceLockError, + type OrcadInstanceLock +} from '../orcad/orcad-instance-lock' +import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' + +export type DesktopProfileLockResult = + | { state: 'acquired'; lock: OrcadInstanceLock } + | { state: 'held'; message: string } + /** The lock could not be taken for another reason; the desktop starts as it did before it. */ + | { state: 'unavailable' } + +export function acquireDesktopProfileInstanceLock( + userDataPath: string, + acquire: typeof acquireOrcadInstanceLock = acquireOrcadInstanceLock, + write?: StartupDiagnosticSink +): DesktopProfileLockResult { + try { + const lock = acquire(userDataPath, { role: 'desktop' }) + // Best effort: a lock left by a dead pid is reclaimed on the next start anyway. + process.once('exit', () => lock.release()) + return { state: 'acquired', lock } + } catch (error) { + if (error instanceof OrcadInstanceLockError && error.code === 'orcad_instance_lock_held') { + const message = `[single-instance] ${error.message}` + writeStartupDiagnosticLine(message, write) + return { state: 'held', message } + } + // Why not refuse: only a proven live holder may stop a desktop that never took this lock before. + console.warn('[single-instance] Could not take the shared profile lock:', error) + return { state: 'unavailable' } + } +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index be0254a9d43..bb3c08a9f9e 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -43,6 +43,7 @@ import { import { setSpawnObserver } from '../../shared/child-process/spawn-observer' import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' +import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock' import { createServeDesktopActivationGate } from './serve-desktop-activation' import { shouldBypassSingleInstanceLock, @@ -261,6 +262,14 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false } + // Why after Electron's lock: that one fences other desktops; this one fences orcad `orca serve`. + if (!skip && !bypass) { + const profileLock = acquireDesktopProfileInstanceLock(getCanonicalUserDataPath()) + if (profileLock.state === 'held') { + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + return false + } + } state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) // Renderer and worker defaults must be fixed before any session exists. initializeBrowserProcessUserAgent( diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index 2be4d47d071..f582acfb421 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -1,8 +1,9 @@ -import { existsSync, statSync } from 'node:fs' -import { isAbsolute, join } from 'node:path' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { app } from 'electron' import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' import { notifyServeSupervisorReady } from '../serve-update-handoff' +import { assertServeProjectRoot, renderServePairingQr } from '../server/serve-pairing-output' import { mainProcessState as state } from './main-process-state' import { getServeOptions, type ServeOptions } from './serve-options' @@ -18,21 +19,6 @@ export function getBundledWebClientRoot(): string | undefined { return roots.find((root) => existsSync(join(root, 'web-index.html'))) } -async function renderTerminalPairingQr(pairingUrl: string): Promise { - // Why dynamic: qrcode is only reachable from mobile pairing, so launch should - // not parse it for the majority who never pair a device. - const QRCode = await import('qrcode') - try { - return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) - } catch { - try { - return await QRCode.toString(pairingUrl, { type: 'utf8' }) - } catch { - return null - } - } -} - export async function printServeReady(options: ServeOptions): Promise { const runtime = state.runtime const runtimeRpc = state.runtimeRpc @@ -43,12 +29,7 @@ export async function printServeReady(options: ServeOptions): Promise { if (!options.projectRoot) { throw new Error('--serve-recipe-json requires --serve-project-root') } - if (!isAbsolute(options.projectRoot)) { - throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) - } - if (!statSync(options.projectRoot).isDirectory()) { - throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) - } + assertServeProjectRoot(options.projectRoot) } const boundEndpoint = runtimeRpc.getWebSocketEndpoint() const advertised = boundEndpoint @@ -67,7 +48,7 @@ export async function printServeReady(options: ServeOptions): Promise { }) const pairingQr = pairing.available && options.mobilePairing - ? await renderTerminalPairingQr(pairing.pairingUrl) + ? await renderServePairingQr(pairing.pairingUrl) : null await state.serveReadinessPublisher.publish( { diff --git a/src/shared/orcad-local-serve-selection.ts b/src/shared/orcad-local-serve-selection.ts new file mode 100644 index 00000000000..f27d61fafd3 --- /dev/null +++ b/src/shared/orcad-local-serve-selection.ts @@ -0,0 +1,58 @@ +/** + * The contract between `orca serve` (CLI) and the app-side entry that decides whether this + * machine can serve on orcad. The CLI never loads that logic: it runs the packaged app's + * `out/main/.js` under ELECTRON_RUN_AS_NODE and reads one line. + */ +export const SERVE_RUNTIME_ENV = 'ORCA_SERVE_RUNTIME' +export const ORCAD_LOCAL_SERVE_SELECTION_ENTRY = 'orcad/orcad-local-serve-selection-entry' +export const ORCAD_LOCAL_SERVE_SELECTION_FLAGS = { + userData: '--user-data', + appRoot: '--app-root', + macUpdateHandoff: '--mac-update-handoff' +} as const +const RESULT_MARKER = 'ORCA_SERVE_RUNTIME' + +export type ServeRuntimeSelection = + | { kind: 'orcad'; runtime: string; entry: string; version: string } + /** `reason` is null when orcad was not asked for, so nothing is printed. */ + | { kind: 'electron'; reason: string | null } + +export function formatServeRuntimeSelection(selection: ServeRuntimeSelection): string { + return `${RESULT_MARKER} ${JSON.stringify(selection)}` +} + +/** The last result line in `output`, or null when none parses. */ +export function parseServeRuntimeSelection(output: string): ServeRuntimeSelection | null { + const line = output + .split(/\r?\n/u) + .findLast((candidate) => candidate.startsWith(`${RESULT_MARKER} `)) + if (!line) { + return null + } + try { + const parsed: unknown = JSON.parse(line.slice(RESULT_MARKER.length + 1)) + if (!parsed || typeof parsed !== 'object' || !('kind' in parsed)) { + return null + } + const record = Object.fromEntries(Object.entries(parsed)) + if ( + record.kind === 'orcad' && + typeof record.runtime === 'string' && + typeof record.entry === 'string' && + typeof record.version === 'string' + ) { + return { + kind: 'orcad', + runtime: record.runtime, + entry: record.entry, + version: record.version + } + } + if (record.kind === 'electron') { + return { kind: 'electron', reason: typeof record.reason === 'string' ? record.reason : null } + } + return null + } catch { + return null + } +} diff --git a/src/shared/orcad-native-preflight-report.ts b/src/shared/orcad-native-preflight-report.ts new file mode 100644 index 00000000000..cd20f43b288 --- /dev/null +++ b/src/shared/orcad-native-preflight-report.ts @@ -0,0 +1,47 @@ +/** + * `orcad.js --orcad-native-preflight`: the node-pty precondition orcad checks at boot, run on + * its own so `orca serve` can choose Electron before handing the terminal to an orcad that + * would refuse to start. One JSON line on stdout, exit 0 whatever the verdict. + */ +export const ORCAD_NATIVE_PREFLIGHT_FLAG = '--orcad-native-preflight' +const REPORT_TYPE = 'orcad_native_preflight' + +export type OrcadNativePreflightReport = { + type: typeof REPORT_TYPE + status: 'ok' | 'degraded' | 'blocked' | 'unverifiable' + reason: string | null +} + +export function formatOrcadNativePreflightReport( + status: OrcadNativePreflightReport['status'], + reason: string | null +): string { + return JSON.stringify({ type: REPORT_TYPE, status, reason }) +} + +/** The last report line in `output`, or null when none parses. */ +export function parseOrcadNativePreflightReport(output: string): OrcadNativePreflightReport | null { + for (const line of output.split(/\r?\n/u).toReversed()) { + try { + const parsed: unknown = JSON.parse(line) + if ( + parsed && + typeof parsed === 'object' && + 'type' in parsed && + parsed.type === REPORT_TYPE && + 'status' in parsed && + (parsed.status === 'ok' || + parsed.status === 'degraded' || + parsed.status === 'blocked' || + parsed.status === 'unverifiable') + ) { + const reason = + 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : null + return { type: REPORT_TYPE, status: parsed.status, reason } + } + } catch { + // Not the report line. + } + } + return null +}