mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
* refactor(window): group clipboard, lifecycle, security, and relay owners * refactor(window): keep Electron paste handling with desktop clipboard
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { app, BrowserWindow, nativeTheme, powerMonitor, screen } from 'electron'
|
|
import { release } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { installRendererHangStackPolicy } from '../hang-watchdog/renderer-hang-stack-policy'
|
|
import { getAppIconPath } from './desktop-integration/app-icon'
|
|
import { browserManager } from '../browser/guest-lifecycle/manager/browser-manager'
|
|
import { getBrowserClientHostId } from '../browser/client-hosts/lifecycle/browser-client-host-id'
|
|
import { formatBrowserClientHostIdArgument } from '../../shared/browser/client-hosting/browser-client-host-id-argument'
|
|
import { formatWindowMaterialArgument, resolveWindowMaterial } from '../../shared/window-material'
|
|
import { windowMaterialOptions } from './presentation/window-material-options'
|
|
import { rememberWindowMaterial } from './presentation/window-material-suppression'
|
|
import { markSystemSessionEnding } from '../crash-reporting/process-exits/expected-teardown-state'
|
|
import { recordDurableCrashBreadcrumb } from '../crash-reporting/breadcrumbs/durable-crash-breadcrumb'
|
|
import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui'
|
|
import { flushUnsavedCloudSessions } from '../orca-profiles/cloud-session/profile-cloud-session-owner'
|
|
import type { Store } from '../persistence/loading-store/store'
|
|
import { closeDashboardPopout } from './dashboard-popout-window'
|
|
import { closeWorkspaceSpaceWindow } from './workspace-space-window'
|
|
import { installMainWindowCloseLifecycle } from './main-window/main-window-close-lifecycle'
|
|
import type {
|
|
CreateMainWindowOptions,
|
|
MainWindowLoadObserver
|
|
} from './main-window/main-window-contracts'
|
|
import { mainWindowLoadErrorCode } from './renderer-recovery/main-window-load-error-code'
|
|
import { installMainWindowFocusLifecycle } from './main-window/main-window-focus-lifecycle'
|
|
import { installMainWindowShortcutRouting } from './main-window/main-window-shortcut-routing'
|
|
import { installMainWindowStateLifecycle } from './main-window/main-window-state-lifecycle'
|
|
import { installMainWindowActivityRelay } from './main-window/main-window-activity'
|
|
import { stopHiddenWindowFramePumpOnShow } from './visibility/hidden-window-frame-pump'
|
|
import { wslHostsWindowArguments } from '../wsl/host-lifecycle/wsl-host-gate'
|
|
import {
|
|
formatHiddenWindowFramePumpArgument,
|
|
shouldPumpHiddenWindowFrames
|
|
} from '../../shared/hidden-window-frame-pump'
|
|
import {
|
|
forceRepaint,
|
|
installMacosVisibilityRepaint,
|
|
MIN_HEIGHT,
|
|
MIN_WIDTH,
|
|
TITLEBAR_CSS_CENTER,
|
|
TRAFFIC_LIGHT_RADIUS,
|
|
TRAFFIC_LIGHT_X
|
|
} from './main-window/main-window-visual-lifecycle'
|
|
import { installMainWindowWebviewSecurity } from './security/main-window-webview-security'
|
|
import { rectHasVisibleAreaOnAnyDisplay } from './presentation/window-bounds-validation'
|
|
import { installWindowsPathRegistryChangeListener } from '../pty/environment/windows-path-registry-change'
|
|
import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session/records/agent-session-runtime-end-record'
|
|
|
|
export function loadMainWindow(mainWindow: BrowserWindow, observer?: MainWindowLoadObserver): void {
|
|
installRendererHangStackPolicy(
|
|
mainWindow.webContents,
|
|
!app.isPackaged && process.env.ELECTRON_RENDERER_URL
|
|
? process.env.ELECTRON_RENDERER_URL
|
|
: pathToFileURL(join(__dirname, '../renderer/index.html')).href
|
|
)
|
|
const load =
|
|
!app.isPackaged && process.env.ELECTRON_RENDERER_URL
|
|
? mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
|
: mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
|
// Observe each load promise so failures cannot leave recovery waiting silently.
|
|
load.then(
|
|
() => observer?.onLoaded?.(),
|
|
(cause: unknown) => {
|
|
const error = cause instanceof Error ? cause : new Error(String(cause))
|
|
const errorCode = mainWindowLoadErrorCode(error)
|
|
// Keep durable diagnostics path-free and exclude shutdown/navigation aborts.
|
|
if (!mainWindow.isDestroyed() && errorCode !== 'ERR_ABORTED') {
|
|
recordDurableCrashBreadcrumb('main_window_load_failed', { errorCode })
|
|
}
|
|
console.error('[window] Main window load failed', error)
|
|
observer?.onError?.(error)
|
|
}
|
|
)
|
|
}
|
|
|
|
export function createMainWindow(
|
|
store: Store | null,
|
|
opts?: CreateMainWindowOptions
|
|
): BrowserWindow {
|
|
const rawSavedBounds = store?.getUI().windowBounds
|
|
// Reject tiny/off-screen saved 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
|
|
const savedFullScreen = store?.getUI().windowFullScreen ?? false
|
|
// Fill the primary work area on first launch; later launches honor saved bounds.
|
|
const defaultBounds = (() => {
|
|
try {
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
|
return { width, height }
|
|
} catch {
|
|
return { width: 1200, height: 800 }
|
|
}
|
|
})()
|
|
|
|
const settings = store?.getSettings()
|
|
browserManager.setDictationShortcutForwardingPredicate(() => {
|
|
const voice = store?.getSettings().voice
|
|
// Chromium drops a page's key releases after a press cancelled here, so Hold can't see its release; the page keeps the key then.
|
|
return voice?.enabled === true && voice.dictationMode !== 'hold'
|
|
})
|
|
const windowMaterial = resolveWindowMaterial(
|
|
settings,
|
|
{ platform: process.platform, osRelease: release() },
|
|
nativeTheme.prefersReducedTransparency
|
|
)
|
|
|
|
const pumpHiddenFrames = shouldPumpHiddenWindowFrames(process.env, process.platform)
|
|
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,
|
|
// Deliver the app-activating click on macOS instead of requiring a second click.
|
|
acceptFirstMouse: true,
|
|
// Save menu-bar space on Windows/Linux; Alt reveals it and macOS uses the system menu.
|
|
autoHideMenuBar: true,
|
|
...windowMaterialOptions(
|
|
windowMaterial,
|
|
process.platform,
|
|
nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff'
|
|
),
|
|
// Keep macOS traffic lights inset; hide Windows native chrome to avoid duplicate titlebars.
|
|
titleBarStyle:
|
|
process.platform === 'darwin'
|
|
? 'hiddenInset'
|
|
: process.platform === 'win32'
|
|
? 'hidden'
|
|
: undefined,
|
|
// Linux ignores hidden titleBarStyle; remove its native frame to avoid duplicate titlebars.
|
|
...(process.platform === 'linux' ? { frame: false } : {}),
|
|
// Start at 1x traffic-light position; syncTrafficLightPosition follows zoom.
|
|
...(process.platform === 'darwin'
|
|
? {
|
|
trafficLightPosition: {
|
|
x: TRAFFIC_LIGHT_X,
|
|
y: TITLEBAR_CSS_CENTER - TRAFFIC_LIGHT_RADIUS
|
|
}
|
|
}
|
|
: {}),
|
|
icon: getAppIconPath(settings?.appIcon),
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: true,
|
|
webviewTag: true,
|
|
// Pass host identity at birth so the renderer has it before first snapshot, without waiting for IPC registration.
|
|
additionalArguments: [
|
|
formatBrowserClientHostIdArgument(getBrowserClientHostId()),
|
|
...(windowMaterial === 'off' ? [] : [formatWindowMaterialArgument(windowMaterial)]),
|
|
...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : []),
|
|
...wslHostsWindowArguments()
|
|
]
|
|
}
|
|
})
|
|
rememberWindowMaterial(mainWindow, windowMaterial)
|
|
const rendererWebContentsId = mainWindow.webContents.id
|
|
installWindowsPathRegistryChangeListener(mainWindow)
|
|
// Native paste fallback is privileged; trust only the top-level renderer.
|
|
setTrustedUIRendererWebContentsId(rendererWebContentsId)
|
|
|
|
// Unlike query-session-end, session-end cannot be canceled before this signal is recorded.
|
|
if (process.platform === 'win32') {
|
|
mainWindow.on('session-end', (event) => {
|
|
markSystemSessionEnding()
|
|
// Record positive OS shutdown evidence; task-kill and shutdown tree deaths otherwise look identical.
|
|
recordDurableCrashBreadcrumb('system_session_end', {
|
|
reasons: Array.isArray(event?.reasons)
|
|
? event.reasons.filter((reason) => typeof reason === 'string').join(',')
|
|
: ''
|
|
})
|
|
flushUnsavedCloudSessions()
|
|
// Windows emits no will-quit for an OS shutdown or logoff, so its teardown never records it.
|
|
recordAgentSessionRuntimeEnd('quit')
|
|
})
|
|
}
|
|
|
|
if (process.platform === 'darwin') {
|
|
// Keep hidden-window throttling; native sizing/frame invalidation recovers wake without an active viewport.
|
|
mainWindow.webContents.setBackgroundThrottling(true)
|
|
installMacosVisibilityRepaint(mainWindow)
|
|
}
|
|
|
|
// Resume may preserve focus and emit no visibility events; explicitly relay terminal wake and repaint stale surfaces.
|
|
const onSystemResume = (): void => {
|
|
if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) {
|
|
return
|
|
}
|
|
forceRepaint(mainWindow)
|
|
mainWindow.webContents.send('system:resumed')
|
|
// Give a suspended recovery load its full budget on wake.
|
|
focus.notifySystemResume()
|
|
}
|
|
|
|
const state = installMainWindowStateLifecycle({
|
|
mainWindow,
|
|
revealOnDidFinishLoad: opts?.revealOnDidFinishLoad === true,
|
|
savedMaximized,
|
|
savedFullScreen,
|
|
store
|
|
})
|
|
installMainWindowWebviewSecurity(mainWindow)
|
|
const activity = installMainWindowActivityRelay(mainWindow)
|
|
if (pumpHiddenFrames) {
|
|
stopHiddenWindowFramePumpOnShow(mainWindow)
|
|
}
|
|
const focus = installMainWindowFocusLifecycle({
|
|
isWindowClosing: state.isWindowClosing,
|
|
mainWindow,
|
|
opts,
|
|
reloadMainWindow: (observer) => loadMainWindow(mainWindow, observer),
|
|
rendererWebContentsId
|
|
})
|
|
// Register after focus is initialized because the resume callback uses it.
|
|
powerMonitor.on('resume', onSystemResume)
|
|
installMainWindowShortcutRouting({ focus, mainWindow, opts, store })
|
|
const closeLifecycle = installMainWindowCloseLifecycle({
|
|
focus,
|
|
mainWindow,
|
|
opts,
|
|
rendererWebContentsId,
|
|
state,
|
|
store
|
|
})
|
|
|
|
mainWindow.on('closed', () => {
|
|
closeDashboardPopout()
|
|
closeWorkspaceSpaceWindow()
|
|
state.clearInitialRevealFallbackTimer()
|
|
closeLifecycle.dispose()
|
|
activity.dispose()
|
|
focus.dispose()
|
|
browserManager.setDictationShortcutForwardingPredicate(null)
|
|
powerMonitor.removeListener('resume', onSystemResume)
|
|
clearTrustedUIRendererWebContentsId(rendererWebContentsId)
|
|
state.dispose()
|
|
})
|
|
|
|
if (!opts?.deferLoad) {
|
|
loadMainWindow(mainWindow)
|
|
}
|
|
|
|
return mainWindow
|
|
}
|