mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* fix(relay): install the desktop Relay provider on demand * fix(relay): suspend Relay installs across sign-out and bound the mint's install wait --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
323 lines
15 KiB
TypeScript
323 lines
15 KiB
TypeScript
import { app, type BrowserWindow } from 'electron'
|
|
import { is } from '@electron-toolkit/utils'
|
|
import {
|
|
getCanonicalUserDataPath,
|
|
migrateMobilePairingDataToCanonicalUserDataPath
|
|
} from '../persistence'
|
|
import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
|
import { registerMobileHandlers } from '../ipc/mobile'
|
|
import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty'
|
|
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
|
import { publishHeadlessRuntimeGraph } from '../runtime/headless-runtime-graph'
|
|
import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend'
|
|
import { browserManager } from '../browser/browser-manager'
|
|
import { getDesktopRelayStatus } from './main-process-relay-status'
|
|
import { startDesktopRelayService } from './main-process-relay-startup'
|
|
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
|
|
import {
|
|
bindTerminalRuntimeStartupServices,
|
|
handleCodexHomePtySpawned,
|
|
handlePtyExit,
|
|
startTerminalRuntimeStartupServices
|
|
} from './main-process-pty-startup'
|
|
import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation'
|
|
import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch'
|
|
import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup'
|
|
import { repairKnownPoisonedInstallDirBeforeWindow } from './windows-install-dir-acl-recovery'
|
|
import { installServeQuitHandling } from './serve-quit-handling'
|
|
import { registerHeadlessServeSshHandlers } from './headless-serve-ssh-registration'
|
|
import { settleServeDesktopActivation } from './serve-desktop-activation'
|
|
import {
|
|
recordRuntimeRpcStartFailure,
|
|
showRuntimeRpcStartupFailureDialog
|
|
} from '../runtime/runtime-rpc-startup-failure'
|
|
import { CliInstaller } from '../cli/cli-installer'
|
|
import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher'
|
|
import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion'
|
|
import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration'
|
|
import { startDesktopPushService } from './main-process-push-startup'
|
|
import { mainProcessState as state } from './main-process-state'
|
|
import { logStartupMilestone } from './startup-diagnostics'
|
|
import { scheduleAgentLaunchRecordWarmup } from './agent-launch-record-warmup'
|
|
import { emitServeBrowserIdentityActionLine } from '../server/serve-stdout-boundary'
|
|
import { getBrowserIdentityModeStatus } from '../browser/browser-identity-mode-store'
|
|
|
|
type RuntimeService = NonNullable<typeof state.runtime>
|
|
|
|
export type MainProcessRuntimeLaunchOptions = {
|
|
openMainWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow
|
|
handleMacAppActivation: () => void
|
|
}
|
|
|
|
function settleDesktopActivation(): void {
|
|
const gate = state.desktopActivationGate
|
|
if (!gate) {
|
|
return
|
|
}
|
|
settleServeDesktopActivation(gate, {
|
|
hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider)
|
|
})
|
|
}
|
|
|
|
function installRuntimeRpc(
|
|
runtime: RuntimeService,
|
|
serveOptions: ReturnType<typeof getServeOptions> | null
|
|
): OrcaRuntimeRpcServer {
|
|
// Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path.
|
|
migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData'))
|
|
// Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port.
|
|
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
|
|
const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT
|
|
const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort)
|
|
if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) {
|
|
throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`)
|
|
}
|
|
// Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511).
|
|
const devWsPort = is.dev && !isE2E ? 6769 : undefined
|
|
const runtimeRpc = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
// Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts.
|
|
userDataPath: getCanonicalUserDataPath(),
|
|
enableWebSocket: true,
|
|
// Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device;
|
|
// `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over.
|
|
exposeNetworkByDefault: Boolean(serveOptions) || isE2E,
|
|
...(isE2E ? { wsPort: e2eWsPort } : {}),
|
|
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
|
|
...(serveOptions?.wsPort !== undefined
|
|
? {
|
|
wsPort: serveOptions.wsPort,
|
|
// Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability.
|
|
preferPinnedWsPort: true
|
|
}
|
|
: {}),
|
|
webClientRoot: getBundledWebClientRoot()
|
|
})
|
|
state.runtimeRpc = runtimeRpc
|
|
registerMobileHandlers(runtimeRpc, {
|
|
getRelayStatus: getDesktopRelayStatus,
|
|
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
|
|
if (
|
|
!state.mainWindow ||
|
|
state.mainWindow.isDestroyed() ||
|
|
state.mainWindow.webContents.id !== webContentsId ||
|
|
!state.pendingUnpairedDeviceAuthFailure
|
|
) {
|
|
return false
|
|
}
|
|
state.pendingUnpairedDeviceAuthFailure = false
|
|
return true
|
|
}
|
|
})
|
|
// Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing.
|
|
runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {
|
|
// Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it.
|
|
state.pendingUnpairedDeviceAuthFailure = true
|
|
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
|
state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure')
|
|
}
|
|
})
|
|
return runtimeRpc
|
|
}
|
|
|
|
async function launchServeMode(
|
|
runtime: RuntimeService,
|
|
runtimeRpc: OrcaRuntimeRpcServer,
|
|
serveOptions: NonNullable<ReturnType<typeof getServeOptions>>
|
|
): Promise<void> {
|
|
// Why here: headless serve has no window to unblock, so keep the persisted proxy strictly
|
|
// ahead of every fetcher this phase can reach (relay, CLI install, RPC clients).
|
|
await state.initialProxyApplicationReady
|
|
// Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness.
|
|
logStartupMilestone('wsl-cli-barrier-start')
|
|
await state.managedWslCliStartupBarrierReady
|
|
logStartupMilestone('wsl-cli-barrier-resolved', {
|
|
reconciliation: state.managedWslCliReconciliationStatus
|
|
})
|
|
// Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers.
|
|
await state.localPtyStartupReady
|
|
await state.localPtyProviderStartupReady
|
|
await registerHeadlessPtyRuntime(
|
|
runtime,
|
|
prepareCodexRuntimeHomeForLaunch,
|
|
() => state.store!.getSettings(),
|
|
(target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target),
|
|
state.store!,
|
|
prepareCodexSessionResumeForLaunch,
|
|
{ onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit }
|
|
)
|
|
await runtime.refreshRestoredOrchestrationAuthority()
|
|
await runtime.reconcileLegacyWorkerTerminals()
|
|
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
|
|
if (state.headlessBrowserDisplayAvailable) {
|
|
runtime.setOffscreenBrowserBackend(
|
|
new OffscreenBrowserBackend(browserManager, {
|
|
getAgentBrowserBridge: () => state.agentBrowserBridge
|
|
})
|
|
)
|
|
}
|
|
publishHeadlessRuntimeGraph(runtime)
|
|
// Why before RPC binds: the first paired client must already see this host's SSH targets.
|
|
registerHeadlessServeSshHandlers(state.store!, runtime)
|
|
await runtimeRpc.start().catch((error) => {
|
|
console.error('[runtime] Failed to start headless RPC transport:', error)
|
|
throw error
|
|
})
|
|
scheduleAgentLaunchRecordWarmup(null)
|
|
// Why: a phone paired to a headless host still registers and unregisters its token;
|
|
// it simply never receives a push, because nothing dispatches notifications here.
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
// 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
|
|
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
|
|
void scheduleAllPendingHistoryTreeRemovals()
|
|
emitServeBrowserIdentityActionLine(getBrowserIdentityModeStatus())
|
|
await printServeReady(serveOptions)
|
|
}
|
|
|
|
async function launchDesktopMode(
|
|
runtimeRpc: OrcaRuntimeRpcServer,
|
|
shellPathReady: Promise<void>,
|
|
desktopWindow: BrowserWindow | null,
|
|
openMainWindow: MainProcessRuntimeLaunchOptions['openMainWindow']
|
|
): Promise<void> {
|
|
// Preserve the pre-split startup failure contract if composition ever hands
|
|
// this phase an incomplete runtime graph.
|
|
if (!runtimeRpc) {
|
|
throw new Error('runtime_rpc_unavailable')
|
|
}
|
|
// Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap.
|
|
const [win, runtimeRpcStartResult] = await Promise.all([
|
|
Promise.resolve(desktopWindow ?? openMainWindow()),
|
|
shellPathReady
|
|
.then(() => runtimeRpc.start())
|
|
.then(
|
|
() => ({ ok: true as const }),
|
|
(error: unknown) => {
|
|
recordRuntimeRpcStartFailure(error)
|
|
return { ok: false as const, error }
|
|
}
|
|
)
|
|
])
|
|
scheduleAgentLaunchRecordWarmup(win)
|
|
if (!runtimeRpcStartResult.ok) {
|
|
// Why gated: this dialog is the only launch-phase text read through translateMain, and i18n
|
|
// now settles alongside this phase — without the wait a non-English user could get the
|
|
// English defaultValue fallback. Still off the renderer's path (it is failure-only).
|
|
void state.mainProcessI18nReady.then(() =>
|
|
showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error)
|
|
)
|
|
}
|
|
// Why before the proxy await: a mint in that window must wait for the installer, not fail
|
|
// provider_missing; the installer itself holds its first request until the proxy lands.
|
|
startDesktopRelayService(runtimeRpc)
|
|
// Why after the window and not before it: the default-session request guard already holds every
|
|
// fetcher until the persisted proxy lands, so this only has to keep the launch phase itself
|
|
// ordered ahead of the push gateway — it must not gate the renderer.
|
|
await state.initialProxyApplicationReady
|
|
// Why after the proxy await: the push gateway client is an app-owned fetcher, so it must not
|
|
// issue its first request ahead of the persisted proxy.
|
|
startDesktopPushService(runtimeRpc)
|
|
// Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window.
|
|
win.once('show', () => {
|
|
// Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener.
|
|
const store = state.store
|
|
if (store && store.getOnboarding().closedAt !== null) {
|
|
triggerStartupNotificationRegistration(store)
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function initializeMainProcessRuntimeLaunch(
|
|
options: MainProcessRuntimeLaunchOptions
|
|
): Promise<void> {
|
|
const runtime = state.runtime
|
|
const shellPathHydration = state.windowsShellPathHydration
|
|
if (!runtime || !shellPathHydration) {
|
|
throw new Error('Runtime and shell-path services must be initialized before launch')
|
|
}
|
|
let serveOptions: ReturnType<typeof getServeOptions> | null = null
|
|
try {
|
|
serveOptions = state.isServeMode ? getServeOptions(process.argv) : null
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
app.exit(1)
|
|
return
|
|
}
|
|
state.serveOptions = serveOptions
|
|
const runtimeRpc = installRuntimeRpc(runtime, serveOptions)
|
|
const shellPathReady = shellPathHydration.whenReady()
|
|
// Why published: the renderer's git-environment barrier must fence on the same
|
|
// generation the terminal startup services wait for, not a later re-read.
|
|
state.shellPathReady = shellPathReady
|
|
// Why before any window: the poisoned install DACL kills the renderer at init, and
|
|
// the probe that detects it cannot finish before createMainWindow. Bounded, and a
|
|
// no-op (one absent-file read) unless a previous launch already recorded the verdict.
|
|
const aclGate = await repairKnownPoisonedInstallDirBeforeWindow({
|
|
isServeMode: state.isServeMode || serveOptions !== null,
|
|
userDataPath: app.getPath('userData'),
|
|
appVersion: app.getVersion()
|
|
})
|
|
if (aclGate !== 'not-marked' && aclGate !== 'skipped') {
|
|
logStartupMilestone('install-dir-acl-repair-blocking-done', { mode: aclGate })
|
|
}
|
|
let desktopWindow: BrowserWindow | null = null
|
|
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
|
|
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
|
|
bindServices: bindTerminalRuntimeStartupServices,
|
|
openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true }),
|
|
shellPathReady,
|
|
startServices: startTerminalRuntimeStartupServices
|
|
})
|
|
desktopWindow = desktopStartup.window
|
|
} else {
|
|
await shellPathReady
|
|
bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))
|
|
}
|
|
app.on('activate', options.handleMacAppActivation)
|
|
if (serveOptions) {
|
|
await launchServeMode(runtime, runtimeRpc, serveOptions)
|
|
return
|
|
}
|
|
await launchDesktopMode(runtimeRpc, shellPathReady, desktopWindow, options.openMainWindow)
|
|
}
|