| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Ref from "effect/Ref"; |
| 8 | |
| 9 | import * as Electron from "electron"; |
| 10 | |
| 11 | import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; |
| 12 | |
| 13 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 15 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; |
| 16 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 17 | import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; |
| 18 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 19 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 20 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 21 | import { |
| 22 | MENU_ACTION_CHANNEL, |
| 23 | QUIT_SHORTCUT_CHANNEL, |
| 24 | SNAP_SHOT_EVENT_CHANNEL, |
| 25 | TRACKPAD_SCROLL_END_CHANNEL, |
| 26 | WINDOW_FULLSCREEN_STATE_CHANNEL, |
| 27 | } from "../ipc/channels.ts"; |
| 28 | import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; |
| 29 | import * as PreviewManager from "../preview/Manager.ts"; |
| 30 | import * as PreviewPasskeys from "../preview/Passkeys.ts"; |
| 31 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 32 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 33 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 34 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; |
| 35 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; |
| 36 | import { copyContextMenuImage } from "./ContextMenuImage.ts"; |
| 37 | |
| 38 | const TITLEBAR_HEIGHT = 40; |
| 39 | // Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS |
| 40 | // buttons are 14 points tall and do not scale with the renderer's zoom. |
| 41 | const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; |
| 42 | const MACOS_WINDOW_BUTTON_RADIUS = 7; |
| 43 | |
| 44 | function syncMacosWindowButtons(window: Electron.BrowserWindow): void { |
| 45 | if (window.isDestroyed() || window.isFullScreen()) return; |
| 46 | window.setWindowButtonPosition({ |
| 47 | x: 16, |
| 48 | y: Math.round( |
| 49 | (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - |
| 50 | MACOS_WINDOW_BUTTON_RADIUS, |
| 51 | ), |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| 56 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; |
| 57 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; |
| 58 | const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; |
| 59 | const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; |
| 60 | // Renderer crash (usually V8 OOM on long sessions) recovery: reload after a |
| 61 | // short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer |
| 62 | // that dies on boot cannot reload-loop forever. |
| 63 | const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; |
| 64 | const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; |
| 65 | const RENDERER_RECOVERY_WINDOW_MS = 60_000; |
| 66 | const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ |
| 67 | -2, // ERR_FAILED |
| 68 | -7, // ERR_TIMED_OUT |
| 69 | -9, // ERR_UNEXPECTED (custom protocol handler rejected) |
| 70 | -102, // ERR_CONNECTION_REFUSED |
| 71 | -105, // ERR_NAME_NOT_RESOLVED |
| 72 | -106, // ERR_INTERNET_DISCONNECTED |
| 73 | -118, // ERR_CONNECTION_TIMED_OUT |
| 74 | ]); |
| 75 | |
| 76 | type WindowTitleBarOptions = Pick< |
| 77 | Electron.BrowserWindowConstructorOptions, |
| 78 | "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" |
| 79 | >; |
| 80 | |
| 81 | type DesktopWindowRuntimeServices = |
| 82 | | DesktopEnvironment.DesktopEnvironment |
| 83 | | DesktopAssets.DesktopAssets |
| 84 | | DesktopAppSettings.DesktopAppSettings |
| 85 | | DesktopClientSettings.DesktopClientSettings |
| 86 | | ElectronApp.ElectronApp |
| 87 | | ElectronMenu.ElectronMenu |
| 88 | | ElectronShell.ElectronShell |
| 89 | | ElectronTheme.ElectronTheme |
| 90 | | ElectronWindow.ElectronWindow |
| 91 | | DesktopRendererHistory.DesktopRendererHistory |
| 92 | | PreviewManager.PreviewManager; |
| 93 | |
| 94 | export type DesktopWindowError = |
| 95 | | ElectronWindow.ElectronWindowCreateError |
| 96 | | PreviewManager.PreviewManagerError; |
| 97 | |
| 98 | export type MainWindowZoomDirection = "in" | "out" | "reset"; |
| 99 | export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools"; |
| 100 | |
| 101 | export class DesktopWindow extends Context.Service< |
| 102 | DesktopWindow, |
| 103 | { |
| 104 | readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| 105 | readonly ensureMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| 106 | readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| 107 | readonly activate: Effect.Effect<void, DesktopWindowError>; |
| 108 | readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>; |
| 109 | // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only |
| 110 | // mode), before the WSL backend that acts as the primary is ready. It is |
| 111 | // dismissed automatically once the real main window reveals. |
| 112 | readonly showConnectingSplash: Effect.Effect<void>; |
| 113 | // Marks the primary backend as ready so `createMainIfBackendReady` and the |
| 114 | // macOS "activate without windows" path may open the real main window. The |
| 115 | // renderer now always loads the local client URL (getDesktopUrl) and connects |
| 116 | // to the backend through the connection layer, so the reported httpBaseUrl is |
| 117 | // no longer used to point the window at the backend — it is kept only for the |
| 118 | // readiness log and to preserve the callback contract the backend pool drives. |
| 119 | readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect<void, DesktopWindowError>; |
| 120 | // Called when the backend transitions back to "not ready" (clean stop, |
| 121 | // restart, crash). Clears the latch that lets `activate` auto-create a |
| 122 | // window so a "macOS dock click" while the backend is down doesn't |
| 123 | // produce a stranded window pointing at nothing. |
| 124 | readonly handleBackendNotReady: Effect.Effect<void>; |
| 125 | readonly flushMainWindowBounds: Effect.Effect<void>; |
| 126 | readonly prepareCaptureReveal: Effect.Effect<void>; |
| 127 | readonly dispatchMenuAction: ( |
| 128 | action: string, |
| 129 | options?: { readonly reveal?: boolean }, |
| 130 | ) => Effect.Effect<void, DesktopWindowError>; |
| 131 | /** |
| 132 | * Push a capture lifecycle event to the renderer. Only `started` reveals the |
| 133 | * window; the rest must not interrupt the app the user has switched to. |
| 134 | */ |
| 135 | readonly dispatchSnapShotEvent: ( |
| 136 | event: DesktopSnapShotEvent, |
| 137 | ) => Effect.Effect<void, DesktopWindowError>; |
| 138 | // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` |
| 139 | // menu roles act on whichever webContents has keyboard focus, so with an |
| 140 | // embedded preview WebContentsView (or DevTools) focused they zoom the |
| 141 | // guest page instead of the app UI. The menu routes here to always target |
| 142 | // the main window. |
| 143 | readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>; |
| 144 | // Reload and DevTools for the main window's own webContents, for the same |
| 145 | // reason as zoomMain: the Electron roles act on the focused webContents, |
| 146 | // which is a preview guest whenever a browser page has focus. |
| 147 | readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect<void>; |
| 148 | readonly syncAppearance: Effect.Effect<void>; |
| 149 | } |
| 150 | >()("@t3tools/desktop/window/DesktopWindow") {} |
| 151 | |
| 152 | const { logInfo: logWindowInfo, logWarning: logWindowWarning } = |
| 153 | makeComponentLogger("desktop-window"); |
| 154 | |
| 155 | function getIconOption( |
| 156 | iconPaths: DesktopAssets.DesktopIconPaths, |
| 157 | platform: NodeJS.Platform, |
| 158 | ): { icon: string } | Record<string, never> { |
| 159 | if (platform === "darwin") return {}; // macOS uses .icns from app bundle |
| 160 | const ext = platform === "win32" ? "ico" : "png"; |
| 161 | return Option.match(iconPaths[ext], { |
| 162 | onNone: () => ({}), |
| 163 | onSome: (icon) => ({ icon }), |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { |
| 168 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; |
| 169 | } |
| 170 | |
| 171 | type DisplayBounds = Pick<Electron.Rectangle, "x" | "y" | "width" | "height">; |
| 172 | |
| 173 | function windowFitsWithinDisplay( |
| 174 | windowBounds: DesktopAppSettings.DesktopWindowBounds, |
| 175 | displayBounds: DisplayBounds, |
| 176 | ): boolean { |
| 177 | return ( |
| 178 | windowBounds.x >= displayBounds.x && |
| 179 | windowBounds.y >= displayBounds.y && |
| 180 | windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && |
| 181 | windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height |
| 182 | ); |
| 183 | } |
| 184 | |
| 185 | function windowBoundsEqual( |
| 186 | left: DesktopAppSettings.DesktopWindowBounds, |
| 187 | right: DesktopAppSettings.DesktopWindowBounds, |
| 188 | ): boolean { |
| 189 | return ( |
| 190 | left.x === right.x && |
| 191 | left.y === right.y && |
| 192 | left.width === right.width && |
| 193 | left.height === right.height |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | export function resolveInitialMainWindowBounds( |
| 198 | persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, |
| 199 | displays: readonly DisplayBounds[], |
| 200 | ): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { |
| 201 | if ( |
| 202 | persistedBounds !== null && |
| 203 | displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) |
| 204 | ) { |
| 205 | return persistedBounds; |
| 206 | } |
| 207 | return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; |
| 208 | } |
| 209 | |
| 210 | // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only |
| 211 | // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as |
| 212 | // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. |
| 213 | function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { |
| 214 | const background = getInitialWindowBackgroundColor(shouldUseDarkColors); |
| 215 | const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; |
| 216 | const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; |
| 217 | const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; |
| 218 | const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><style>html,body{margin:0;height:100%}body{background:${background};color:${label};font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;-webkit-user-select:none;user-select:none;-webkit-app-region:drag}.spinner{width:26px;height:26px;border:3px solid ${track};border-top-color:${accent};border-radius:50%;animation:spin .8s linear infinite}.label{font-size:13px}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><div class="spinner"></div><div class="label">Connecting to WSL…</div></body></html>`; |
| 219 | return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; |
| 220 | } |
| 221 | |
| 222 | export function isSameOriginRendererNavigation(input: { |
| 223 | readonly applicationUrl: string; |
| 224 | readonly navigationUrl: string; |
| 225 | }): boolean { |
| 226 | try { |
| 227 | return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; |
| 228 | } catch { |
| 229 | return false; |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | export function isRetryableDevelopmentRendererLoadFailure(input: { |
| 234 | readonly applicationUrl: string; |
| 235 | readonly errorCode: number; |
| 236 | readonly isMainFrame: boolean; |
| 237 | readonly validatedUrl: string; |
| 238 | }): boolean { |
| 239 | return ( |
| 240 | input.isMainFrame && |
| 241 | DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES.has(input.errorCode) && |
| 242 | isSameOriginRendererNavigation({ |
| 243 | applicationUrl: input.applicationUrl, |
| 244 | navigationUrl: input.validatedUrl, |
| 245 | }) |
| 246 | ); |
| 247 | } |
| 248 | |
| 249 | export function concealPendingQuitWindow( |
| 250 | window: Pick< |
| 251 | Electron.BrowserWindow, |
| 252 | "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity" |
| 253 | >, |
| 254 | ): void { |
| 255 | if (window.isDestroyed()) return; |
| 256 | if (window.isFullScreen()) { |
| 257 | window.setFullScreen(false); |
| 258 | } |
| 259 | // Electron implements window opacity on macOS and Windows. Linux keeps the |
| 260 | // release-gated quit behavior but cannot make the pending window disappear. |
| 261 | window.setOpacity(0); |
| 262 | } |
| 263 | |
| 264 | function getWindowTitleBarOptions( |
| 265 | shouldUseDarkColors: boolean, |
| 266 | platform: NodeJS.Platform, |
| 267 | ): WindowTitleBarOptions { |
| 268 | if (platform === "darwin") { |
| 269 | return { |
| 270 | titleBarStyle: "hiddenInset", |
| 271 | trafficLightPosition: { |
| 272 | x: 16, |
| 273 | y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, |
| 274 | }, |
| 275 | }; |
| 276 | } |
| 277 | |
| 278 | return { |
| 279 | titleBarStyle: "hidden", |
| 280 | titleBarOverlay: { |
| 281 | color: TITLEBAR_COLOR, |
| 282 | height: TITLEBAR_HEIGHT, |
| 283 | symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, |
| 284 | }, |
| 285 | }; |
| 286 | } |
| 287 | |
| 288 | function syncWindowAppearance( |
| 289 | window: Electron.BrowserWindow, |
| 290 | shouldUseDarkColors: boolean, |
| 291 | platform: NodeJS.Platform, |
| 292 | ): Effect.Effect<void> { |
| 293 | return Effect.sync(() => { |
| 294 | if (window.isDestroyed()) { |
| 295 | return; |
| 296 | } |
| 297 | |
| 298 | window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); |
| 299 | const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); |
| 300 | if (typeof titleBarOverlay === "object") { |
| 301 | window.setTitleBarOverlay(titleBarOverlay); |
| 302 | } |
| 303 | }); |
| 304 | } |
| 305 | |
| 306 | type RevealSubscription = (listener: () => void) => void; |
| 307 | |
| 308 | function bindFirstRevealTrigger( |
| 309 | subscribers: readonly RevealSubscription[], |
| 310 | reveal: () => void, |
| 311 | ): void { |
| 312 | let revealed = false; |
| 313 | const fire = () => { |
| 314 | if (revealed) return; |
| 315 | revealed = true; |
| 316 | reveal(); |
| 317 | }; |
| 318 | for (const subscribe of subscribers) { |
| 319 | subscribe(fire); |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | /** @public Service construction is part of the canonical Effect module API. */ |
| 324 | export const make = Effect.gen(function* () { |
| 325 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 326 | const assets = yield* DesktopAssets.DesktopAssets; |
| 327 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 328 | const electronShell = yield* ElectronShell.ElectronShell; |
| 329 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 330 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 331 | const previewManager = yield* PreviewManager.PreviewManager; |
| 332 | const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; |
| 333 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 334 | const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; |
| 335 | const electronApp = yield* ElectronApp.ElectronApp; |
| 336 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; |
| 337 | // Window-side latch for the primary backend's readiness. Set by |
| 338 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 339 | // by handleBackendNotReady (driven by onShutdown). Only consumed by |
| 340 | // createMainIfBackendReady, which gates the post-readiness window |
| 341 | // open in development and the macOS "activate without windows" path. |
| 342 | const backendReadyRef = yield* Ref.make(false); |
| 343 | // The transient "Connecting to WSL" splash window, tracked separately so it |
| 344 | // is never mistaken for the real main window. |
| 345 | const splashWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); |
| 346 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 347 | const runFork = Effect.runForkWith(context); |
| 348 | const runPromise = Effect.runPromiseWith(context); |
| 349 | let flushMainWindowBounds: Effect.Effect<void> = Effect.void; |
| 350 | |
| 351 | const dismissConnectingSplash = Effect.gen(function* () { |
| 352 | const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); |
| 353 | if (Option.isSome(splash) && !splash.value.isDestroyed()) { |
| 354 | splash.value.close(); |
| 355 | } |
| 356 | }); |
| 357 | |
| 358 | // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", |
| 359 | // which during WSL-only boot is the connecting splash. The splash is never |
| 360 | // registered via setMain, so it must be treated as "no real main window" -- |
| 361 | // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never |
| 362 | // open (or retry) the real main. That is the failure the pool's swallowed |
| 363 | // post-readiness window-open error would otherwise strand the user in: |
| 364 | // splash up, backend ready, no main, and activation only re-reveals splash. |
| 365 | const withoutSplash = (window: Option.Option<Electron.BrowserWindow>) => |
| 366 | Ref.get(splashWindowRef).pipe( |
| 367 | Effect.map((splash) => |
| 368 | Option.isSome(splash) && Option.isSome(window) && window.value === splash.value |
| 369 | ? Option.none<Electron.BrowserWindow>() |
| 370 | : window, |
| 371 | ), |
| 372 | ); |
| 373 | |
| 374 | const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); |
| 375 | const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); |
| 376 | |
| 377 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< |
| 378 | Electron.BrowserWindow, |
| 379 | DesktopWindowError |
| 380 | > { |
| 381 | yield* previewManager.getBrowserSession(); |
| 382 | const applicationUrl = getDesktopUrl(environment.isDevelopment); |
| 383 | const iconPaths = yield* assets.iconPaths; |
| 384 | const iconOption = getIconOption(iconPaths, environment.platform); |
| 385 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 386 | const persistedSettings = yield* desktopSettings.get; |
| 387 | const persistedBounds = persistedSettings.mainWindowBounds; |
| 388 | const displayBoundsResult = yield* Effect.sync(() => { |
| 389 | try { |
| 390 | return { |
| 391 | _tag: "Success" as const, |
| 392 | bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), |
| 393 | }; |
| 394 | } catch (cause) { |
| 395 | return { _tag: "Failure" as const, cause }; |
| 396 | } |
| 397 | }); |
| 398 | const displayBounds = |
| 399 | displayBoundsResult._tag === "Success" |
| 400 | ? displayBoundsResult.bounds |
| 401 | : yield* logWindowWarning("failed to read connected displays; using defaults", { |
| 402 | cause: displayBoundsResult.cause, |
| 403 | }).pipe(Effect.as<readonly Electron.Rectangle[]>([])); |
| 404 | const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); |
| 405 | const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; |
| 406 | if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { |
| 407 | yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); |
| 408 | } |
| 409 | const window = yield* electronWindow.create({ |
| 410 | ...initialBounds, |
| 411 | minWidth: 840, |
| 412 | minHeight: 620, |
| 413 | show: false, |
| 414 | autoHideMenuBar: true, |
| 415 | ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), |
| 416 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), |
| 417 | ...iconOption, |
| 418 | title: environment.displayName, |
| 419 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), |
| 420 | webPreferences: { |
| 421 | preload: environment.preloadPath, |
| 422 | // The window boots hidden (show: false until ready-to-show), and |
| 423 | // Chromium throttles hidden renderers: timers coalesce and rAF stops, |
| 424 | // which stalls first paint. Boot unthrottled; the first-reveal trigger |
| 425 | // re-enables throttling so a hidden or minimized window goes back to |
| 426 | // being cheap after it has been shown once. |
| 427 | backgroundThrottling: false, |
| 428 | contextIsolation: true, |
| 429 | nodeIntegration: false, |
| 430 | sandbox: true, |
| 431 | webviewTag: true, |
| 432 | // A preview guest's fullscreen request is mirrored onto this embedder, |
| 433 | // which would otherwise put the whole window into OS fullscreen. With |
| 434 | // both sides opted out the page fills its webview and the window stays. |
| 435 | disableHtmlFullscreenWindowResize: true, |
| 436 | }, |
| 437 | }); |
| 438 | |
| 439 | yield* rendererHistory.register(window.webContents, { surface: "main" }); |
| 440 | if (environment.platform === "darwin") { |
| 441 | window.setAutoHideCursor(false); |
| 442 | } |
| 443 | let boundsPersistFiber: Fiber.Fiber<void, never> | undefined; |
| 444 | let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined; |
| 445 | let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; |
| 446 | const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { |
| 447 | if (window.isDestroyed()) { |
| 448 | return null; |
| 449 | } |
| 450 | const bounds = |
| 451 | window.isFullScreen() || window.isMaximized() || window.isMinimized() |
| 452 | ? window.getNormalBounds() |
| 453 | : window.getBounds(); |
| 454 | return DesktopAppSettings.normalizeMainWindowBounds({ |
| 455 | x: Math.round(bounds.x), |
| 456 | y: Math.round(bounds.y), |
| 457 | width: Math.round(bounds.width), |
| 458 | height: Math.round(bounds.height), |
| 459 | }); |
| 460 | }; |
| 461 | const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); |
| 462 | const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; |
| 463 | const persistCurrentBounds = (): Fiber.Fiber<void, never> | undefined => { |
| 464 | if (!boundsPersistenceEnabled) { |
| 465 | return pendingBoundsPersistFiber; |
| 466 | } |
| 467 | const bounds = readPersistableBounds(); |
| 468 | if (bounds === null) { |
| 469 | return pendingBoundsPersistFiber; |
| 470 | } |
| 471 | pendingBoundsPersistFiber = runFork( |
| 472 | desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( |
| 473 | Effect.asVoid, |
| 474 | Effect.catch((error) => |
| 475 | logWindowWarning("failed to persist main window bounds", { |
| 476 | message: error.message, |
| 477 | }), |
| 478 | ), |
| 479 | ), |
| 480 | ); |
| 481 | return pendingBoundsPersistFiber; |
| 482 | }; |
| 483 | const scheduleBoundsPersist = () => { |
| 484 | if (!boundsPersistenceEnabled) { |
| 485 | const currentBounds = readPersistableBounds(); |
| 486 | if ( |
| 487 | currentBounds === null || |
| 488 | (fallbackWindowBounds !== null && |
| 489 | windowBoundsEqual(currentBounds, fallbackWindowBounds) && |
| 490 | window.isMaximized() === fallbackWindowMaximized) |
| 491 | ) { |
| 492 | return; |
| 493 | } |
| 494 | } |
| 495 | boundsPersistenceEnabled = true; |
| 496 | if (boundsPersistFiber !== undefined) { |
| 497 | const fiber = boundsPersistFiber; |
| 498 | boundsPersistFiber = undefined; |
| 499 | runFork(Fiber.interrupt(fiber)); |
| 500 | } |
| 501 | boundsPersistFiber = runFork( |
| 502 | Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( |
| 503 | Effect.andThen( |
| 504 | Effect.sync(() => { |
| 505 | boundsPersistFiber = undefined; |
| 506 | void persistCurrentBounds(); |
| 507 | }), |
| 508 | ), |
| 509 | ), |
| 510 | ); |
| 511 | }; |
| 512 | const clearBoundsPersist = () => { |
| 513 | if (boundsPersistFiber === undefined) { |
| 514 | return; |
| 515 | } |
| 516 | const fiber = boundsPersistFiber; |
| 517 | boundsPersistFiber = undefined; |
| 518 | runFork(Fiber.interrupt(fiber)); |
| 519 | }; |
| 520 | const flushBoundsPersist = Effect.sync(() => { |
| 521 | clearBoundsPersist(); |
| 522 | return persistCurrentBounds(); |
| 523 | }).pipe( |
| 524 | Effect.flatMap((fiber) => |
| 525 | fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), |
| 526 | ), |
| 527 | ); |
| 528 | flushMainWindowBounds = flushBoundsPersist; |
| 529 | |
| 530 | yield* previewManager.setMainWindow(window); |
| 531 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { |
| 532 | if ( |
| 533 | typeof params.partition !== "string" || |
| 534 | !previewManager.isBrowserPartition(params.partition) |
| 535 | ) { |
| 536 | event.preventDefault(); |
| 537 | return; |
| 538 | } |
| 539 | webPreferences.sandbox = true; |
| 540 | webPreferences.nodeIntegration = false; |
| 541 | webPreferences.nodeIntegrationInSubFrames = false; |
| 542 | webPreferences.contextIsolation = false; |
| 543 | webPreferences.disableHtmlFullscreenWindowResize = true; |
| 544 | if (previewPasskeys.bridgeEnabled) { |
| 545 | webPreferences.additionalArguments = [ |
| 546 | ...(webPreferences.additionalArguments ?? []), |
| 547 | PASSKEY_BRIDGE_ARGUMENT, |
| 548 | ]; |
| 549 | } |
| 550 | }); |
| 551 | |
| 552 | const contextMenuContents = new WeakSet<Electron.WebContents>(); |
| 553 | const installContextMenu = ( |
| 554 | ownerWindow: Electron.BrowserWindow, |
| 555 | contents: Electron.WebContents, |
| 556 | ): void => { |
| 557 | if (contextMenuContents.has(contents)) return; |
| 558 | contextMenuContents.add(contents); |
| 559 | contents.on("context-menu", (event, params) => { |
| 560 | event.preventDefault(); |
| 561 | if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; |
| 562 | // Native editing roles act on the focused contents, which may still be |
| 563 | // the host renderer when the user right-clicks inside a browser guest. |
| 564 | contents.focus(); |
| 565 | |
| 566 | const menuTemplate: Electron.MenuItemConstructorOptions[] = []; |
| 567 | |
| 568 | if (params.misspelledWord) { |
| 569 | for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { |
| 570 | menuTemplate.push({ |
| 571 | label: suggestion, |
| 572 | click: () => { |
| 573 | if (!contents.isDestroyed()) contents.replaceMisspelling(suggestion); |
| 574 | }, |
| 575 | }); |
| 576 | } |
| 577 | if (params.dictionarySuggestions.length === 0) { |
| 578 | menuTemplate.push({ label: "No suggestions", enabled: false }); |
| 579 | } |
| 580 | menuTemplate.push({ type: "separator" }); |
| 581 | } |
| 582 | |
| 583 | if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { |
| 584 | menuTemplate.push( |
| 585 | { |
| 586 | label: "Copy Link", |
| 587 | click: () => { |
| 588 | void runPromise(electronShell.copyText(params.linkURL)); |
| 589 | }, |
| 590 | }, |
| 591 | { type: "separator" }, |
| 592 | ); |
| 593 | } |
| 594 | |
| 595 | if (params.mediaType === "image") { |
| 596 | menuTemplate.push({ |
| 597 | label: "Copy Image", |
| 598 | click: () => { |
| 599 | void runPromise( |
| 600 | Effect.tryPromise(() => copyContextMenuImage(contents, params)).pipe( |
| 601 | Effect.catch(() => logWindowWarning("failed to copy context-menu image")), |
| 602 | ), |
| 603 | ); |
| 604 | }, |
| 605 | }); |
| 606 | menuTemplate.push({ type: "separator" }); |
| 607 | } |
| 608 | |
| 609 | menuTemplate.push( |
| 610 | { role: "cut", enabled: params.editFlags.canCut }, |
| 611 | { role: "copy", enabled: params.editFlags.canCopy }, |
| 612 | { role: "paste", enabled: params.editFlags.canPaste }, |
| 613 | { role: "selectAll", enabled: params.editFlags.canSelectAll }, |
| 614 | ); |
| 615 | |
| 616 | void runPromise( |
| 617 | electronMenu.popupTemplate({ |
| 618 | window: ownerWindow, |
| 619 | template: menuTemplate, |
| 620 | ...(params.frame ? { frame: params.frame } : {}), |
| 621 | }), |
| 622 | ); |
| 623 | }); |
| 624 | contents.on("did-create-window", (popup) => { |
| 625 | installContextMenu(popup, popup.webContents); |
| 626 | }); |
| 627 | }; |
| 628 | installContextMenu(window, window.webContents); |
| 629 | window.webContents.on("did-attach-webview", (_event, contents) => { |
| 630 | installContextMenu(window, contents); |
| 631 | void runPromise(previewManager.prepareWebview(contents)); |
| 632 | }); |
| 633 | |
| 634 | window.webContents.setWindowOpenHandler(({ url }) => { |
| 635 | if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { |
| 636 | void runPromise(electronShell.openExternal(url)); |
| 637 | } |
| 638 | return { action: "deny" }; |
| 639 | }); |
| 640 | window.webContents.on("will-navigate", (event, url) => { |
| 641 | if ( |
| 642 | isSameOriginRendererNavigation({ |
| 643 | applicationUrl, |
| 644 | navigationUrl: url, |
| 645 | }) |
| 646 | ) { |
| 647 | return; |
| 648 | } |
| 649 | |
| 650 | event.preventDefault(); |
| 651 | if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { |
| 652 | void runPromise(electronShell.openExternal(url)); |
| 653 | } |
| 654 | }); |
| 655 | |
| 656 | // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the |
| 657 | // close-terminal shortcut can outlive the terminal that handled its first |
| 658 | // press, so reject repeats before they reach the native window accelerator. |
| 659 | // Deliberate presses still flow through the renderer or native menu. |
| 660 | // Intercept the quit accelerator before the native menu sees it and apply |
| 661 | // the configured direct, hold, or double-press behavior. |
| 662 | const quitShortcutHandler = makeQuitShortcutHandler({ |
| 663 | platform: environment.platform, |
| 664 | getMode: () => |
| 665 | runPromise( |
| 666 | Effect.map( |
| 667 | clientSettings.get, |
| 668 | Option.match({ |
| 669 | onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, |
| 670 | onSome: (settings) => settings.confirmQuit, |
| 671 | }), |
| 672 | ), |
| 673 | ), |
| 674 | notify: (hint) => { |
| 675 | if (!window.isDestroyed()) { |
| 676 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); |
| 677 | } |
| 678 | }, |
| 679 | // Keep the transparent window focused until the physical shortcut is |
| 680 | // released so its remaining repeats cannot reach the next app. |
| 681 | concealWindow: () => concealPendingQuitWindow(window), |
| 682 | quit: () => { |
| 683 | void runPromise(electronApp.quit); |
| 684 | }, |
| 685 | }); |
| 686 | window.webContents.on("before-input-event", (event, input) => { |
| 687 | quitShortcutHandler(event, input); |
| 688 | if (input.type !== "keyDown" || !input.isAutoRepeat) return; |
| 689 | const modifier = environment.platform === "darwin" ? input.meta : input.control; |
| 690 | if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { |
| 691 | event.preventDefault(); |
| 692 | } |
| 693 | }); |
| 694 | window.webContents.on("input-event", (_event, input) => { |
| 695 | if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); |
| 696 | }); |
| 697 | |
| 698 | window.on("page-title-updated", (event) => { |
| 699 | event.preventDefault(); |
| 700 | window.setTitle(environment.displayName); |
| 701 | }); |
| 702 | window.on("resize", scheduleBoundsPersist); |
| 703 | window.on("move", scheduleBoundsPersist); |
| 704 | window.on("maximize", scheduleBoundsPersist); |
| 705 | window.on("unmaximize", scheduleBoundsPersist); |
| 706 | window.on("close", () => { |
| 707 | runFork(flushBoundsPersist); |
| 708 | }); |
| 709 | |
| 710 | if (environment.platform === "darwin") { |
| 711 | window.on("enter-full-screen", () => { |
| 712 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); |
| 713 | }); |
| 714 | window.on("leave-full-screen", () => { |
| 715 | syncMacosWindowButtons(window); |
| 716 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); |
| 717 | }); |
| 718 | } |
| 719 | |
| 720 | let developmentLoadRetryIndex = 0; |
| 721 | let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined; |
| 722 | let rendererRecoveryTimestamps: number[] = []; |
| 723 | const clearDevelopmentLoadRetry = () => { |
| 724 | if (developmentLoadRetryFiber === undefined) { |
| 725 | return; |
| 726 | } |
| 727 | const retryFiber = developmentLoadRetryFiber; |
| 728 | developmentLoadRetryFiber = undefined; |
| 729 | runFork(Fiber.interrupt(retryFiber)); |
| 730 | }; |
| 731 | const loadApplication = () => { |
| 732 | if (window.isDestroyed()) { |
| 733 | return; |
| 734 | } |
| 735 | void window.loadURL(applicationUrl).catch(() => undefined); |
| 736 | }; |
| 737 | const scheduleDevelopmentLoadRetry = () => { |
| 738 | if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { |
| 739 | return undefined; |
| 740 | } |
| 741 | |
| 742 | const retryIndex = Math.min( |
| 743 | developmentLoadRetryIndex, |
| 744 | DEVELOPMENT_LOAD_RETRY_DELAYS_MS.length - 1, |
| 745 | ); |
| 746 | const retryInMs = DEVELOPMENT_LOAD_RETRY_DELAYS_MS[retryIndex] ?? 2_000; |
| 747 | developmentLoadRetryIndex += 1; |
| 748 | developmentLoadRetryFiber = runFork( |
| 749 | Effect.sleep(retryInMs).pipe( |
| 750 | Effect.andThen( |
| 751 | Effect.sync(() => { |
| 752 | developmentLoadRetryFiber = undefined; |
| 753 | if (!window.isDestroyed()) { |
| 754 | loadApplication(); |
| 755 | } |
| 756 | }), |
| 757 | ), |
| 758 | ), |
| 759 | ); |
| 760 | return retryInMs; |
| 761 | }; |
| 762 | |
| 763 | window.webContents.on("did-finish-load", () => { |
| 764 | if ( |
| 765 | environment.isDevelopment && |
| 766 | !isSameOriginRendererNavigation({ |
| 767 | applicationUrl, |
| 768 | navigationUrl: window.webContents.getURL(), |
| 769 | }) |
| 770 | ) { |
| 771 | return; |
| 772 | } |
| 773 | clearDevelopmentLoadRetry(); |
| 774 | developmentLoadRetryIndex = 0; |
| 775 | window.setTitle(environment.displayName); |
| 776 | if (environment.platform === "darwin") syncMacosWindowButtons(window); |
| 777 | }); |
| 778 | window.webContents.on( |
| 779 | "did-fail-load", |
| 780 | (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { |
| 781 | if (!isMainFrame) { |
| 782 | return; |
| 783 | } |
| 784 | const retryInMs = |
| 785 | environment.isDevelopment && |
| 786 | isRetryableDevelopmentRendererLoadFailure({ |
| 787 | applicationUrl, |
| 788 | errorCode, |
| 789 | isMainFrame, |
| 790 | validatedUrl: validatedURL, |
| 791 | }) |
| 792 | ? scheduleDevelopmentLoadRetry() |
| 793 | : undefined; |
| 794 | void runPromise( |
| 795 | logWindowWarning("main window failed to load", { |
| 796 | errorCode, |
| 797 | errorDescription, |
| 798 | url: validatedURL, |
| 799 | ...(retryInMs === undefined ? {} : { retryInMs }), |
| 800 | }), |
| 801 | ); |
| 802 | }, |
| 803 | ); |
| 804 | window.webContents.on("render-process-gone", (_event, details) => { |
| 805 | const recoverable = |
| 806 | details.reason === "crashed" || |
| 807 | details.reason === "oom" || |
| 808 | details.reason === "abnormal-exit"; |
| 809 | // Long sessions can OOM the renderer (V8 heap exhaustion from |
| 810 | // accumulated thread state). Without a reload the user is left staring |
| 811 | // at a dead white window while agents keep running invisibly, so |
| 812 | // recover by reloading — the renderer rehydrates from the backend, |
| 813 | // which is unaffected. Recovery attempts are bounded so a renderer |
| 814 | // that dies immediately on boot cannot reload-loop forever. |
| 815 | runFork( |
| 816 | Effect.gen(function* () { |
| 817 | const now = yield* Clock.currentTimeMillis; |
| 818 | rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter( |
| 819 | (timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS, |
| 820 | ); |
| 821 | const shouldRecover = |
| 822 | recoverable && |
| 823 | !window.isDestroyed() && |
| 824 | rendererRecoveryTimestamps.length < RENDERER_RECOVERY_MAX_ATTEMPTS; |
| 825 | yield* logWindowWarning("main window render process gone", { |
| 826 | reason: details.reason, |
| 827 | exitCode: details.exitCode, |
| 828 | recovering: shouldRecover, |
| 829 | }); |
| 830 | if (!shouldRecover) { |
| 831 | return; |
| 832 | } |
| 833 | rendererRecoveryTimestamps.push(now); |
| 834 | yield* Effect.sleep(RENDERER_RECOVERY_RELOAD_DELAY_MS); |
| 835 | if (!window.isDestroyed()) { |
| 836 | loadApplication(); |
| 837 | } |
| 838 | }), |
| 839 | ); |
| 840 | }); |
| 841 | |
| 842 | const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; |
| 843 | if (environment.platform === "linux") { |
| 844 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); |
| 845 | } |
| 846 | bindFirstRevealTrigger(revealSubscribers, () => { |
| 847 | // Boot is done; hand the window back to normal hidden-window throttling |
| 848 | // (see the backgroundThrottling comment on the create options above). |
| 849 | if (!window.isDestroyed()) { |
| 850 | window.webContents.setBackgroundThrottling(true); |
| 851 | } |
| 852 | // Reveal the real window, then close the connecting splash (if any) so the |
| 853 | // two don't overlap and there's no blank gap between them. |
| 854 | if (persistedSettings.mainWindowMaximized) { |
| 855 | window.maximize(); |
| 856 | } |
| 857 | void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); |
| 858 | }); |
| 859 | |
| 860 | loadApplication(); |
| 861 | if (environment.isDevelopment) { |
| 862 | window.webContents.openDevTools({ mode: "detach" }); |
| 863 | } |
| 864 | |
| 865 | window.on("closed", () => { |
| 866 | clearDevelopmentLoadRetry(); |
| 867 | clearBoundsPersist(); |
| 868 | void runPromise(electronWindow.clearMain(Option.some(window))); |
| 869 | }); |
| 870 | |
| 871 | return window; |
| 872 | }); |
| 873 | |
| 874 | const createMain = Effect.gen(function* () { |
| 875 | const window = yield* createWindow(); |
| 876 | yield* electronWindow.setMain(window); |
| 877 | yield* logWindowInfo("main window created"); |
| 878 | return window; |
| 879 | }).pipe(Effect.withSpan("desktop.window.createMain")); |
| 880 | |
| 881 | const ensureMain = Effect.gen(function* () { |
| 882 | const existingWindow = yield* currentMainWindow; |
| 883 | if (Option.isSome(existingWindow)) { |
| 884 | return existingWindow.value; |
| 885 | } |
| 886 | return yield* createMain; |
| 887 | }).pipe(Effect.withSpan("desktop.window.ensureMain")); |
| 888 | |
| 889 | const revealOrCreateMain = Effect.gen(function* () { |
| 890 | const window = yield* ensureMain; |
| 891 | yield* electronWindow.reveal(window); |
| 892 | return window; |
| 893 | }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); |
| 894 | |
| 895 | // With the local environment disabled there is no backend to wait for: the |
| 896 | // renderer is served from bundled assets and only talks to remote environments. |
| 897 | const waitingForBackend = Effect.gen(function* () { |
| 898 | if (yield* Ref.get(backendReadyRef)) return false; |
| 899 | return (yield* desktopSettings.get).localEnvironmentEnabled; |
| 900 | }); |
| 901 | |
| 902 | const createMainIfBackendReady = Effect.gen(function* () { |
| 903 | if (yield* waitingForBackend) return; |
| 904 | const existingWindow = yield* currentMainWindow; |
| 905 | if (Option.isSome(existingWindow)) return; |
| 906 | yield* createMain; |
| 907 | }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); |
| 908 | |
| 909 | const showConnectingSplash = Effect.gen(function* () { |
| 910 | // Only when nothing is shown yet: no real window, no existing splash. |
| 911 | const existingSplash = yield* Ref.get(splashWindowRef); |
| 912 | if (Option.isSome(existingSplash)) return; |
| 913 | const existingWindow = yield* electronWindow.currentMainOrFirst; |
| 914 | if (Option.isSome(existingWindow)) return; |
| 915 | |
| 916 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 917 | const splash = yield* electronWindow.create({ |
| 918 | width: 360, |
| 919 | height: 220, |
| 920 | resizable: false, |
| 921 | minimizable: false, |
| 922 | maximizable: false, |
| 923 | fullscreenable: false, |
| 924 | frame: false, |
| 925 | center: true, |
| 926 | show: false, |
| 927 | skipTaskbar: false, |
| 928 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), |
| 929 | title: environment.displayName, |
| 930 | webPreferences: { |
| 931 | contextIsolation: true, |
| 932 | nodeIntegration: false, |
| 933 | sandbox: true, |
| 934 | }, |
| 935 | }); |
| 936 | yield* rendererHistory.register(splash.webContents, { surface: "splash" }); |
| 937 | yield* Ref.set(splashWindowRef, Option.some(splash)); |
| 938 | splash.once("closed", () => { |
| 939 | void runPromise(Ref.set(splashWindowRef, Option.none())); |
| 940 | }); |
| 941 | splash.once("ready-to-show", () => { |
| 942 | if (!splash.isDestroyed()) { |
| 943 | splash.show(); |
| 944 | } |
| 945 | }); |
| 946 | void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); |
| 947 | yield* logWindowInfo("connecting splash shown"); |
| 948 | }).pipe( |
| 949 | // The splash is best-effort UX — never let it fail startup. |
| 950 | Effect.catch((error) => |
| 951 | logWindowWarning("failed to show connecting splash", { message: error.message }), |
| 952 | ), |
| 953 | Effect.withSpan("desktop.window.showConnectingSplash"), |
| 954 | ); |
| 955 | |
| 956 | const dispatchRendererEvent = Effect.fn("desktop.window.dispatchRendererEvent")(function* ( |
| 957 | channel: string, |
| 958 | payload: unknown, |
| 959 | { reveal = true }: { readonly reveal?: boolean } = {}, |
| 960 | ) { |
| 961 | const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; |
| 962 | if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; |
| 963 | const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; |
| 964 | if (targetWindow.isDestroyed()) return; |
| 965 | const send = Effect.sync(() => { |
| 966 | if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); |
| 967 | }); |
| 968 | // The renderer must learn about the event even when another process refuses to |
| 969 | // yield the foreground, so send first and treat the reveal as best effort. |
| 970 | const dispatch = reveal |
| 971 | ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) |
| 972 | : send; |
| 973 | if (targetWindow.webContents.isLoadingMainFrame()) { |
| 974 | targetWindow.webContents.once("did-finish-load", () => void runPromise(dispatch)); |
| 975 | return; |
| 976 | } |
| 977 | yield* dispatch; |
| 978 | }); |
| 979 | |
| 980 | return DesktopWindow.of({ |
| 981 | createMain, |
| 982 | ensureMain, |
| 983 | revealOrCreateMain, |
| 984 | prepareCaptureReveal: Effect.gen(function* () { |
| 985 | const existingWindow = yield* currentMainWindow; |
| 986 | if (Option.isSome(existingWindow)) { |
| 987 | yield* electronWindow.prepareReveal(existingWindow.value); |
| 988 | } |
| 989 | }), |
| 990 | activate: Effect.gen(function* () { |
| 991 | const existingWindow = yield* currentMainWindow; |
| 992 | if (Option.isSome(existingWindow)) { |
| 993 | yield* electronWindow.reveal(existingWindow.value); |
| 994 | return; |
| 995 | } |
| 996 | // No real main window yet. While the backend is still cold-booting, |
| 997 | // re-reveal the connecting splash so taskbar/dock activation brings it |
| 998 | // back instead of doing nothing. Once the backend is ready we fall |
| 999 | // through to (re)create the real main -- including retrying a previously |
| 1000 | // failed open the pool swallowed -- rather than latching onto the splash. |
| 1001 | const backendReady = yield* Ref.get(backendReadyRef); |
| 1002 | if (!backendReady) { |
| 1003 | const splash = yield* Ref.get(splashWindowRef); |
| 1004 | if (Option.isSome(splash)) { |
| 1005 | yield* electronWindow.reveal(splash.value); |
| 1006 | return; |
| 1007 | } |
| 1008 | } |
| 1009 | yield* createMainIfBackendReady; |
| 1010 | }).pipe(Effect.withSpan("desktop.window.activate")), |
| 1011 | createMainIfBackendReady, |
| 1012 | showConnectingSplash, |
| 1013 | handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { |
| 1014 | yield* Ref.set(backendReadyRef, true); |
| 1015 | yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); |
| 1016 | yield* createMainIfBackendReady; |
| 1017 | }), |
| 1018 | handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( |
| 1019 | Effect.withSpan("desktop.window.handleBackendNotReady"), |
| 1020 | ), |
| 1021 | flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( |
| 1022 | Effect.withSpan("desktop.window.flushMainWindowBounds"), |
| 1023 | ), |
| 1024 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action, options) { |
| 1025 | yield* Effect.annotateCurrentSpan({ action }); |
| 1026 | yield* dispatchRendererEvent(MENU_ACTION_CHANNEL, action, options); |
| 1027 | }), |
| 1028 | dispatchSnapShotEvent: Effect.fn("desktop.window.dispatchSnapShotEvent")(function* (event) { |
| 1029 | yield* Effect.annotateCurrentSpan({ |
| 1030 | event: event.type, |
| 1031 | captureId: "id" in event ? (event.id ?? null) : null, |
| 1032 | }); |
| 1033 | yield* dispatchRendererEvent(SNAP_SHOT_EVENT_CHANNEL, event, { |
| 1034 | reveal: event.type === "started", |
| 1035 | }); |
| 1036 | }), |
| 1037 | zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { |
| 1038 | yield* Effect.annotateCurrentSpan({ direction }); |
| 1039 | const window = yield* focusedMainWindow; |
| 1040 | if (Option.isNone(window) || window.value.isDestroyed()) { |
| 1041 | return; |
| 1042 | } |
| 1043 | const webContents = window.value.webContents; |
| 1044 | // Same step size as the Electron zoomIn/zoomOut menu roles. |
| 1045 | webContents.setZoomLevel( |
| 1046 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), |
| 1047 | ); |
| 1048 | if (environment.platform === "darwin") syncMacosWindowButtons(window.value); |
| 1049 | // Chromium pushes the new level down to embedded guests, which would zoom |
| 1050 | // the previewed page along with the app UI. The preview browser keeps its |
| 1051 | // own zoom, so put each guest back where the preview left it. |
| 1052 | yield* previewManager.reapplyZoom(); |
| 1053 | }), |
| 1054 | runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { |
| 1055 | yield* Effect.annotateCurrentSpan({ command }); |
| 1056 | // The registered main window, never the focused one: with an OAuth popup |
| 1057 | // focused, Reload would otherwise reload the popup mid sign-in. |
| 1058 | const window = yield* electronWindow.main; |
| 1059 | if (Option.isNone(window) || window.value.isDestroyed()) return; |
| 1060 | const webContents = window.value.webContents; |
| 1061 | if (command === "reload") webContents.reload(); |
| 1062 | else if (command === "forceReload") webContents.reloadIgnoringCache(); |
| 1063 | else webContents.toggleDevTools(); |
| 1064 | }), |
| 1065 | syncAppearance: Effect.gen(function* () { |
| 1066 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 1067 | yield* electronWindow.syncAllAppearance((window) => |
| 1068 | syncWindowAppearance(window, shouldUseDarkColors, environment.platform), |
| 1069 | ); |
| 1070 | }).pipe(Effect.withSpan("desktop.window.syncAppearance")), |
| 1071 | }); |
| 1072 | }); |
| 1073 | |
| 1074 | export const layer = Layer.effect(DesktopWindow, make); |