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

454 lines
17 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 { BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import icon from '../../../resources/icon.png?asset'
import devIcon from '../../../resources/icon-dev.png?asset'
import type { Store } from '../persistence'
import { browserManager } from '../browser/browser-manager'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
} from '../../shared/browser-url'
import { resolveWindowShortcutAction } from '../../shared/window-shortcut-policy'
import { getMainE2EConfig } from '../e2e-config'
function forceRepaint(window: BrowserWindow): void {
if (window.isDestroyed()) {
return
}
window.webContents.invalidate()
if (window.isMaximized() || window.isFullScreen()) {
return
}
const [width, height] = window.getSize()
window.setSize(width + 1, height)
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
}
}, 32)
}
// Why: the titlebar is 42px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~20 CSS px from the top.
// At zoom factor z that becomes 20·z window px. Traffic lights are
// ~12px tall, so we position their top edge at (center 6).
const TITLEBAR_CSS_CENTER = 20
const TRAFFIC_LIGHT_RADIUS = 6
const TRAFFIC_LIGHT_X = 16
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
}
export function createMainWindow(
store: Store | null,
opts?: CreateMainWindowOptions
): BrowserWindow {
const savedBounds = store?.getUI().windowBounds
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 mainWindow = new BrowserWindow({
width: savedBounds?.width ?? defaultBounds.width,
height: savedBounds?.height ?? defaultBounds.height,
...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}),
minWidth: 600,
minHeight: 400,
show: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined,
// 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: is.dev ? devIcon : icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
webviewTag: true
}
})
if (process.platform === 'darwin') {
// Why: persistent parked webviews use separate compositor layers, and on
// recent macOS releases those layers can fail to repaint after occlusion or
// restore. Disabling main-window throttling and forcing a repaint on
// visibility transitions hardens Orca against the same black-surface
// failure mode seen during browser-tab restore and tab switching.
mainWindow.webContents.setBackgroundThrottling(false)
mainWindow.on('restore', () => {
forceRepaint(mainWindow)
})
mainWindow.on('show', () => {
forceRepaint(mainWindow)
})
}
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
mainWindow.on('ready-to-show', () => {
if (handledInitialReadyToShow) {
return
}
handledInitialReadyToShow = true
// 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()
})
// 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
const saveBounds = (): void => {
if (boundsTimer) {
clearTimeout(boundsTimer)
}
boundsTimer = setTimeout(() => {
boundsTimer = null
if (mainWindow.isDestroyed() || mainWindow.isFullScreen()) {
return
}
const isMaximized = mainWindow.isMaximized()
store?.updateUI({ windowMaximized: isMaximized })
if (!isMaximized) {
store?.updateUI({ windowBounds: mainWindow.getBounds() })
}
}, 500)
}
mainWindow.on('resize', saveBounds)
mainWindow.on('move', saveBounds)
mainWindow.on('maximize', () => {
store?.updateUI({ windowMaximized: true })
})
mainWindow.on('unmaximize', () => {
store?.updateUI({ windowMaximized: false, windowBounds: mainWindow.getBounds() })
})
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: 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 — terminal and browser-guest focus still get sidebar-toggle, which
// preserves the ^B-to-PTY leak protection rationale in
// shared/window-shortcut-policy.ts:74-77.
let markdownEditorFocused = 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)
// Why: renderer can't mirror focus state across a crash/reload/close.
// Default-deny the carve-out so Cmd+B falls back to sidebar-toggle, which is
// the safe behavior when focus context is unknown. Preserves the
// ^B-to-PTY leak invariant from shared/window-shortcut-policy.ts:74-77.
const resetMarkdownEditorFocus = (): void => {
markdownEditorFocused = false
}
mainWindow.webContents.on('render-process-gone', resetMarkdownEditorFocus)
mainWindow.webContents.on('destroyed', resetMarkdownEditorFocus)
mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => {
if (isMainFrame) {
resetMarkdownEditorFocus()
}
})
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') {
return
}
if (is.dev && input.code === 'F12') {
event.preventDefault()
if (mainWindow.webContents.isDevToolsOpened()) {
mainWindow.webContents.closeDevTools()
} else {
mainWindow.webContents.openDevTools({ mode: 'undocked' })
}
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. Other focus contexts
// (terminal, browser guest) still get sidebar-toggle because ^B would
// otherwise reach xterm.js / guest webContents.
// 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)
if (!action) {
return
}
event.preventDefault()
if (action.type === 'zoom') {
mainWindow.webContents.send('terminal:zoom', action.direction)
return
}
if (action.type === 'toggleLeftSidebar') {
mainWindow.webContents.send('ui:toggleLeftSidebar')
return
}
if (action.type === 'toggleRightSidebar') {
mainWindow.webContents.send('ui:toggleRightSidebar')
return
}
if (action.type === 'toggleWorktreePalette') {
// Why: embedded browser guests can keep keyboard focus inside Chromium's
// guest webContents, which bypasses the renderer's window-level keydown
// listener. Forward the worktree-switch shortcut through the main window
// so Cmd+J (macOS) or Ctrl+Shift+J (Win/Linux) works consistently from browser tabs too.
mainWindow.webContents.send('ui:toggleWorktreePalette')
return
}
if (action.type === 'openQuickOpen') {
// Forward Cmd/Ctrl+P to trigger Quick Open
mainWindow.webContents.send('ui:openQuickOpen')
return
}
if (action.type === 'jumpToWorktreeIndex') {
// Forward Cmd/Ctrl+1-9 for quick worktree switching
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
}
})
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. We
// reroute that command to terminal zoom so zoom-out remains reachable.
event.preventDefault()
if (zoomDirection === 'in') {
mainWindow.webContents.send('terminal:zoom', 'in')
} else if (zoomDirection === 'out') {
mainWindow.webContents.send('terminal:zoom', 'out')
}
})
// 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'
mainWindow.on('close', (e) => {
if (windowCloseConfirmed) {
windowCloseConfirmed = false
return
}
e.preventDefault()
mainWindow.webContents.send('window:close-requested', {
isQuitting: opts?.getIsQuitting?.() ?? false
})
})
mainWindow.webContents.on('will-prevent-unload', () => {
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)
ipcMain.on(confirmCloseChannel, onConfirmClose)
mainWindow.on('closed', () => {
// 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
ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights)
ipcMain.removeListener(confirmCloseChannel, onConfirmClose)
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
})
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}