From 5f1f9ea07a7c80e4dd7dcb2d7104616c614d84f0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 12 Oct 2026 08:39:07 -0700 Subject: [PATCH] fix(serve): give the first AppImage serve one unpacking deadline and progress (#28015) Direct AppImage serve now unpacks its stable copy once, under one overall deadline that also bounds the registration lock wait and both extraction attempts, prints progress to stderr, and refuses fast when the private cache is not writable, not executable, or too full. Co-authored-by: m4air Co-authored-by: Claude --- .../run-signal-case.sh | 6 + src/main/cli/appimage-extracted-root.ts | 32 ++- src/main/cli/appimage-registration-lock.ts | 14 +- .../cli/appimage-serve-preparation.test.ts | 182 ++++++++++++++++++ src/main/cli/appimage-serve-preparation.ts | 146 ++++++++++++++ src/main/cli/cli-install-location.ts | 5 +- src/main/cli/cli-installer-contracts.ts | 2 + src/main/cli/cli-installer.ts | 4 +- src/main/cli/linux-bare-orca-dispatcher.ts | 8 +- .../startup/main-process-runtime-launch.ts | 40 +--- src/main/startup/serve-cli-registration.ts | 61 ++++++ 11 files changed, 450 insertions(+), 50 deletions(-) create mode 100644 src/main/cli/appimage-serve-preparation.test.ts create mode 100644 src/main/cli/appimage-serve-preparation.ts create mode 100644 src/main/startup/serve-cli-registration.ts diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index efe6133de7e..efbca6592fc 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -104,6 +104,12 @@ if [[ "$entrypoint_kind" == appimage ]]; then echo "FAIL: installed CLI payload ${installed_target:-missing} is in the legacy namespace or missing" >&2 exit 1 fi + # A fresh cache means this serve unpacked the AppImage first, with progress on stderr. + if ! grep -q '\[serve\] AppImage copy ready after' "$stderr_log"; then + cat "$stderr_log" >&2 + echo "FAIL: first AppImage serve did not report unpacking progress" >&2 + exit 1 + fi registered_cli_verified=true fi diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index 07564276bd1..bfc5800e7a2 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -31,10 +31,18 @@ export type AppImageExtractedRoot = { stableLauncherPath: string } +export type AppImageExtractRunner = ( + appImagePath: string, + cwd: string, + timeoutMs: number +) => Promise + export type AppImageExtractionOptions = { appImagePath: string cacheRootPath?: string - runExtract?: (appImagePath: string, cwd: string) => Promise + runExtract?: AppImageExtractRunner + /** Absolute time (ms) by which every extraction attempt must have finished. */ + deadlineAt?: number } export function getAppImageCacheRootPath(homePath = homedir()): string { @@ -139,7 +147,7 @@ export async function ensureAppImageExtractedRoot( ): Promise { for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { const root = resolveAppImageExtractedRoot(options) - if (!root) { + if (!root || remainingExtractionMs(options) <= 0) { return null } const complete = @@ -178,7 +186,11 @@ async function extractAppImageGeneration( const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX)) const stopTracking = trackAppImageExtraction(stagingPath) try { - await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath) + const timeoutMs = remainingExtractionMs(options) + if (timeoutMs <= 0) { + return false + } + await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath, timeoutMs) const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR) if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) { return false @@ -195,6 +207,12 @@ async function extractAppImageGeneration( } } +function remainingExtractionMs(options: AppImageExtractionOptions): number { + return options.deadlineAt === undefined + ? APPIMAGE_EXTRACTION_TIMEOUT_MS + : Math.min(APPIMAGE_EXTRACTION_TIMEOUT_MS, options.deadlineAt - Date.now()) +} + function digest(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 24) } @@ -273,12 +291,16 @@ async function renameRoot(sourcePath: string, destinationPath: string): Promise< } } -async function runAppImageExtract(appImagePath: string, cwd: string): Promise { +async function runAppImageExtract( + appImagePath: string, + cwd: string, + timeoutMs: number +): Promise { const result = await runProcess({ program: appImagePath, args: ['--appimage-extract'], cwd, - timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS, + timeoutMs, maxOutputBytes: 1024 * 1024, terminationBarrier: true }) diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts index 7c6350e249f..a0b63902e6f 100644 --- a/src/main/cli/appimage-registration-lock.ts +++ b/src/main/cli/appimage-registration-lock.ts @@ -21,21 +21,29 @@ const LOCK_RETRIES = { export async function withAppImageRegistrationLock( cacheRootPath: string, - operation: () => Promise + operation: () => Promise, + options: { deadlineAt?: number } = {} ): Promise { await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + const waitMs = Math.min( + LOCK_ACQUIRE_DEADLINE_MS, + options.deadlineAt === undefined ? Infinity : options.deadlineAt - Date.now() + ) let release: () => Promise try { + if (waitMs <= 0) { + throw new Error('deadline already passed') + } release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { realpath: false, - retries: LOCK_RETRIES, + retries: { ...LOCK_RETRIES, maxRetryTime: waitMs }, stale: LOCK_STALE_MS, update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 }) } catch (error) { throw new Error( `Timed out waiting for another Orca process to finish CLI registration ` + - `(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` + + `(waited ${Math.round(Math.max(waitMs, 0) / 1000)}s). ` + `If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`, { cause: error } ) diff --git a/src/main/cli/appimage-serve-preparation.test.ts b/src/main/cli/appimage-serve-preparation.test.ts new file mode 100644 index 00000000000..882788d1173 --- /dev/null +++ b/src/main/cli/appimage-serve-preparation.test.ts @@ -0,0 +1,182 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { prepareAppImageCopyForServe, probeAppImageCache } from './appimage-serve-preparation' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-serve-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +async function writePayload(cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { mode: 0o755 }) +} + +const acceptCache = async (): Promise => null + +describe('prepareAppImageCopyForServe', () => { + it('returns at once, silently, when the copy already exists', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(join(root.rootPath, 'resources', 'bin'), { recursive: true }) + await writeFile(join(root.rootPath, 'resources', 'bin', 'orca-ide'), '', { mode: 0o755 }) + const lines: string[] = [] + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + log: (line) => lines.push(line), + runExtract: async () => { + throw new Error('must not extract') + } + }) + + expect(result.status).toBe('ready') + expect(lines).toEqual([]) + }) + + it('reports progress while unpacking and when it is done', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const lines: string[] = [] + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + progressIntervalMs: 5, + log: (line) => lines.push(line), + runExtract: async (_path, cwd) => { + await new Promise((resolve) => setTimeout(resolve, 40)) + await writePayload(cwd) + } + }) + + expect(result.status).toBe('ready') + expect(lines[0]).toContain('unpacking a stable copy') + expect(lines.some((line) => line.includes('Still unpacking'))).toBe(true) + expect(lines.at(-1)).toContain('AppImage copy ready') + }) + + it('bounds every extraction attempt by one overall deadline', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const timeouts: number[] = [] + const startedAt = Date.now() + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + budgetMs: 200, + probeCache: acceptCache, + log: () => {}, + runExtract: async (_path, _cwd, timeoutMs) => { + timeouts.push(timeoutMs) + await new Promise((resolve) => setTimeout(resolve, timeoutMs + 5)) + throw new Error('AppImage extraction timed out.') + } + }) + + expect(result).toMatchObject({ status: 'unavailable' }) + expect(timeouts.length).toBeGreaterThanOrEqual(1) + expect(timeouts.every((timeout) => timeout <= 200)).toBe(true) + expect(Date.now() - startedAt).toBeLessThan(2_000) + }) + + it('refuses before extracting when the cache cannot hold a runnable copy', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extracted = false + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + probeCache: async () => 'cache is mounted noexec', + log: () => {}, + runExtract: async () => { + extracted = true + } + }) + + expect(result).toMatchObject({ status: 'unavailable', reason: 'cache is mounted noexec' }) + expect(extracted).toBe(false) + }) + + it('gives up waiting for the lock at the deadline', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let release!: () => void + const held = withAppImageRegistrationLock( + cacheRootPath, + () => new Promise((resolve) => (release = resolve)) + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + budgetMs: 300, + probeCache: acceptCache, + log: () => {}, + runExtract: async (_path, cwd) => writePayload(cwd) + }) + release() + await held + + expect(result).toMatchObject({ status: 'unavailable' }) + }) +}) + +// POSIX permissions and shebang execution are what the probe measures. +describe.skipIf(process.platform === 'win32')('probeAppImageCache', () => { + it('accepts a private, writable cache that can run programs', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull() + }) + + it('accepts an AppImage in a read-only directory', async () => { + const { root, cacheRootPath } = await makeFixture() + const readOnlyDir = join(root, 'opt') + await mkdir(readOnlyDir) + const appImagePath = join(readOnlyDir, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await chmod(readOnlyDir, 0o555) + try { + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull() + } finally { + await chmod(readOnlyDir, 0o755) + } + }) + + it('refuses a cache other users can write to', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await mkdir(cacheRootPath, { recursive: true }) + await chmod(cacheRootPath, 0o777) + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain( + 'writable by other users' + ) + }) + + it.skipIf(process.getuid?.() === 0)('refuses a cache it cannot write to', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await mkdir(cacheRootPath, { recursive: true }) + await chmod(cacheRootPath, 0o500) + try { + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain( + 'not writable' + ) + } finally { + await chmod(cacheRootPath, 0o700) + } + }) +}) diff --git a/src/main/cli/appimage-serve-preparation.ts b/src/main/cli/appimage-serve-preparation.ts new file mode 100644 index 00000000000..178e18dd2a4 --- /dev/null +++ b/src/main/cli/appimage-serve-preparation.ts @@ -0,0 +1,146 @@ +/** + * The first `AppImage serve` after an update extracts the AppImage before it registers the CLI. + * That wait gets one deadline across the lock and every extraction attempt, progress on stderr, + * and a fast refusal when the private cache cannot hold a runnable copy. + */ +import { randomUUID } from 'node:crypto' +import { mkdir, stat, statfs, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { + ensureAppImageExtractedRoot, + isAppImageExtractionComplete, + resolveAppImageCacheRootPath, + resolveAppImageExtractedRoot, + type AppImageExtractionOptions +} from './appimage-extracted-root' +import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' + +export const APPIMAGE_SERVE_PREPARATION_BUDGET_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS +const PROGRESS_INTERVAL_MS = 15_000 +// An extracted payload is about 2.7x the AppImage file; leave headroom for the staging copy. +const FREE_SPACE_FACTOR = 3 + +export type AppImageServePreparation = + | { status: 'ready'; deadlineAt: number } + | { status: 'unavailable'; reason: string; deadlineAt: number } + +export type AppImageServePreparationOptions = Omit & { + budgetMs?: number + progressIntervalMs?: number + log?: (line: string) => void + probeCache?: (cacheRootPath: string, appImagePath: string) => Promise +} + +export async function prepareAppImageCopyForServe( + options: AppImageServePreparationOptions +): Promise { + const budgetMs = options.budgetMs ?? APPIMAGE_SERVE_PREPARATION_BUDGET_MS + const deadlineAt = Date.now() + budgetMs + const log = options.log ?? ((line: string) => console.log(line)) + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return { status: 'unavailable', reason: 'the AppImage file is not readable', deadlineAt } + } + if (isAppImageExtractionComplete(root)) { + return { status: 'ready', deadlineAt } + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + const startedAt = Date.now() + log( + `[serve] First start of this AppImage: unpacking a stable copy into ${cacheRootPath} ` + + `(up to ${formatSeconds(budgetMs)})` + ) + const refusal = await (options.probeCache ?? probeAppImageCache)( + cacheRootPath, + options.appImagePath + ) + if (refusal) { + log(`[serve] Cannot unpack the AppImage copy: ${refusal}`) + return { status: 'unavailable', reason: refusal, deadlineAt } + } + const progress = setInterval( + () => + log( + `[serve] Still unpacking the AppImage copy (${formatSeconds(Date.now() - startedAt)} elapsed)` + ), + options.progressIntervalMs ?? PROGRESS_INTERVAL_MS + ) + progress.unref?.() + try { + const extracted = await withAppImageRegistrationLock( + cacheRootPath, + () => ensureAppImageExtractedRoot({ ...options, deadlineAt }), + { deadlineAt } + ) + if (!extracted) { + const reason = + Date.now() >= deadlineAt + ? `it did not finish within ${formatSeconds(budgetMs)}` + : 'the AppImage could not be unpacked' + log(`[serve] Cannot unpack the AppImage copy: ${reason}`) + return { status: 'unavailable', reason, deadlineAt } + } + log(`[serve] AppImage copy ready after ${formatSeconds(Date.now() - startedAt)}`) + return { status: 'ready', deadlineAt } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + log(`[serve] Cannot unpack the AppImage copy: ${reason}`) + return { status: 'unavailable', reason, deadlineAt } + } finally { + clearInterval(progress) + } +} + +/** Null when the cache is a private, writable directory that can run programs and has room. */ +export async function probeAppImageCache( + cacheRootPath: string, + appImagePath: string +): Promise { + try { + await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + const stats = await stat(cacheRootPath) + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + return `${cacheRootPath} belongs to another user` + } + if ((stats.mode & 0o022) !== 0) { + return `${cacheRootPath} is writable by other users` + } + const needed = (await stat(appImagePath)).size * FREE_SPACE_FACTOR + const fsStats = await statfs(cacheRootPath) + if (fsStats.bavail * fsStats.bsize < needed) { + return `${cacheRootPath} needs about ${Math.ceil(needed / 1024 / 1024)} MB free` + } + } catch (error) { + return `${cacheRootPath} is not usable (${describeError(error)})` + } + const probePath = join(cacheRootPath, `.exec-probe-${process.pid}-${randomUUID()}`) + try { + try { + await writeFile(probePath, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + } catch (error) { + return `${cacheRootPath} is not writable (${describeError(error)})` + } + try { + const result = await runProcess({ program: probePath, args: [], timeoutMs: 5_000 }) + return result.code === 0 ? null : `${cacheRootPath} cannot run programs` + } catch (error) { + return `${cacheRootPath} cannot run programs (${describeError(error)}; is it mounted noexec?)` + } + } finally { + await unlink(probePath).catch(() => {}) + } +} + +function describeError(error: unknown): string { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + return error.code + } + return error instanceof Error ? error.message : String(error) +} + +function formatSeconds(ms: number): string { + return `${Math.max(0, Math.round(ms / 1000))}s` +} diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index 228e574addc..4be9d60302a 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -58,6 +58,7 @@ export abstract class CliInstallLocation { protected readonly hasUnverifiedAppImageRuntime: boolean protected readonly appImageCacheRootPath: string protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + protected readonly appImageDeadlineAt?: number protected get commandName(): string { if (!this.isPackaged && !this.commandPathOverride) { @@ -117,6 +118,7 @@ export abstract class CliInstallLocation { this.appImageCacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) this.appImageExtractRunner = options.appImageExtractRunner + this.appImageDeadlineAt = options.appImageDeadlineAt } protected resolveInstallSpec(): InstallSpec | null { @@ -274,7 +276,8 @@ export abstract class CliInstallLocation { ? { appImagePath: this.appImagePath, cacheRootPath: this.appImageCacheRootPath, - runExtract: this.appImageExtractRunner + runExtract: this.appImageExtractRunner, + deadlineAt: this.appImageDeadlineAt } : null } diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts index 40a75cb984f..079820ae9b1 100644 --- a/src/main/cli/cli-installer-contracts.ts +++ b/src/main/cli/cli-installer-contracts.ts @@ -24,6 +24,8 @@ export type CliInstallerOptions = { appImagePath?: string | null appImageCacheRootPath?: string appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + /** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */ + appImageDeadlineAt?: number } export type InstallSpec = { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 1be0f88b545..761b6f00c58 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -222,7 +222,9 @@ export class CliInstaller extends CliPathRegistration { private runAppImageRegistrationOperation(operation: () => Promise): Promise { return this.isLinuxAppImage() - ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation) + ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation, { + deadlineAt: this.appImageDeadlineAt + }) : operation() } } diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index ac48c7d6b25..91979020fc2 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -32,6 +32,8 @@ export type LinuxBareOrcaDispatcherOptions = { appImageCacheRootPath?: string /** Test seam — defaults to running the AppImage's own `--appimage-extract`. */ appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + /** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */ + appImageDeadlineAt?: number } export type LinuxBareOrcaDispatcherState = @@ -100,7 +102,8 @@ async function resolveStableLauncherPath( const extractionOptions = { appImagePath, cacheRootPath: options.appImageCacheRootPath, - runExtract: options.appImageExtractRunner + runExtract: options.appImageExtractRunner, + deadlineAt: options.appImageDeadlineAt } return withAppImageRegistrationLock( resolveAppImageCacheRootPath(extractionOptions), @@ -113,7 +116,8 @@ async function resolveStableLauncherPath( ) } return extractedRoot?.stableLauncherPath ?? null - } + }, + { deadlineAt: options.appImageDeadlineAt } ) } const launcher = getBundledLauncherPath('linux', options.resourcesPath) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 2789aca517c..e30f8ee6f1a 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -33,8 +33,7 @@ import { recordRuntimeRpcStartFailure, showRuntimeRpcStartupFailureDialog } from '../runtime/runtime-rpc-startup-failure' -import { CliInstaller } from '../cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { registerCliForHeadlessServe } from './serve-cli-registration' import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' import { startDesktopPushService } from './main-process-push-startup' @@ -175,42 +174,7 @@ async function launchServeMode( startDesktopPushService(runtimeRpc) settleDesktopActivation() installServeQuitHandling() - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } + await registerCliForHeadlessServe() // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. state.automations?.start() // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is diff --git a/src/main/startup/serve-cli-registration.ts b/src/main/startup/serve-cli-registration.ts new file mode 100644 index 00000000000..e5c01910b9f --- /dev/null +++ b/src/main/startup/serve-cli-registration.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' +import { prepareAppImageCopyForServe } from '../cli/appimage-serve-preparation' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' + +/** Headless serve has no renderer to run the normal cli:install flow, so it registers here. */ +export async function registerCliForHeadlessServe(): Promise { + // Windows-excluded: install() only mutates registry PATH, not child terminals. + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return + } + const appImagePath = + process.platform === 'linux' ? (resolveAppImageRuntimeIdentity()?.appImagePath ?? null) : null + let appImageDeadlineAt: number | undefined + if (appImagePath) { + // Why first: CLI install and the bare dispatcher would otherwise each wait out their own + // extraction timeout; this gives the whole AppImage wait one deadline and visible progress. + const preparation = await prepareAppImageCopyForServe({ appImagePath }) + if (preparation.status === 'unavailable') { + console.warn(`[serve] orca CLI install skipped: ${preparation.reason}`) + return + } + appImageDeadlineAt = preparation.deadlineAt + } + try { + // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + }, + ...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt }) + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath, + ...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt }) + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } +}