Files
orca/src/main/window/createMainWindow.ts
T

1134 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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<BrowserWindow>()
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<typeof setTimeout> | 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<typeof setTimeout> | 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<typeof setTimeout> | 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<string, unknown>).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<typeof setTimeout> | 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<typeof setTimeout> | 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
}