1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";
8
9import * as Electron from "electron";
10
11import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
12
13import * as DesktopAssets from "../app/DesktopAssets.ts";
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import { makeComponentLogger } from "../app/DesktopObservability.ts";
16import * as ElectronMenu from "../electron/ElectronMenu.ts";
17import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
18import * as ElectronShell from "../electron/ElectronShell.ts";
19import * as ElectronTheme from "../electron/ElectronTheme.ts";
20import * as ElectronWindow from "../electron/ElectronWindow.ts";
21import {
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";
28import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts";
29import * as PreviewManager from "../preview/Manager.ts";
30import * as PreviewPasskeys from "../preview/Passkeys.ts";
31import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
32import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
33import * as ElectronApp from "../electron/ElectronApp.ts";
34import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
35import { makeQuitShortcutHandler } from "./QuitHold.ts";
36import { copyContextMenuImage } from "./ContextMenuImage.ts";
37
38const 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.
41const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52;
42const MACOS_WINDOW_BUTTON_RADIUS = 7;
43
44function 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
55const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
56const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
57const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
58const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500;
59const 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.
63const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500;
64const RENDERER_RECOVERY_MAX_ATTEMPTS = 3;
65const RENDERER_RECOVERY_WINDOW_MS = 60_000;
66const 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
76type WindowTitleBarOptions = Pick<
77 Electron.BrowserWindowConstructorOptions,
78 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
79>;
80
81type 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
94export type DesktopWindowError =
95 | ElectronWindow.ElectronWindowCreateError
96 | PreviewManager.PreviewManagerError;
97
98export type MainWindowZoomDirection = "in" | "out" | "reset";
99export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools";
100
101export 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
152const { logInfo: logWindowInfo, logWarning: logWindowWarning } =
153 makeComponentLogger("desktop-window");
154
155function 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
167function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
168 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
169}
170
171type DisplayBounds = Pick<Electron.Rectangle, "x" | "y" | "width" | "height">;
172
173function 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
185function 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
197export 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.
213function 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
222export 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
233export 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
249export 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
264function 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
288function 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
306type RevealSubscription = (listener: () => void) => void;
307
308function 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. */
324export 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
1074export const layer = Layer.effect(DesktopWindow, make);