mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
/* 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
|
||
}
|