orca/src/main/window/createMainWindow.ts

1267 lines
51 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,
shell
} 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,
normalizeExternalBrowserUrl
} 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'
// Why: show/restore/resume can overlap before the size nudge resets; never
// capture the temporary width as the next repaint's baseline.
const activeRepaintJiggles = new WeakSet<BrowserWindow>()
function forceRepaint(window: BrowserWindow): void {
// Why: webContents can be destroyed a beat before the BrowserWindow during
// close, and this runs from timers/focus events that can land in that gap.
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
}
window.webContents.invalidate()
if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
return
}
activeRepaintJiggles.add(window)
const [width, height] = window.getSize()
window.setSize(width + 1, height)
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
}
activeRepaintJiggles.delete(window)
}, 32)
}
function installMacosVisibilityRepaint(window: BrowserWindow): void {
let delayedRepaintTimer: ReturnType<typeof setTimeout> | null = null
const repaintAfterVisibilityTransition = (): void => {
forceRepaint(window)
if (delayedRepaintTimer) {
clearTimeout(delayedRepaintTimer)
}
// Why: macOS can finish restoring webview compositor layers after Electron's
// show/restore event, so a second paint catches late black-surface recovery.
delayedRepaintTimer = setTimeout(() => {
delayedRepaintTimer = null
forceRepaint(window)
}, 250)
}
const clearDelayedRepaint = (): void => {
if (delayedRepaintTimer) {
clearTimeout(delayedRepaintTimer)
delayedRepaintTimer = null
}
}
window.on('restore', repaintAfterVisibilityTransition)
window.on('show', repaintAfterVisibilityTransition)
// Why: occlusion-uncover fires neither restore nor show; focus is the only
// signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal
// on each Cmd+Tab.
window.on('focus', () => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.invalidate()
}
})
window.on('closed', clearDelayedRepaint)
}
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: the titlebar is 36px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~18 CSS px from the top.
// At zoom factor z that becomes 18·z window px. Traffic lights are
// ~12px tall, so we position their top edge at (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. The close
* handler sends this to the renderer so it can skip the running-process
* confirmation dialog and proceed directly to buffer capture + close. */
getIsQuitting?: () => boolean
/** Notifies the caller when the renderer vetoes unload. Why: a prevented
* beforeunload cancels the in-flight app.quit(), so the app-level quit
* latch must be cleared or later window closes will be misclassified as
* quit attempts. */
onQuitAborted?: () => void
onRendererProcessGone?: (
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => void
/** Returns true when Orca should reload after an unexpected renderer loss.
* Why: update relaunch and app quit intentionally tear down child
* processes; recovering those paths can fight Electron's shutdown. */
shouldRecoverRenderer?: (
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => boolean
/** Called when consecutive auto-recoveries hit the circuit-breaker limit, so
* the host can record diagnostics and surface a recovery prompt instead of
* letting Orca crash-loop. */
onRendererRecoveryExhausted?: (info: {
details: Electron.RenderProcessGoneDetails
webContentsId: number
recentRecoveryCount: number
}) => void
/** Why: main-process startup must register IPC handlers before the renderer
* begins booting, or eager renderer calls can race into missing channels. */
deferLoad?: boolean
title?: string
getKeybindings?: () => KeybindingOverrides | undefined
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
/** Why: the in-place renderer-recovery reload re-fires did-finish-load, whose
* local-PTY orphan sweep would kill live sessions across the single window
* before session restore re-attaches them (#5787). This callback lets the host
* mark that one reload so the sweep can be skipped for it. */
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: defense in depth — if a previous quit/update path persisted
// shrink-to-min bounds (see freezeBoundsOnQuit), discard them on restore
// rather than resurrecting a tiny window. Anything at or below the min
// dimensions is treated as corrupt and falls back to defaultBounds. The
// position must also land on a currently-attached display with a
// *meaningful* visible area — not just any >0 overlap, since a 1-pixel
// sliver (or a sub-pixel shaving after DPI scaling) would still leave
// the titlebar unreachable. Require at least MIN_WIDTH/2 of horizontal
// and MIN_HEIGHT/2 of vertical overlap with some display's workArea
// (workArea excludes menu bar / dock, so a rect entirely hidden under
// the dock is also correctly discarded). A rect saved while an external
// monitor was connected would otherwise be restored off-screen and
// macOS would silently shrink/reposition the window.
const rectHasVisibleAreaOnAnyDisplay = (b: {
x: number
y: number
width: number
height: number
}): boolean => {
try {
return screen.getAllDisplays().some((d) => {
const wa = d.workArea
const overlapX = Math.max(0, Math.min(b.x + b.width, wa.x + wa.width) - Math.max(b.x, wa.x))
const overlapY = Math.max(
0,
Math.min(b.y + b.height, wa.y + wa.height) - Math.max(b.y, wa.y)
)
return overlapX >= MIN_WIDTH / 2 && overlapY >= MIN_HEIGHT / 2
})
} catch (err) {
console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err)
return false
}
}
const savedBounds =
rawSavedBounds &&
rawSavedBounds.width > MIN_WIDTH &&
rawSavedBounds.height > MIN_HEIGHT &&
rectHasVisibleAreaOnAnyDisplay(rawSavedBounds)
? 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 (no saved bounds), fill the primary display work area
// so the window feels spacious without calling maximize(). Saved bounds still
// win on subsequent launches.
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: focused webview guests do not expose a safe transcript insertion
// target yet. Let Cmd/Ctrl+E continue to the page instead of starting a
// dictation session whose final text would be dropped.
return false
})
const blur = settings?.windowBackgroundBlur ?? false
// Why: native blur requires platform-specific Electron APIs. macOS uses
// vibrancy (needs transparent: true), Windows uses backgroundMaterial.
// Linux has no native equivalent. Blur only applies at window creation;
// changing the setting requires a restart.
const platformBlurOptions = blur
? process.platform === 'darwin'
? { vibrancy: 'under-window' as const, transparent: true }
: 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 (e.g. the floating workspace) needed a second click.
// macOS-only option; Windows/Linux already deliver that click.
acceptFirstMouse: true,
// Why: on macOS the menu lives in the system menu bar, so the in-window
// menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar
// doesn't consume a dedicated row of vertical space on every launch —
// users can still reveal the (properly restructured) File/Edit/View/
// Window/Help menus by pressing Alt, matching native Windows/Linux
// conventions (File Explorer, Firefox, etc.).
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
// Why: on macOS 'hiddenInset' keeps the native traffic lights positioned
// inside our custom 42px titlebar. On Windows 'hidden' removes the default
// OS title bar (which would otherwise stack on top of our renderer titlebar
// and waste vertical space) while still allowing our renderer to draw its
// own drag region and window controls.
titleBarStyle:
process.platform === 'darwin'
? 'hiddenInset'
: process.platform === 'win32'
? 'hidden'
: undefined,
// Why: Linux ignores titleBarStyle: 'hidden', so without this the native
// WM title bar stays and stacks on top of our renderer titlebar (double
// title bar). frame: false drops the native frame; the renderer draws its
// own titlebar + window controls (see WindowControls in App.tsx), matching
// the Windows custom-titlebar path.
...(process.platform === 'linux' ? { frame: false } : {}),
// Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts
// dynamically when the user changes UI zoom.
...(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 real top-level
// renderer should be allowed to request Electron's native paste operation.
setTrustedUIRendererWebContentsId(rendererWebContentsId)
if (process.platform === 'darwin') {
// Why: browser-guest surfaces are kept alive by their own per-guest
// unthrottle (browser-manager attach), so the main window can throttle
// normally while hidden/occluded instead of rendering at full rate.
// Toggling must happen only while visible: flipping it on a hidden window
// desyncs Chromium's frame evictor and blanks the surface (electron#42378).
mainWindow.webContents.setBackgroundThrottling(true)
installMacosVisibilityRepaint(mainWindow)
}
// Why: a focus-preserving system/display wake fires no window focus or
// visibility events in the renderer, so terminal wake recovery would never
// run. Relay powerMonitor resume explicitly (supported on mac/win/linux)
// and force a repaint so stale compositor surfaces recover too.
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: the native traffic lights sit at a fixed position in the window
// while CSS content scales with zoom. We must reposition the buttons
// on startup so they stay vertically aligned with the zoomed titlebar.
if (process.platform === 'darwin') {
syncTrafficLightPosition(mainWindow, Math.pow(1.2, level))
}
})
// Why: on macOS + Electron 41, creating a webview guest process can re-emit
// ready-to-show on the same BrowserWindow. Without a one-shot guard the
// handler re-runs maximize() from the persisted savedMaximized flag, snapping
// the window back to full-screen after the user already resized it (#591).
let handledInitialReadyToShow = false
let initialRevealFallbackTimer: ReturnType<typeof setTimeout> | null =
process.platform === 'win32' || process.platform === 'linux'
? setTimeout(() => {
// Why: GPU/driver failures on Windows and Linux/X11 can prevent
// ready-to-show forever, leaving the only app window hidden while the
// main process stays alive (#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, the window stays hidden to avoid stealing
// focus and screen real estate during test runs. Playwright interacts
// with the renderer via CDP, which works without a visible window.
const e2eConfig = getMainE2EConfig()
if (e2eConfig.headless) {
return
}
if (savedMaximized) {
mainWindow.maximize()
}
mainWindow.show()
}
mainWindow.on('ready-to-show', revealInitialWindow)
// Why: persist window bounds so the app restores to the user's last
// position/size instead of maximizing on every launch. Debounce to avoid
// hammering the persistence layer during continuous resize drags.
let boundsTimer: ReturnType<typeof setTimeout> | null = null
// Why: once close has been initiated (user Cmd+Q, auto-updater relaunch,
// app.quit during quitAndInstall), Electron can still emit resize/move/
// unmaximize events while the OS tears the window down — persisting those
// intermediate, often near-minimum bounds would clobber the user's real
// last-used size and cause the next launch (especially post-update
// relaunch) to come up at minWidth × minHeight. Freeze persistence as soon
// as 'close' is observed.
let windowClosing = false
const saveBounds = (): void => {
if (boundsTimer) {
clearTimeout(boundsTimer)
}
boundsTimer = setTimeout(() => {
boundsTimer = null
if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) {
return
}
// Why: windowMaximized and windowBounds must be sampled and persisted
// atomically — writing windowMaximized first and then deciding whether
// to write bounds can leave the store with `windowMaximized: false`
// paired with stale/absent windowBounds if the near-min guard trips,
// which violates the pairing invariant subsequent launches rely on.
const isMaximized = mainWindow.isMaximized()
if (isMaximized) {
store?.updateUI({ windowMaximized: true })
return
}
const bounds = mainWindow.getBounds()
// Why: never persist shrink-to-min bounds. The user cannot want these
// saved — the window hit the enforced minimum, so either the teardown
// race from PR #1269 slipped past the freeze (e.g. dev-mode Ctrl+C
// where will-prevent-unload re-opens the freeze), or a transient
// OS resize fired. Dropping the bounds write here makes the next
// launch fall back to defaultBounds instead of resurrecting a tiny
// window. We still record windowMaximized: false so subsequent
// launches don't incorrectly restore maximized state.
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 install path calls
// `win.removeAllListeners('close')` before quitting, so the per-window
// 'close' handler below never runs for update-triggered relaunches.
// Listen to app-level 'before-quit' as a second latch so resize/move
// events emitted during window teardown don't persist shrink-to-min
// bounds that would be restored on next launch.
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_WIDTH × MIN_HEIGHT and we must not persist those as the user's
// 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)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
const externalUrl = normalizeExternalBrowserUrl(details.url)
if (externalUrl) {
shell.openExternal(externalUrl)
}
return { action: 'deny' }
})
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: arbitrary sites must stay inside an unprivileged guest surface. We
// fail closed here so a renderer bug cannot smuggle preload, Node, or a
// non-browser partition into the guest and widen the app privilege boundary.
// The one allowed data URL is Orca's inert blank-tab bootstrap page; deny
// every other data URL so the renderer cannot inject arbitrary inline HTML.
// Why: session profiles use per-profile partitions (e.g.
// persist:orca-browser-session-<uuid>). The registry is the sole authority
// for which partitions are valid — renderer-provided strings that are not
// in the allowlist are rejected.
if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) {
event.preventDefault()
return
}
delete webPreferences.preload
// Why: older Electron builds expose preloadURL alongside preload; delete
// both so the guest surface cannot 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: keep renderer-created webviews aligned with the browser guest policy
// even if the host markup omits or misspells a preference.
Object.assign(webPreferences, ORCA_BROWSER_GUEST_WEB_PREFERENCES)
// Why: preserve the registry-validated partition instead of forcing the
// legacy constant. This lets imported/isolated session profiles use their
// own cookie/storage partition while keeping all other hardening intact.
webPreferences.partition = partition
})
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
// Why: popup and navigation policy must attach as soon as Chromium creates
// the guest webContents. Waiting until renderer-driven registration leaves
// a race where target=_blank or early redirects can bypass Orca's intended
// fallback behavior.
browserManager.attachGuestPolicies(guest)
})
// Block ALL in-window navigations to prevent remote pages from inheriting
// the privileged preload bridge (PTY, filesystem, etc.).
// In dev mode, allow navigations to the local dev server (e.g. HMR reloads).
mainWindow.webContents.on('will-navigate', (event, url) => {
const externalUrl = normalizeExternalBrowserUrl(url)
if (externalUrl) {
const target = new URL(externalUrl)
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
try {
const allowed = new URL(process.env.ELECTRON_RENDERER_URL)
if (target.origin === allowed.origin) {
return // allow dev server navigations (HMR, etc.)
}
} catch {
// fall through to prevent
}
}
shell.openExternal(externalUrl)
}
event.preventDefault()
})
// Why: mirrors the renderer's markdown-editor focus state so the main-process
// before-input-event handler can skip Cmd/Ctrl+B interception while TipTap
// owns focus. See docs/markdown-cmd-b-bold-design.md. We only carve out
// Cmd+B so browser guests and other editable surfaces keep the existing
// global shortcut behavior.
let markdownEditorFocused = false
let terminalInputFocused = false
let floatingTerminalInputFocused = false
let shortcutRecorderFocused = false
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
// Why: coerce to strict boolean and verify the sender. A renderer bug or
// compromised IPC payload must not set the flag to a truthy non-bool (e.g.
// an object) and silently disable the sidebar toggle — default-deny on any
// non-bool. Additionally, only this main window's top-level webContents may
// mutate the flag, so a guest/webview or unrelated sender 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
// regular xterm focus so Terminal-first can let shells/TUIs 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 floatingTerminalInputFocusChannel = 'ui:setFloatingTerminalInputFocused'
// Why: main before-input-event runs before renderer keydown handlers. Mirror
// floating xterm focus so Ctrl+B/L and related shell chords can reach SSH/tmux.
const onFloatingTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
}
floatingTerminalInputFocused = focused === true
}
ipcMain.on(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused'
// Why: the Settings recorder must receive existing app shortcuts so users can
// 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: right-click can produce a Chromium context-menu event before our
// renderer focus mirror updates, so trust Electron's editable/spellcheck
// params here instead of gating on markdownEditorFocused.
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
}
mainWindow.webContents.on('context-menu', onMainContextMenu)
// Why: renderer can't mirror focus state across a crash/reload/close.
// Default-deny the carve-outs so focus context from a dead renderer cannot
// disable app shortcuts in a later lifecycle state.
const resetMarkdownEditorFocus = (): void => {
markdownEditorFocused = false
}
const resetTerminalInputFocus = (): void => {
terminalInputFocused = false
}
const resetFloatingTerminalInputFocus = (): void => {
floatingTerminalInputFocused = false
}
const resetShortcutRecorderFocus = (): void => {
shortcutRecorderFocused = false
}
let rendererProcessGone = false
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null
// Why: stop a deterministic per-load renderer fault (bad GPU driver, corrupt
// chunk, AV interference) from auto-reloading every ~0.25-1.3s forever
// (Windows crash-loop clusters). The breaker opens after too many recoveries
// inside a rolling window and hands off to the host's recovery surface.
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 in the window means reloading again will just
// crash again. Stop the loop and let the host surface a recovery prompt.
opts?.onRendererRecoveryExhausted?.({
details,
webContentsId: rendererWebContentsId,
recentRecoveryCount: recovery.recentRecoveryCount
})
return
}
// Why: a transient Network Service / renderer loss can leave Chromium
// showing a blank shell. Reload the app document once so the user gets
// back to a usable window instead of needing a full relaunch.
// Why: mark this one in-place reload so the did-finish-load orphan sweep
// spares live local PTYs until session restore re-attaches them (#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 can report BrowserWindow teardown as renderer `killed`/SIGKILL
// after a confirmed close; that is window lifecycle noise, not a crash.
if (!windowClosing) {
// Why: the recorder owns crash classification and durable suppression
// diagnostics; 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 place maps a resolved window-shortcut action to its IPC/side effect,
// reused by the normal keydown path and the double-tap path so they cannot 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
}
const capturedTerminalActionId =
focusedShortcutContext.context === 'terminal' &&
focusedShortcutContext.terminalShortcutPolicy === 'orca-first' &&
windowShortcutActionCapturesTerminal(action)
? getWindowShortcutActionId(action)
: null
// Why: hold-mode dictation needs renderer keyup events, so the main process
// may only consume shortcuts that toggle dictation from a single keydown.
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: native chat/terminal panes can own focus without being native
// editable controls, so route Cmd+V through Orca's paste ownership first.
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: detect double-tap-modifier gestures on the raw key stream. A bare
// modifier emits no terminal bytes, so this 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
})
) {
// Only preventDefault the emitting keydown — never the first tap's
// down/up. This suppresses the renderer DOM keydown so the renderer
// detector cannot 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. If main prevents the
// keydown, Electron can suppress the renderer keyup and strand the overlay.
return
}
// Why: TipTap owns bare Cmd/Ctrl+B for bold while the markdown editor is
// focused — skip interception so its keymap runs. Scoped to the bare chord
// (no Shift/Alt): any extra modifier signals different intent and must
// still resolve through the policy allowlist.
// 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 the main-process interception surface as an explicit allowlist.
// Anything outside this helper must continue to the renderer/PTTY so
// readline control chords are not silently stolen above the terminal.
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: a mid-gesture focus loss must not leave the detector armed so the next
// unrelated modifier press completes a phantom double-tap.
mainWindow.on('blur', () => doubleTapDetector.reset())
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
// Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before
// before-input-event fires, but still emit Electron's zoom command. Keep
// that fallback only while the matching 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 window close so the renderer can show a confirmation dialog
// when terminals with running processes would be killed. The renderer
// replies with 'window:confirm-close' to proceed, or does nothing to cancel.
let windowCloseConfirmed = false
const confirmCloseChannel = 'window:confirm-close'
// Why: Windows minimize-to-tray. Hides the window instead of closing when the
// setting is on, this isn't a real quit (Ctrl+Q / tray "Quit" set
// getIsQuitting), and the renderer is alive. Returns true when it handled the
// close by hiding, so callers skip their normal close path. Shared by BOTH the
// renderer-drawn X (window:request-close) and the native close event (Alt+F4).
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: tell the user once that closing only hid the window; the persisted
// flag stops the notice from 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 and programmatic closes reach the native event; apply the same
// minimize-to-tray guard the renderer-drawn X uses via onRequestClose.
if (!windowCloseConfirmed && hideToTrayIfEnabled()) {
e.preventDefault()
return
}
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
// Why: a hung-but-ALIVE renderer (neither gone nor crashed) must still hit
// the renderer's save/running-process confirmation; only a genuinely gone or
// crashed renderer — which cannot answer window:close-requested — may bypass
// it. Routing this through the pure decision locks that invariant (#5787).
const closeAction = resolveWindowCloseAction({
windowCloseConfirmed,
rendererProcessGone,
isRendererCrashed
})
if (closeAction !== 'request-confirmation') {
// allow-confirmed: the renderer already replied and re-entered close().
// bypass-gone: after a native renderer crash the renderer cannot answer
// window:close-requested, so let Cmd+Q / OS close complete instead of
// trapping the user in a blank, unquittable window.
if (closeAction === 'allow-confirmed') {
windowCloseConfirmed = false
}
// Why: past this point Electron/OS may emit resize/move/unmaximize as
// the window is destroyed. Freeze bounds persistence so those
// teardown events can't clobber the user's saved window size — which
// would otherwise make the post-update relaunch come up at minWidth ×
// minHeight (issue surfaced in v1.3.26-rc2).
windowClosing = true
if (boundsTimer) {
clearTimeout(boundsTimer)
boundsTimer = null
}
return
}
e.preventDefault()
// Why: the renderer owns the close decision (dirty-file save dialogs,
// running-process confirmation). The subscription lives at the always-
// mounted App root, so even pre-workspace states reply — see #5144.
mainWindow.webContents.send('window:close-requested', {
isQuitting: opts?.getIsQuitting?.() ?? false
})
})
mainWindow.webContents.on('will-prevent-unload', () => {
// Why: a prevented beforeunload cancels the in-flight quit. Release the
// bounds-persistence freeze so a user who keeps using the window after
// aborting Cmd+Q still gets their size saved.
windowClosing = false
opts?.onQuitAborted?.()
})
const onConfirmClose = (): void => {
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 desktop send these to
// 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: send window:close-requested directly rather than calling
// mainWindow.close() and letting the 'close' event re-send it. Calling
// mainWindow.close() from within an IPC message handler on Windows can cause
// the 'close' event to misfire (e.preventDefault() doesn't suppress the OS
// close in all Windows configurations). Going straight to the renderer's
// close guard (Terminal.tsx onWindowCloseRequested) keeps the flow identical
// to what happens when confirmWindowClose() ultimately calls mainWindow.close()
// with windowCloseConfirmed = true.
const requestCloseChannel = 'window:request-close'
const onRequestClose = (): void => {
if (mainWindow.isDestroyed()) {
return
}
// Why: the renderer-drawn X on Windows routes here (not the native close
// event), so the minimize-to-tray guard must run on this path too — hide
// instead of asking the renderer to close.
if (hideToTrayIfEnabled()) {
return
}
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
}
// Why: the ··· button in the renderer-drawn title bar on Windows/Linux
// desktop pops up the application menu at the cursor position, replicating
// the Alt-key reveal that autoHideMenuBar normally provides.
const popupMenuChannel = 'menu:popup'
const onPopupMenu = (): void => {
Menu.getApplicationMenu()?.popup({ window: mainWindow })
}
// Why: the renderer's WindowControls mounts after ready-to-show, which is
// also when savedMaximized is restored — so window:maximize-changed has
// already fired (or not fired, if maximize() was called pre-mount) before
// the listener attaches. Expose a synchronous getter so the button can
// initialize its icon to match the current state on mount.
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)
mainWindow.on('closed', () => {
clearInitialRevealFallbackTimer()
// Why: default-deny the Cmd+B carve-out after the window is gone so a
// stale-true flag can't leak past subsequent state transitions. Paired
// with the webContents lifecycle resets above.
markdownEditorFocused = false
terminalInputFocused = false
floatingTerminalInputFocused = 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(markdownFocusChannel, onMarkdownEditorFocused)
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
// Why: powerMonitor is app-global; without this the closed window's
// resume relay would leak and fire against a destroyed webContents.
powerMonitor.removeListener('resume', onSystemResume)
clearTrustedUIRendererWebContentsId(rendererWebContentsId)
// Why: on updater-triggered shutdown, BrowserWindow can emit `closed`
// after its webContents has already been destroyed. The destroyed
// webContents owns its listeners, so do not touch `mainWindow.webContents`
// here or the quit path can crash before Squirrel.Mac relaunches Orca.
app.removeListener('before-quit', freezeBoundsOnQuit)
})
if (!opts?.deferLoad) {
loadMainWindow(mainWindow)
}
return mainWindow
}