/* oxlint-disable max-lines */ import { app, BrowserWindow, ipcMain, Menu, nativeTheme, Notification, powerMonitor, screen } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' import { isCrashReportReason } from '../../shared/crash-reporting' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, DEFAULT_RENDERER_RECOVERY_WINDOW_MS, RendererRecoveryCircuitBreaker } from '../crash-reporting/renderer-recovery-circuit-breaker' import { getWindowShortcutActionId, matchesRecentTabSwitcherChord, nativeZoomCommandMatchesKeybindings, resolveWindowShortcutAction, windowShortcutActionCapturesTerminal, type WindowShortcutAction } from '../../shared/window-shortcut-policy' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent } from '../../shared/modifier-double-tap-detector' import { normalizeTerminalShortcutPolicy, type KeybindingMatchOptions, type KeybindingOverrides } from '../../shared/keybindings' import { getMainE2EConfig } from '../e2e-config' import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000 function forceRepaint(window: BrowserWindow): void { // Why: webContents can be destroyed a beat before the BrowserWindow during close, and this runs from timers/focus events in that gap. if (window.isDestroyed() || window.webContents.isDestroyed()) { return } window.webContents.invalidate() // Why: macOS 26 scene-backed windows deadlock on frame mutation, and device emulation can // strand the compositor after wake. The native shell no longer relies on dvh reflow. if (isMacosTahoeOrNewer()) { return } if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) { return } activeRepaintJiggles.add(window) // Why: show/restore fire from inside AppKit's window-state dispatch; mutating the frame there re-enters scene handling, so nudge on a fresh turn. setTimeout(() => { if (window.isDestroyed()) { activeRepaintJiggles.delete(window) return } const [width, height] = window.getSize() // Why: if the nudge throws mid-flight the WeakSet entry must still clear, or this window // never repaints again. try { window.setSize(width + 1, height) } catch { activeRepaintJiggles.delete(window) return } setTimeout(() => { try { if (!window.isDestroyed()) { const [currentWidth, currentHeight] = window.getSize() // Why: a real user resize during the jiggle owns the final bounds. if (currentWidth === width + 1 && currentHeight === height) { window.setSize(width, height) } } } finally { activeRepaintJiggles.delete(window) } }, 32) }, 0) } function installMacosVisibilityRepaint(window: BrowserWindow): void { let delayedRepaintTimer: ReturnType | null = null const repaintAfterVisibilityTransition = (): void => { forceRepaint(window) if (delayedRepaintTimer) { clearTimeout(delayedRepaintTimer) } // Why: macOS may restore compositor layers after the show/restore event; a second paint catches late black-surface recovery. delayedRepaintTimer = setTimeout(() => { delayedRepaintTimer = null forceRepaint(window) }, 250) } const clearDelayedRepaint = (): void => { if (delayedRepaintTimer) { clearTimeout(delayedRepaintTimer) delayedRepaintTimer = null } } // Why: occlusion reveal can fire no restore/show, so preserve the renderer relay without // trusting events from another window. const onRendererRevealed = (event: Electron.IpcMainEvent): void => { if (window.isDestroyed() || window.webContents.isDestroyed()) { return } if (event.sender !== window.webContents) { return } forceRepaint(window) } ipcMain.on('ui:window-revealed', onRendererRevealed) window.on('restore', repaintAfterVisibilityTransition) window.on('show', repaintAfterVisibilityTransition) // Why: occlusion-uncover can fire only focus; invalidate without resizing terminals on Cmd+Tab. window.on('focus', () => { if (!window.isDestroyed() && !window.webContents.isDestroyed()) { window.webContents.invalidate() } }) window.on('closed', () => { clearDelayedRepaint() ipcMain.removeListener('ui:window-revealed', onRendererRevealed) }) } function isMacAppPasteInput(input: Electron.Input): boolean { return ( process.platform === 'darwin' && input.type === 'keyDown' && input.meta && !input.control && !input.alt && !input.shift && (input.code === 'KeyV' || input.key.toLowerCase() === 'v') ) } // Why: titlebar content center sits ~18 CSS px from top (×zoom); traffic lights are ~12px tall, so top edge = center − 6. const TITLEBAR_CSS_CENTER = 18 const TRAFFIC_LIGHT_RADIUS = 6 const TRAFFIC_LIGHT_X = 16 const MIN_WIDTH = 600 const MIN_HEIGHT = 400 function syncTrafficLightPosition(win: BrowserWindow, zoomFactor: number): void { if (process.platform !== 'darwin' || win.isDestroyed()) { return } const y = Math.round(TITLEBAR_CSS_CENTER * zoomFactor - TRAFFIC_LIGHT_RADIUS) win.setWindowButtonPosition({ x: TRAFFIC_LIGHT_X, y }) } type CreateMainWindowOptions = { /** Returns true when a manual app.quit() (Cmd+Q) is in progress, so the renderer skips the running-process confirm dialog. */ getIsQuitting?: () => boolean /** Notifies the caller when the renderer vetoes unload, so the quit latch clears — a prevented beforeunload cancels the in-flight app.quit(). */ onQuitAborted?: () => void onRendererProcessGone?: ( details: Electron.RenderProcessGoneDetails, webContentsId: number ) => void /** Returns true when Orca should reload after renderer loss; update-relaunch/quit tear down children intentionally, so don't fight shutdown. */ shouldRecoverRenderer?: ( details: Electron.RenderProcessGoneDetails, webContentsId: number ) => boolean /** Called when consecutive auto-recoveries hit the circuit-breaker limit so the host can prompt instead of crash-looping. */ onRendererRecoveryExhausted?: (info: { details: Electron.RenderProcessGoneDetails webContentsId: number recentRecoveryCount: number }) => void /** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */ deferLoad?: boolean title?: string getKeybindings?: () => KeybindingOverrides | undefined onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void /** Marks the in-place recovery reload so did-finish-load's PTY orphan sweep spares live sessions until restore re-attaches (#5787). */ onBeforeRecoveryReload?: (webContentsId: number) => void } export function loadMainWindow(mainWindow: BrowserWindow): void { if (is.dev && process.env.ELECTRON_RENDERER_URL) { void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) } else { void mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } export function createMainWindow( store: Store | null, opts?: CreateMainWindowOptions ): BrowserWindow { const rawSavedBounds = store?.getUI().windowBounds // Why: reject min-size or substantially off-screen bounds so the titlebar stays reachable after display changes. const savedBounds = rawSavedBounds && rawSavedBounds.width > MIN_WIDTH && rawSavedBounds.height > MIN_HEIGHT && rectHasVisibleAreaOnAnyDisplay(rawSavedBounds, MIN_WIDTH / 2, MIN_HEIGHT / 2) ? rawSavedBounds : undefined if (rawSavedBounds && !savedBounds) { console.warn( '[window] Discarding persisted windowBounds and falling back to defaultBounds:', rawSavedBounds ) } const savedMaximized = store?.getUI().windowMaximized ?? false // Why: on first launch fill the primary display work area so the window feels spacious without maximize(); saved bounds win later. const defaultBounds = (() => { try { const { width, height } = screen.getPrimaryDisplay().workAreaSize return { width, height } } catch { return { width: 1200, height: 800 } } })() const settings = store?.getSettings() browserManager.setDictationShortcutForwardingPredicate(() => { // Why: webview guests expose no safe transcript insertion target; let Cmd/Ctrl+E reach the page instead of dropping dictation text. return false }) const blur = settings?.windowBackgroundBlur ?? false // Why: only Windows acrylic is ever visible; macOS vibrancy+transparent sat behind our opaque background yet // forced per-frame WindowServer alpha compositing (#8482). Applies at creation only, so it needs a restart. const platformBlurOptions = blur && process.platform === 'win32' ? { backgroundMaterial: 'acrylic' as const } : {} const mainWindow = new BrowserWindow({ width: savedBounds?.width ?? defaultBounds.width, height: savedBounds?.height ?? defaultBounds.height, ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, title: opts?.title ?? 'Orca', show: false, // Why: macOS swallows the app-activating click by default, so clicking back into Orca needed a second click (Windows/Linux already deliver it). acceptFirstMouse: true, // Why: auto-hide the Windows/Linux menu bar to save a row (Alt reveals it); macOS uses the system menu bar anyway. autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', // Why: macOS 'hiddenInset' keeps native traffic lights in our custom titlebar; Windows 'hidden' removes the OS title bar so it doesn't double up. titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : process.platform === 'win32' ? 'hidden' : undefined, // Why: Linux ignores titleBarStyle 'hidden'; frame:false drops the native frame so we don't get a double title bar (renderer draws its own). ...(process.platform === 'linux' ? { frame: false } : {}), // Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts on zoom change. ...(process.platform === 'darwin' ? { trafficLightPosition: { x: TRAFFIC_LIGHT_X, y: TITLEBAR_CSS_CENTER - TRAFFIC_LIGHT_RADIUS } } : {}), icon: getAppIconPath(settings?.appIcon), ...platformBlurOptions, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: true, webviewTag: true } }) const rendererWebContentsId = mainWindow.webContents.id // Why: native paste fallback is privileged IPC; only the top-level renderer may request it. setTrustedUIRendererWebContentsId(rendererWebContentsId) if (process.platform === 'darwin') { // Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation // make wake recovery independent of the throttled viewport. mainWindow.webContents.setBackgroundThrottling(true) installMacosVisibilityRepaint(mainWindow) } // Why: a focus-preserving wake fires no focus/visibility events; relay resume so terminal wake recovery runs and force a repaint so stale compositor surfaces recover. const onSystemResume = (): void => { if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { return } forceRepaint(mainWindow) mainWindow.webContents.send('system:resumed') } powerMonitor.on('resume', onSystemResume) mainWindow.webContents.on('dom-ready', () => { const level = store?.getUI().uiZoomLevel ?? 0 mainWindow.webContents.setZoomLevel(level) // Why: native traffic lights don't scale with CSS zoom; reposition on startup to stay aligned with the zoomed titlebar. if (process.platform === 'darwin') { syncTrafficLightPosition(mainWindow, Math.pow(1.2, level)) } }) // Why: macOS+Electron 41 re-emits ready-to-show on webview-guest creation; a one-shot guard stops re-running maximize() after resize (#591). let handledInitialReadyToShow = false let initialRevealFallbackTimer: ReturnType | null = process.platform === 'win32' || process.platform === 'linux' ? setTimeout(() => { // Why: GPU/driver failures on Windows/Linux can prevent ready-to-show forever, hiding the only app window (#8421). initialRevealFallbackTimer = null revealInitialWindow() }, 10_000) : null initialRevealFallbackTimer?.unref?.() const clearInitialRevealFallbackTimer = (): void => { if (initialRevealFallbackTimer) { clearTimeout(initialRevealFallbackTimer) initialRevealFallbackTimer = null } } const revealInitialWindow = (): void => { if (mainWindow.isDestroyed()) { clearInitialRevealFallbackTimer() return } if (handledInitialReadyToShow) { return } handledInitialReadyToShow = true clearInitialRevealFallbackTimer() // Why: in E2E headless mode keep the window hidden (Playwright drives via CDP) so tests don't steal focus. const e2eConfig = getMainE2EConfig() if (e2eConfig.headless) { return } if (savedMaximized) { mainWindow.maximize() } mainWindow.show() } mainWindow.on('ready-to-show', revealInitialWindow) // Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags. let boundsTimer: ReturnType | null = null // Why: teardown still emits resize/move/unmaximize at near-min bounds; freeze persistence once closing so they can't clobber the saved size. let windowClosing = false const saveBounds = (): void => { if (boundsTimer) { clearTimeout(boundsTimer) } boundsTimer = setTimeout(() => { boundsTimer = null if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) { return } // Why: persist windowMaximized and windowBounds atomically; the near-min guard must not leave them a mismatched pair. const isMaximized = mainWindow.isMaximized() if (isMaximized) { store?.updateUI({ windowMaximized: true }) return } const bounds = mainWindow.getBounds() // Why: never persist shrink-to-min bounds (teardown race past the freeze, PR #1269); fall back to defaultBounds next launch. if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) { console.warn('[window] Skipping persist of near-minimum windowBounds:', bounds) store?.updateUI({ windowMaximized: false }) return } store?.updateUI({ windowMaximized: false, windowBounds: bounds }) }, 500) } mainWindow.on('resize', saveBounds) mainWindow.on('move', saveBounds) // Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown. const freezeBoundsOnQuit = (): void => { windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) boundsTimer = null } } app.on('before-quit', freezeBoundsOnQuit) mainWindow.on('maximize', () => { if (windowClosing) { return } store?.updateUI({ windowMaximized: true }) mainWindow.webContents.send('window:maximize-changed', true) }) mainWindow.on('unmaximize', () => { if (windowClosing) { return } mainWindow.webContents.send('window:maximize-changed', false) const bounds = mainWindow.getBounds() // Why: mirror the saveBounds guard — unmaximize during teardown can land at min size; don't persist that as remembered size. if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) { console.warn('[window] Skipping unmaximize-time persist of near-min bounds:', bounds) store?.updateUI({ windowMaximized: false }) return } store?.updateUI({ windowMaximized: false, windowBounds: bounds }) }) mainWindow.on('enter-full-screen', () => { mainWindow.webContents.send('window:fullscreen-changed', true) }) mainWindow.on('leave-full-screen', () => { mainWindow.webContents.send('window:fullscreen-changed', false) }) installPrivilegedWindowNavigationPolicy(mainWindow.webContents) // Why: containment must be listening before any plugin panel frame is created, // so register it with the window's other navigation policy. registerPluginPanelNavigationGuard(mainWindow.webContents) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' const normalizedSrc = normalizeBrowserNavigationUrl(src) const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : '' // Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest. if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) { event.preventDefault() return } delete webPreferences.preload // Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge. delete (webPreferences as Record).preloadURL webPreferences.nodeIntegration = false webPreferences.nodeIntegrationInSubFrames = false webPreferences.enableBlinkFeatures = '' webPreferences.disableBlinkFeatures = '' webPreferences.webSecurity = true webPreferences.allowRunningInsecureContent = false webPreferences.contextIsolation = true webPreferences.sandbox = true // Why: force the browser guest policy even if host markup omits or misspells a preference. Object.assign(webPreferences, ORCA_BROWSER_GUEST_WEB_PREFERENCES) // Why: keep the registry-validated partition so isolated session profiles use their own storage while other hardening stays intact. webPreferences.partition = partition }) mainWindow.webContents.on('did-attach-webview', (_event, guest) => { // Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it. browserManager.attachGuestPolicies(guest) }) // Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md). let markdownEditorFocused = false let terminalInputFocused = false // floatingTerminalInputFocused: textarea-only (terminal keybinding context). floatingPanelFocused: superset for routing ownership. let floatingTerminalInputFocused = false let floatingPanelFocused = false let shortcutRecorderFocused = false const markdownFocusChannel = 'ui:setMarkdownEditorFocused' // Why: strict-bool + sender check so a guest/webview or malformed IPC payload can't disable the Cmd+B sidebar carve-out. const onMarkdownEditorFocused = (event: Electron.IpcMainEvent, focused: unknown): void => { if (event.sender !== mainWindow.webContents) { return } markdownEditorFocused = focused === true } ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused) const terminalInputFocusChannel = 'ui:setTerminalInputFocused' // Why: before-input-event resolves shortcuts before renderer keydown; mirror xterm focus so Terminal-first lets shells own app chords. const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => { if (event.sender !== mainWindow.webContents) { return } terminalInputFocused = focused === true } ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused) const floatingFocusChannel = 'ui:setFloatingFocus' // Why: one atomic payload for both bits so before-input-event never reads a torn terminal=true/panel=false state. // terminalFocused drives the Ctrl+B/L terminal-context carve-out; panelFocused is the routing-ownership superset (panel ⊇ terminal). const onFloatingFocus = (event: Electron.IpcMainEvent, state: unknown): void => { if (event.sender !== mainWindow.webContents) { return } const payload = (state ?? {}) as { panelFocused?: unknown; terminalFocused?: unknown } const terminal = payload.terminalFocused === true floatingTerminalInputFocused = terminal // Re-assert the invariant defensively in case a sender ever emits panel=false with terminal=true. floatingPanelFocused = payload.panelFocused === true || terminal } ipcMain.on(floatingFocusChannel, onFloatingFocus) const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused' // Why: the Settings recorder must receive app shortcuts to rebind them; before-input-event would otherwise consume the key first. const onShortcutRecorderFocused = (event: Electron.IpcMainEvent, focused: unknown): void => { if (event.sender !== mainWindow.webContents) { return } shortcutRecorderFocused = focused === true } ipcMain.on(shortcutRecorderFocusChannel, onShortcutRecorderFocused) const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { const template = buildEditableContextMenuTemplate(params, mainWindow.webContents) if (template.length === 0) { return } // Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused. Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y }) } mainWindow.webContents.on('context-menu', onMainContextMenu) // Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle. const resetMarkdownEditorFocus = (): void => { markdownEditorFocused = false } const resetTerminalInputFocus = (): void => { terminalInputFocused = false } const resetFloatingTerminalInputFocus = (): void => { floatingTerminalInputFocused = false floatingPanelFocused = false } const resetShortcutRecorderFocus = (): void => { shortcutRecorderFocused = false } let rendererProcessGone = false let rendererRecoveryTimer: ReturnType | null = null // Why: stop a deterministic per-load renderer fault from auto-reloading forever; breaker opens after too many recoveries in a rolling window. const rendererRecoveryCircuitBreaker = new RendererRecoveryCircuitBreaker({ windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer) rendererRecoveryTimer = null } } const scheduleRendererRecovery = (details: Electron.RenderProcessGoneDetails): void => { if ( rendererRecoveryTimer || !details || !isCrashReportReason(details.reason) || windowClosing || opts?.getIsQuitting?.() || opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false || mainWindow.isDestroyed() ) { return } rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = null if ( windowClosing || opts?.getIsQuitting?.() || opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false || mainWindow.isDestroyed() ) { return } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. opts?.onRendererRecoveryExhausted?.({ details, webContentsId: rendererWebContentsId, recentRecoveryCount: recovery.recentRecoveryCount }) return } // Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover. // Why: mark this in-place reload so the did-finish-load orphan sweep spares live PTYs until session restore (#5787). opts?.onBeforeRecoveryReload?.(mainWindow.webContents.id) loadMainWindow(mainWindow) }, 250) } mainWindow.webContents.on('render-process-gone', (_event, details) => { rendererProcessGone = true resetMarkdownEditorFocus() resetTerminalInputFocus() resetFloatingTerminalInputFocus() resetShortcutRecorderFocus() // Why: macOS reports BrowserWindow teardown as renderer killed/SIGKILL after close — window noise, not a crash. if (!windowClosing) { // Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable. opts?.onRendererProcessGone?.(details, rendererWebContentsId) } if (!windowClosing) { console.error('[window] Renderer process gone; close confirmation will be bypassed', details) } scheduleRendererRecovery(details) }) mainWindow.webContents.on('destroyed', () => { resetMarkdownEditorFocus() resetTerminalInputFocus() resetFloatingTerminalInputFocus() resetShortcutRecorderFocus() }) mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => { if (isMainFrame) { resetMarkdownEditorFocus() resetTerminalInputFocus() resetFloatingTerminalInputFocus() resetShortcutRecorderFocus() } }) mainWindow.webContents.on('did-finish-load', () => { rendererProcessGone = false clearRendererRecoveryTimer() }) const doubleTapDetector = new ModifierDoubleTapDetector() // Why: one mapping of action → IPC/side effect, shared by the keydown and double-tap paths so they can't drift. const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => { switch (action.type) { // The renderer's DictationController re-checks enabled/sttModel and ignores hold mode, so this path needs no voice guards. case 'dictationKeyDown': mainWindow.webContents.send('ui:dictationKeyDown') return case 'zoom': mainWindow.webContents.send('terminal:zoom', action.direction) return case 'openSettings': mainWindow.webContents.send('ui:openSettings') return case 'forceReload': opts?.onBeforeReload?.({ ignoreCache: true, webContentsId: mainWindow.webContents.id }) mainWindow.webContents.reloadIgnoringCache() return case 'toggleLeftSidebar': mainWindow.webContents.send('ui:toggleLeftSidebar') return case 'toggleRightSidebar': mainWindow.webContents.send('ui:toggleRightSidebar') return case 'toggleWorktreePalette': mainWindow.webContents.send('ui:toggleWorktreePalette') return case 'toggleFloatingTerminal': mainWindow.webContents.send('ui:toggleFloatingTerminal') return case 'openQuickOpen': mainWindow.webContents.send('ui:openQuickOpen') return case 'toggleQuickCommandsMenu': mainWindow.webContents.send('ui:toggleQuickCommandsMenu') return case 'openNewWorkspace': mainWindow.webContents.send('ui:openNewWorkspace') return case 'deleteCurrentWorkspace': mainWindow.webContents.send('ui:deleteCurrentWorkspace') return case 'openWorkspaceBoard': mainWindow.webContents.send('ui:openWorkspaceBoard') return case 'openTasks': mainWindow.webContents.send('ui:openTasks') return case 'switchRecentTab': mainWindow.webContents.send('ui:switchRecentTab') return case 'jumpToWorktreeIndex': mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index) return case 'jumpToTabIndex': mainWindow.webContents.send('ui:jumpToTabIndex', action.index) return case 'worktreeHistoryNavigate': mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction) } } const dispatchResolvedWindowShortcutAction = ( event: Electron.Event, action: WindowShortcutAction, options: { isAutoRepeat: boolean focusedShortcutContext: KeybindingMatchOptions } ): boolean => { const { focusedShortcutContext, isAutoRepeat } = options if ( floatingTerminalInputFocused && (action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar') ) { return false } // While the floating panel owns the keyboard, yield indexed switch chords to the renderer // so L2 selects a floating tab instead of switching the main workspace behind the panel. if ( floatingPanelFocused && (action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex') ) { if (isAutoRepeat) { // Contain held-key repeats in main — both renderer index paths skip e.repeat, so yielding a repeat would leak a raw key to xterm/DOM. event.preventDefault() return true } return false } const capturedTerminalActionId = focusedShortcutContext.context === 'terminal' && focusedShortcutContext.terminalShortcutPolicy === 'orca-first' && windowShortcutActionCapturesTerminal(action) ? getWindowShortcutActionId(action) : null // Why: hold-mode dictation needs renderer keyup events, so main only consumes single-keydown dictation toggles. if (action.type === 'dictationKeyDown') { const voiceSettings = store?.getSettings().voice if (!voiceSettings?.enabled || !voiceSettings.sttModel) { return false } const dictationMode = voiceSettings.dictationMode ?? 'toggle' if (dictationMode === 'hold') { return false } if (isAutoRepeat) { event.preventDefault() return true } event.preventDefault() if (capturedTerminalActionId) { mainWindow.webContents.send('ui:terminalShortcutCaptured', { actionId: capturedTerminalActionId }) } mainWindow.webContents.send('ui:dictationKeyDown') return true } if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) { event.preventDefault() return true } event.preventDefault() if (capturedTerminalActionId) { mainWindow.webContents.send('ui:terminalShortcutCaptured', { actionId: capturedTerminalActionId }) } sendResolvedWindowShortcutAction(action) return true } mainWindow.webContents.on('before-input-event', (event, input) => { if (shortcutRecorderFocused) { return } if (input.type === 'keyDown' && is.dev && input.code === 'F12') { event.preventDefault() if (mainWindow.webContents.isDevToolsOpened()) { mainWindow.webContents.closeDevTools() } else { mainWindow.webContents.openDevTools({ mode: 'undocked' }) } return } if (isMacAppPasteInput(input)) { // Why: chat/terminal panes hold focus without native editable controls, so route Cmd+V through Orca's paste ownership. event.preventDefault() mainWindow.webContents.send('ui:appMenuPaste') return } const keybindings = opts?.getKeybindings?.() const terminalShortcutContext: KeybindingMatchOptions = { context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app', terminalShortcutPolicy: normalizeTerminalShortcutPolicy( store?.getSettings().terminalShortcutPolicy ) } const appShortcutContext: KeybindingMatchOptions = { context: 'app', terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy } // Why: bare modifiers emit no terminal bytes, so double-tap detection on the raw key stream never steals readline input. if (input.type === 'keyDown' || input.type === 'keyUp') { const detected = doubleTapDetector.process( toModifierDoubleTapEvent({ type: input.type, code: input.code, key: input.key, shift: input.shift, control: input.control, alt: input.alt, meta: input.meta, isAutoRepeat: input.isAutoRepeat }), Date.now() ) if (detected) { const doubleTapAction = resolveWindowShortcutAction( { type: 'keyDown', doubleTapModifier: detected.modifier }, process.platform, keybindings, appShortcutContext ) if ( doubleTapAction && dispatchResolvedWindowShortcutAction(event, doubleTapAction, { isAutoRepeat: false, focusedShortcutContext: terminalShortcutContext }) ) { // preventDefault only the emitting keydown so the renderer detector can't also fire for the same gesture. return } // No allowlisted action: let the keydown reach the renderer, whose detector completes and dispatches inline. } } if ( input.type === 'keyDown' && matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext) ) { // Why: the held switcher commits on modifier keyup; preventing the keydown here can suppress the keyup and strand the overlay. return } // Why: TipTap owns bare Cmd/Ctrl+B for bold in the markdown editor; skip interception for the bare chord only. // See docs/markdown-cmd-b-bold-design.md. const modForBold = process.platform === 'darwin' ? input.meta : input.control if ( markdownEditorFocused && input.code === 'KeyB' && !input.alt && !input.shift && modForBold ) { return } // Why: keep interception an explicit allowlist so readline control chords reach the PTY instead of being silently stolen. const action = resolveWindowShortcutAction( input, process.platform, keybindings, terminalShortcutContext ) if (!action) { return } if (input.type !== 'keyDown') { return } dispatchResolvedWindowShortcutAction(event, action, { isAutoRepeat: Boolean(input.isAutoRepeat), focusedShortcutContext: terminalShortcutContext }) }) // Why: mid-gesture focus loss must not leave the detector armed, or the next modifier press completes a phantom double-tap. mainWindow.on('blur', () => doubleTapDetector.reset()) mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => { // Why: some layouts fire Electron's zoom command without before-input-event; honor it only while the zoom action is still bound. if (zoomDirection !== 'in' && zoomDirection !== 'out') { return } if ( !nativeZoomCommandMatchesKeybindings( zoomDirection, process.platform, opts?.getKeybindings?.(), { context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app', terminalShortcutPolicy: normalizeTerminalShortcutPolicy( store?.getSettings().terminalShortcutPolicy ) } ) ) { return } event.preventDefault() mainWindow.webContents.send('terminal:zoom', zoomDirection) }) // Intercept close so the renderer can confirm killing running-process terminals (replies window:confirm-close to proceed). let windowCloseConfirmed = false const confirmCloseChannel = 'window:confirm-close' const closeRequestReceivedChannel = 'window:close-request-received' let closeRequestSequence = 0 let quitRendererAckRequestId: number | null = null let quitRendererAckTimer: ReturnType | null = null const clearQuitRendererAckTimer = (): void => { quitRendererAckRequestId = null if (quitRendererAckTimer) { clearTimeout(quitRendererAckTimer) quitRendererAckTimer = null } } const armQuitRendererAckTimer = (requestId: number): void => { quitRendererAckRequestId = requestId if (quitRendererAckTimer) { return } // Why: will-quit cannot run until the renderer-backed window closes; an // already-frozen renderer otherwise makes Force Quit the only escape. quitRendererAckTimer = setTimeout(() => { quitRendererAckTimer = null quitRendererAckRequestId = null if (mainWindow.isDestroyed()) { return } console.warn('[window] Renderer did not acknowledge quit; destroying unresponsive window') freezeBoundsOnQuit() mainWindow.destroy() }, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS) quitRendererAckTimer.unref?.() } const onCloseRequestReceived = (event: Electron.IpcMainEvent, requestId: number): void => { if (event.sender.id === rendererWebContentsId && requestId === quitRendererAckRequestId) { clearQuitRendererAckTimer() } } // Windows minimize-to-tray: hide instead of close when enabled; returns true when it hid so callers skip their close path. const hideToTrayIfEnabled = (): boolean => { const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false if ( process.platform !== 'win32' || rendererProcessGone || isRendererCrashed || opts?.getIsQuitting?.() === true || store?.getSettings().minimizeToTrayOnClose !== true ) { return false } mainWindow.hide() // Why: notify once that closing only hid the window; the persisted flag stops it repeating on every later minimize. if (store.getUI().trayMinimizeNoticeShown !== true) { try { new Notification({ title: 'Orca', body: translateMain( 'tray.minimizeNotice.body', 'Orca is still running in the system tray' ) }).show() } catch { // Notification is best-effort — never block hiding the window. } store.updateUI({ trayMinimizeNoticeShown: true }) } return true } mainWindow.on('close', (e) => { // Why: Alt+F4/programmatic closes hit the native event; apply the same minimize-to-tray guard the renderer-drawn X uses. if (!windowCloseConfirmed && hideToTrayIfEnabled()) { e.preventDefault() return } const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false // Why: only a gone/crashed renderer (can't answer) may bypass close confirmation; a hung-but-alive one still must (#5787). const closeAction = resolveWindowCloseAction({ windowCloseConfirmed, rendererProcessGone, isRendererCrashed }) if (closeAction !== 'request-confirmation') { // allow-confirmed: renderer already replied and re-entered close(). // bypass-gone: a gone renderer can't answer window:close-requested, so let OS close complete rather than trap a blank window. if (closeAction === 'allow-confirmed') { windowCloseConfirmed = false } // Why: window teardown emits resize/move/unmaximize; freeze bounds persistence so they can't clobber saved size (v1.3.26-rc2). windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) boundsTimer = null } return } e.preventDefault() const isQuitting = opts?.getIsQuitting?.() ?? false const requestId = ++closeRequestSequence if (isQuitting) { armQuitRendererAckTimer(requestId) } // Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144). mainWindow.webContents.send('window:close-requested', { isQuitting, requestId }) }) mainWindow.webContents.on('will-prevent-unload', () => { // Why: a prevented beforeunload cancels the quit; release the bounds-persistence freeze so later resizing still saves. windowClosing = false clearQuitRendererAckTimer() opts?.onQuitAborted?.() mainWindow.webContents.send('window:unload-prevented') }) const onConfirmClose = (): void => { clearQuitRendererAckTimer() windowCloseConfirmed = true if (!mainWindow.isDestroyed()) { mainWindow.close() } } const trafficLightChannel = 'ui:sync-traffic-lights' const onSyncTrafficLights = (_event: Electron.IpcMainEvent, zoomFactor: number): void => { syncTrafficLightPosition(mainWindow, zoomFactor) } ipcMain.on(trafficLightChannel, onSyncTrafficLights) // Why: renderer-drawn window controls on Windows/Linux replicate the native title-bar buttons hidden by custom chrome. const minimizeChannel = 'window:minimize' const onMinimize = (): void => { if (!mainWindow.isDestroyed()) { mainWindow.minimize() } } const maximizeChannel = 'window:maximize' const onMaximize = (): void => { if (mainWindow.isDestroyed()) { return } if (mainWindow.isMaximized()) { mainWindow.unmaximize() } else { mainWindow.maximize() } } // Why: mainWindow.close() from an IPC handler on Windows can make 'close' misfire, so send window:close-requested directly. const requestCloseChannel = 'window:request-close' const onRequestClose = (): void => { if (mainWindow.isDestroyed()) { return } // Why: renderer-drawn X routes here (not the native close event), so the minimize-to-tray guard must also run here. if (hideToTrayIfEnabled()) { return } mainWindow.webContents.send('window:close-requested', { isQuitting: false }) } // Why: renderer-drawn title-bar ··· menu button replicates the Alt-key reveal autoHideMenuBar provides (Windows/Linux). const popupMenuChannel = 'menu:popup' const onPopupMenu = (): void => { Menu.getApplicationMenu()?.popup({ window: mainWindow }) } // Why: WindowControls mounts after window:maximize-changed already fired, so expose a synchronous getter to init its icon. const isMaximizedChannel = 'window:isMaximized' const onIsMaximized = (): boolean => { return !mainWindow.isDestroyed() && mainWindow.isMaximized() } ipcMain.on(minimizeChannel, onMinimize) ipcMain.on(maximizeChannel, onMaximize) ipcMain.on(requestCloseChannel, onRequestClose) ipcMain.on(popupMenuChannel, onPopupMenu) ipcMain.handle(isMaximizedChannel, onIsMaximized) ipcMain.on(confirmCloseChannel, onConfirmClose) ipcMain.on(closeRequestReceivedChannel, onCloseRequestReceived) mainWindow.on('closed', () => { // Why: the dashboard pop-out is a companion of the main window — close it // alongside so it never orphans as a lone window after the app window is // gone (e.g. on macOS where the app stays alive after the window closes). closeDashboardPopout() clearInitialRevealFallbackTimer() clearQuitRendererAckTimer() // Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state. markdownEditorFocused = false terminalInputFocused = false floatingTerminalInputFocused = false floatingPanelFocused = false shortcutRecorderFocused = false clearRendererRecoveryTimer() ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) ipcMain.removeListener(minimizeChannel, onMinimize) ipcMain.removeListener(maximizeChannel, onMaximize) browserManager.setDictationShortcutForwardingPredicate(null) ipcMain.removeListener(requestCloseChannel, onRequestClose) ipcMain.removeListener(popupMenuChannel, onPopupMenu) ipcMain.removeHandler(isMaximizedChannel) ipcMain.removeListener(confirmCloseChannel, onConfirmClose) ipcMain.removeListener(closeRequestReceivedChannel, onCloseRequestReceived) ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingFocusChannel, onFloatingFocus) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) // Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents. powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater shutdown 'closed' can fire after webContents is destroyed, so don't touch mainWindow.webContents here. app.removeListener('before-quit', freezeBoundsOnQuit) }) if (!opts?.deferLoad) { loadMainWindow(mainWindow) } return mainWindow }