orca/src/main/index.ts

271 lines
11 KiB
TypeScript

import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { killAllPty } from './ipc/pty'
import {
initDaemonPtyProvider,
disconnectDaemon,
cleanupOrphanedDaemon
} from './daemon/daemon-init'
import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { registerAppMenu } from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import {
configureDevUserDataPath,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentWatchdog,
installUncaughtPipeErrorGuard,
patchPackagedProcessPath
} from './startup/configure-process'
import { RateLimitService } from './rate-limits/service'
import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow } from './window/createMainWindow'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { openCodeHookService } from './opencode/hook-service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
* window close handler so it can tell the renderer to skip the running-process
* confirmation dialog and proceed directly to buffer capture + close. */
let isQuitting = false
let store: Store | null = null
let stats: StatsCollector | null = null
let claudeUsage: ClaudeUsageStore | null = null
let codexUsage: CodexUsageStore | null = null
let codexAccounts: CodexAccountService | null = null
let codexRuntimeHome: CodexRuntimeHomeService | null = null
let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
installUncaughtPipeErrorGuard()
patchPackagedProcessPath()
configureDevUserDataPath(is.dev)
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
function openMainWindow(): BrowserWindow {
if (!store) {
throw new Error('Store must be initialized before opening the main window')
}
if (!runtime) {
throw new Error('Runtime must be initialized before opening the main window')
}
if (!stats) {
throw new Error('Stats must be initialized before opening the main window')
}
if (!claudeUsage) {
throw new Error('Claude usage store must be initialized before opening the main window')
}
if (!codexUsage) {
throw new Error('Codex usage store must be initialized before opening the main window')
}
if (!rateLimits) {
throw new Error('Rate limit service must be initialized before opening the main window')
}
if (!codexAccounts) {
throw new Error('Codex account service must be initialized before opening the main window')
}
if (!codexRuntimeHome) {
throw new Error('Codex runtime home service must be initialized before opening the main window')
}
const window = createMainWindow(store, {
getIsQuitting: () => isQuitting,
onQuitAborted: () => {
isQuitting = false
}
})
registerCoreHandlers(
store,
runtime,
stats,
claudeUsage,
codexUsage,
codexAccounts,
rateLimits,
window.webContents.id
)
attachMainWindowServices(window, store, runtime, () => codexRuntimeHome!.prepareForCodexLaunch())
rateLimits.attach(window)
rateLimits.start()
window.on('closed', () => {
if (mainWindow === window) {
mainWindow = null
}
})
mainWindow = window
return window
}
app.whenReady().then(async () => {
electronApp.setAppUserModelId('com.stablyai.orca')
app.setName('Orca')
if (process.platform === 'darwin' && is.dev) {
const dockIcon = nativeImage.createFromPath(devIcon)
app.dock?.setIcon(dockIcon)
}
store = new Store()
stats = new StatsCollector()
claudeUsage = new ClaudeUsageStore(store)
codexUsage = new CodexUsageStore(store)
rateLimits = new RateLimitService()
codexRuntimeHome = new CodexRuntimeHomeService(store)
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch())
runtime = new OrcaRuntimeService(store, stats)
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
registerAppMenu({
onCheckForUpdates: () => checkForUpdatesFromMenu(),
onOpenSettings: () => {
mainWindow?.webContents.send('ui:openSettings')
},
onZoomIn: () => {
mainWindow?.webContents.send('terminal:zoom', 'in')
},
onZoomOut: () => {
mainWindow?.webContents.send('terminal:zoom', 'out')
},
onZoomReset: () => {
mainWindow?.webContents.send('terminal:zoom', 'reset')
},
onToggleStatusBar: () => {
mainWindow?.webContents.send('ui:toggleStatusBar')
}
})
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData')
})
// Why: persistent terminal sessions (the out-of-process daemon) are gated
// behind an experimental setting that defaults to OFF. Users on v1.3.0 had
// the daemon on by default, so on upgrade we may need to clean up a live
// daemon from their previous session before continuing with the local
// provider. `registerPtyHandlers` (called inside openMainWindow) relies on
// the provider being set, so whichever branch runs must complete first.
const daemonEnabled = store.getSettings().experimentalTerminalDaemon === true
let daemonStarted = false
if (daemonEnabled) {
// Why: catch so the app still opens even if the daemon fails. The local
// PTY provider remains as the fallback — terminals will still work, just
// without cross-restart persistence.
try {
await initDaemonPtyProvider()
daemonStarted = true
} catch (error) {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
}
} else {
// Why: stash the cleanup result so the renderer's one-shot transition
// toast can tell the user how many background sessions were stopped. Only
// record when `cleaned: true` — i.e. an orphan daemon was actually found.
// Fresh installs (no socket) skip the toast entirely.
try {
const result = await cleanupOrphanedDaemon()
if (result.cleaned) {
recordPendingDaemonTransitionNotice({ killedCount: result.killedCount })
}
} catch (error) {
console.error('[daemon] Failed to clean up orphaned daemon:', error)
}
}
setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted })
// Why: both server binds are independent and neither blocks window creation.
// Parallelizing them with the window open shaves ~100-200ms off cold start.
const [win] = await Promise.all([
Promise.resolve(openMainWindow()),
openCodeHookService.start().catch((error) => {
console.error('[opencode] Failed to start local hook server:', error)
}),
runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start local RPC transport:', error)
})
])
// Why: the macOS notification permission dialog must fire after the window
// is visible and focused. If it fires before the window exists, the system
// dialog either doesn't appear or gets immediately covered by the maximized
// window, making it impossible for the user to click "Allow".
win.once('show', () => {
triggerStartupNotificationRegistration(store!)
})
app.on('activate', () => {
// Don't re-open a window while Squirrel's ShipIt is replacing the .app
// bundle. Without this guard the old version gets resurrected and the
// update never applies.
if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) {
openMainWindow()
}
})
})
app.on('before-quit', () => {
isQuitting = true
// Why: PTY cleanup is deferred to will-quit so the renderer has a chance to
// capture terminal scrollback buffers before PTY exit events race in and
// unmount TerminalPane components (removing their capture callbacks).
// The window close handler passes isQuitting to the renderer so it skips the
// child-process confirmation dialog and proceeds directly to buffer capture.
rateLimits?.stop()
})
app.on('will-quit', () => {
// Why: stats.flush() must run before killAllPty() so it can read the
// live agent state and emit synthetic agent_stop events for agents that
// are still running. killAllPty() does not call runtime.onPtyExit(),
// so without this ordering, running agents would produce orphaned
// agent_start events with no matching stops.
openCodeHookService.stop()
stats?.flush()
killAllPty()
// Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app
// quit) but the client connection must be closed so sockets are released.
// disconnectDaemon only tears down the client transport — it does NOT kill
// the daemon process or mark its history as cleanly ended, preserving both
// warm reattach and crash recovery on next launch.
disconnectDaemon()
void closeAllWatchers()
if (runtimeRpc) {
void runtimeRpc.stop().catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
}
store?.flush()
})
app.on('window-all-closed', () => {
// Why: on macOS, closing all windows normally keeps the app alive (dock
// stays active). But when a quit is in progress (Cmd+Q), the window close
// handler defers to the renderer for buffer capture, which cancels the
// original quit sequence. Re-trigger quit here so the app actually exits
// instead of requiring a second Cmd+Q.
if (process.platform !== 'darwin' || isQuitting) {
app.quit()
}
})