1001 lines · 38.7 KB · TypeScript
RawDownload
import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref"; import * as Electron from "electron"; import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts";import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { makeComponentLogger } from "../app/DesktopObservability.ts";import * as ElectronMenu from "../electron/ElectronMenu.ts";import { getDesktopUrl } from "../electron/ElectronProtocol.ts";import * as ElectronShell from "../electron/ElectronShell.ts";import * as ElectronTheme from "../electron/ElectronTheme.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts";import {  MENU_ACTION_CHANNEL,  QUIT_SHORTCUT_CHANNEL,  SNAP_SHOT_EVENT_CHANNEL,  WINDOW_FULLSCREEN_STATE_CHANNEL,} from "../ipc/channels.ts";import * as PreviewManager from "../preview/Manager.ts";import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";import * as ElectronApp from "../electron/ElectronApp.ts";import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40;const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linuxconst TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500;const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;// Renderer crash (usually V8 OOM on long sessions) recovery: reload after a// short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer// that dies on boot cannot reload-loop forever.const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500;const RENDERER_RECOVERY_MAX_ATTEMPTS = 3;const RENDERER_RECOVERY_WINDOW_MS = 60_000;const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([  -2, // ERR_FAILED  -7, // ERR_TIMED_OUT  -9, // ERR_UNEXPECTED (custom protocol handler rejected)  -102, // ERR_CONNECTION_REFUSED  -105, // ERR_NAME_NOT_RESOLVED  -106, // ERR_INTERNET_DISCONNECTED  -118, // ERR_CONNECTION_TIMED_OUT]); type WindowTitleBarOptions = Pick<  Electron.BrowserWindowConstructorOptions,  "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition">; type DesktopWindowRuntimeServices =  | DesktopEnvironment.DesktopEnvironment  | DesktopAssets.DesktopAssets  | DesktopAppSettings.DesktopAppSettings  | DesktopClientSettings.DesktopClientSettings  | ElectronApp.ElectronApp  | ElectronMenu.ElectronMenu  | ElectronShell.ElectronShell  | ElectronTheme.ElectronTheme  | ElectronWindow.ElectronWindow  | PreviewManager.PreviewManager; export type DesktopWindowError =  | ElectronWindow.ElectronWindowCreateError  | PreviewManager.PreviewManagerError; export type MainWindowZoomDirection = "in" | "out" | "reset"; export class DesktopWindow extends Context.Service<  DesktopWindow,  {    readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;    readonly ensureMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;    readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;    readonly activate: Effect.Effect<void, DesktopWindowError>;    readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;    // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only    // mode), before the WSL backend that serves the renderer is ready. It is    // dismissed automatically once the real main window reveals.    readonly showConnectingSplash: Effect.Effect<void>;    // Marks the primary backend as ready so `createMainIfBackendReady` and the    // macOS "activate without windows" path may open the real main window. The    // renderer now always loads the local client URL (getDesktopUrl) and connects    // to the backend through the connection layer, so the reported httpBaseUrl is    // no longer used to point the window at the backend — it is kept only for the    // readiness log and to preserve the callback contract the backend pool drives.    readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect<void, DesktopWindowError>;    // Called when the backend transitions back to "not ready" (clean stop,    // restart, crash). Clears the latch that lets `activate` auto-create a    // window so a "macOS dock click" while the backend is down doesn't    // produce a stranded window pointing at nothing.    readonly handleBackendNotReady: Effect.Effect<void>;    readonly flushMainWindowBounds: Effect.Effect<void>;    readonly prepareCaptureReveal: Effect.Effect<void>;    readonly dispatchMenuAction: (      action: string,      options?: { readonly reveal?: boolean },    ) => Effect.Effect<void, DesktopWindowError>;    /**     * Push a capture lifecycle event to the renderer. Only `started` reveals the     * window; the rest must not interrupt the app the user has switched to.     */    readonly dispatchSnapShotEvent: (      event: DesktopSnapShotEvent,    ) => Effect.Effect<void, DesktopWindowError>;    // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut`    // menu roles act on whichever webContents has keyboard focus, so with an    // embedded preview WebContentsView (or DevTools) focused they zoom the    // guest page instead of the app UI. The menu routes here to always target    // the main window.    readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>;    readonly syncAppearance: Effect.Effect<void>;  }>()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } =  makeComponentLogger("desktop-window"); function getIconOption(  iconPaths: DesktopAssets.DesktopIconPaths,  platform: NodeJS.Platform,): { icon: string } | Record<string, never> {  if (platform === "darwin") return {}; // macOS uses .icns from app bundle  const ext = platform === "win32" ? "ico" : "png";  return Option.match(iconPaths[ext], {    onNone: () => ({}),    onSome: (icon) => ({ icon }),  });} function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {  return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";} type DisplayBounds = Pick<Electron.Rectangle, "x" | "y" | "width" | "height">; function windowFitsWithinDisplay(  windowBounds: DesktopAppSettings.DesktopWindowBounds,  displayBounds: DisplayBounds,): boolean {  return (    windowBounds.x >= displayBounds.x &&    windowBounds.y >= displayBounds.y &&    windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width &&    windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height  );} function windowBoundsEqual(  left: DesktopAppSettings.DesktopWindowBounds,  right: DesktopAppSettings.DesktopWindowBounds,): boolean {  return (    left.x === right.x &&    left.y === right.y &&    left.width === right.width &&    left.height === right.height  );} export function resolveInitialMainWindowBounds(  persistedBounds: DesktopAppSettings.DesktopWindowBounds | null,  displays: readonly DisplayBounds[],): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE {  if (    persistedBounds !== null &&    displays.some((display) => windowFitsWithinDisplay(persistedBounds, display))  ) {    return persistedBounds;  }  return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE;} // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as// a data URL so it needs no bundled asset and no backend — pure CSS, no JS.function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string {  const background = getInitialWindowBackgroundColor(shouldUseDarkColors);  const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280";  const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937";  const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)";  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>`;  return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;} export function isSameOriginRendererNavigation(input: {  readonly applicationUrl: string;  readonly navigationUrl: string;}): boolean {  try {    return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin;  } catch {    return false;  }} export function isRetryableDevelopmentRendererLoadFailure(input: {  readonly applicationUrl: string;  readonly errorCode: number;  readonly isMainFrame: boolean;  readonly validatedUrl: string;}): boolean {  return (    input.isMainFrame &&    DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES.has(input.errorCode) &&    isSameOriginRendererNavigation({      applicationUrl: input.applicationUrl,      navigationUrl: input.validatedUrl,    })  );} export function concealPendingQuitWindow(  window: Pick<    Electron.BrowserWindow,    "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity"  >,): void {  if (window.isDestroyed()) return;  if (window.isFullScreen()) {    window.setFullScreen(false);  }  // Electron implements window opacity on macOS and Windows. Linux keeps the  // release-gated quit behavior but cannot make the pending window disappear.  window.setOpacity(0);} function getWindowTitleBarOptions(  shouldUseDarkColors: boolean,  platform: NodeJS.Platform,): WindowTitleBarOptions {  if (platform === "darwin") {    return {      titleBarStyle: "hiddenInset",      trafficLightPosition: { x: 16, y: 18 },    };  }   return {    titleBarStyle: "hidden",    titleBarOverlay: {      color: TITLEBAR_COLOR,      height: TITLEBAR_HEIGHT,      symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR,    },  };} function syncWindowAppearance(  window: Electron.BrowserWindow,  shouldUseDarkColors: boolean,  platform: NodeJS.Platform,): Effect.Effect<void> {  return Effect.sync(() => {    if (window.isDestroyed()) {      return;    }     window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors));    const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform);    if (typeof titleBarOverlay === "object") {      window.setTitleBarOverlay(titleBarOverlay);    }  });} type RevealSubscription = (listener: () => void) => void; function bindFirstRevealTrigger(  subscribers: readonly RevealSubscription[],  reveal: () => void,): void {  let revealed = false;  const fire = () => {    if (revealed) return;    revealed = true;    reveal();  };  for (const subscribe of subscribers) {    subscribe(fire);  }} /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () {  const environment = yield* DesktopEnvironment.DesktopEnvironment;  const assets = yield* DesktopAssets.DesktopAssets;  const electronMenu = yield* ElectronMenu.ElectronMenu;  const electronShell = yield* ElectronShell.ElectronShell;  const electronTheme = yield* ElectronTheme.ElectronTheme;  const electronWindow = yield* ElectronWindow.ElectronWindow;  const previewManager = yield* PreviewManager.PreviewManager;  const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;  const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;  const electronApp = yield* ElectronApp.ElectronApp;  // Window-side latch for the primary backend's readiness. Set by  // handleBackendReady (driven by the pool's onReady callback), cleared  // by handleBackendNotReady (driven by onShutdown). Only consumed by  // createMainIfBackendReady, which gates the post-readiness window  // open in development and the macOS "activate without windows" path.  const backendReadyRef = yield* Ref.make(false);  // The transient "Connecting to WSL" splash window, tracked separately so it  // is never mistaken for the real main window.  const splashWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());  const context = yield* Effect.context<DesktopWindowRuntimeServices>();  const runFork = Effect.runForkWith(context);  const runPromise = Effect.runPromiseWith(context);  let flushMainWindowBounds: Effect.Effect<void> = Effect.void;   const dismissConnectingSplash = Effect.gen(function* () {    const splash = yield* Ref.getAndSet(splashWindowRef, Option.none());    if (Option.isSome(splash) && !splash.value.isDestroyed()) {      splash.value.close();    }  });   // currentMainOrFirst / focusedMainOrFirst fall back to "any first window",  // which during WSL-only boot is the connecting splash. The splash is never  // registered via setMain, so it must be treated as "no real main window" --  // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never  // open (or retry) the real main. That is the failure the pool's swallowed  // post-readiness window-open error would otherwise strand the user in:  // splash up, backend ready, no main, and activation only re-reveals splash.  const withoutSplash = (window: Option.Option<Electron.BrowserWindow>) =>    Ref.get(splashWindowRef).pipe(      Effect.map((splash) =>        Option.isSome(splash) && Option.isSome(window) && window.value === splash.value          ? Option.none<Electron.BrowserWindow>()          : window,      ),    );   const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash));  const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash));   const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return<    Electron.BrowserWindow,    DesktopWindowError  > {    yield* previewManager.getBrowserSession();    const applicationUrl = getDesktopUrl(environment.isDevelopment);    const iconPaths = yield* assets.iconPaths;    const iconOption = getIconOption(iconPaths, environment.platform);    const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;    const persistedSettings = yield* desktopSettings.get;    const persistedBounds = persistedSettings.mainWindowBounds;    const displayBoundsResult = yield* Effect.sync(() => {      try {        return {          _tag: "Success" as const,          bounds: Electron.screen.getAllDisplays().map((display) => display.bounds),        };      } catch (cause) {        return { _tag: "Failure" as const, cause };      }    });    const displayBounds =      displayBoundsResult._tag === "Success"        ? displayBoundsResult.bounds        : yield* logWindowWarning("failed to read connected displays; using defaults", {            cause: displayBoundsResult.cause,          }).pipe(Effect.as<readonly Electron.Rectangle[]>([]));    const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds);    const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds;    if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) {      yield* logWindowWarning("saved main window bounds could not be restored; using defaults");    }    const window = yield* electronWindow.create({      ...initialBounds,      minWidth: 840,      minHeight: 620,      show: false,      autoHideMenuBar: true,      ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}),      backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),      ...iconOption,      title: environment.displayName,      ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform),      webPreferences: {        preload: environment.preloadPath,        // The window boots hidden (show: false until ready-to-show), and        // Chromium throttles hidden renderers: timers coalesce and rAF stops,        // which stalls first paint. Boot unthrottled; the first-reveal trigger        // re-enables throttling so a hidden or minimized window goes back to        // being cheap after it has been shown once.        backgroundThrottling: false,        contextIsolation: true,        nodeIntegration: false,        sandbox: true,        webviewTag: true,      },    });     if (environment.platform === "darwin") {      window.setAutoHideCursor(false);    }    let boundsPersistFiber: Fiber.Fiber<void, never> | undefined;    let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined;    let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds;    const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => {      if (window.isDestroyed()) {        return null;      }      const bounds =        window.isFullScreen() || window.isMaximized() || window.isMinimized()          ? window.getNormalBounds()          : window.getBounds();      return DesktopAppSettings.normalizeMainWindowBounds({        x: Math.round(bounds.x),        y: Math.round(bounds.y),        width: Math.round(bounds.width),        height: Math.round(bounds.height),      });    };    const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds();    const fallbackWindowMaximized = persistedSettings.mainWindowMaximized;    const persistCurrentBounds = (): Fiber.Fiber<void, never> | undefined => {      if (!boundsPersistenceEnabled) {        return pendingBoundsPersistFiber;      }      const bounds = readPersistableBounds();      if (bounds === null) {        return pendingBoundsPersistFiber;      }      pendingBoundsPersistFiber = runFork(        desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe(          Effect.asVoid,          Effect.catch((error) =>            logWindowWarning("failed to persist main window bounds", {              message: error.message,            }),          ),        ),      );      return pendingBoundsPersistFiber;    };    const scheduleBoundsPersist = () => {      if (!boundsPersistenceEnabled) {        const currentBounds = readPersistableBounds();        if (          currentBounds === null ||          (fallbackWindowBounds !== null &&            windowBoundsEqual(currentBounds, fallbackWindowBounds) &&            window.isMaximized() === fallbackWindowMaximized)        ) {          return;        }      }      boundsPersistenceEnabled = true;      if (boundsPersistFiber !== undefined) {        const fiber = boundsPersistFiber;        boundsPersistFiber = undefined;        runFork(Fiber.interrupt(fiber));      }      boundsPersistFiber = runFork(        Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe(          Effect.andThen(            Effect.sync(() => {              boundsPersistFiber = undefined;              void persistCurrentBounds();            }),          ),        ),      );    };    const clearBoundsPersist = () => {      if (boundsPersistFiber === undefined) {        return;      }      const fiber = boundsPersistFiber;      boundsPersistFiber = undefined;      runFork(Fiber.interrupt(fiber));    };    const flushBoundsPersist = Effect.sync(() => {      clearBoundsPersist();      return persistCurrentBounds();    }).pipe(      Effect.flatMap((fiber) =>        fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid),      ),    );    flushMainWindowBounds = flushBoundsPersist;     yield* previewManager.setMainWindow(window);    window.webContents.on("will-attach-webview", (event, webPreferences, params) => {      if (        typeof params.partition !== "string" ||        !previewManager.isBrowserPartition(params.partition)      ) {        event.preventDefault();        return;

Lines 1–500 of 1001.