405 lines · 13.7 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts";import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { makeComponentLogger } from "../app/DesktopObservability.ts";import * as DesktopState from "../app/DesktopState.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 } from "../ipc/channels.ts";import * as PreviewManager from "../preview/Manager.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"; type WindowTitleBarOptions = Pick<  Electron.BrowserWindowConstructorOptions,  "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition">; type DesktopWindowRuntimeServices =  | DesktopEnvironment.DesktopEnvironment  | DesktopAssets.DesktopAssets  | DesktopState.DesktopState  | ElectronMenu.ElectronMenu  | ElectronShell.ElectronShell  | ElectronTheme.ElectronTheme  | ElectronWindow.ElectronWindow  | PreviewManager.PreviewManager; export type DesktopWindowError =  | ElectronWindow.ElectronWindowCreateError  | PreviewManager.PreviewManagerError; 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>;    readonly handleBackendReady: Effect.Effect<void, DesktopWindowError>;    readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;    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";} 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;  }} 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);  }} 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 state = yield* DesktopState.DesktopState;  const context = yield* Effect.context<DesktopWindowRuntimeServices>();  const runPromise = Effect.runPromiseWith(context);   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 window = yield* electronWindow.create({      width: 1100,      height: 780,      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,        contextIsolation: true,        nodeIntegration: false,        sandbox: true,        webviewTag: true,      },    });     if (environment.platform === "darwin") {      window.setAutoHideCursor(false);    }     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;      }      webPreferences.sandbox = true;      webPreferences.nodeIntegration = false;      webPreferences.nodeIntegrationInSubFrames = false;      webPreferences.contextIsolation = false;    });     window.webContents.on("context-menu", (event, params) => {      event.preventDefault();       const menuTemplate: Electron.MenuItemConstructorOptions[] = [];       if (params.misspelledWord) {        for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {          menuTemplate.push({            label: suggestion,            click: () => window.webContents.replaceMisspelling(suggestion),          });        }        if (params.dictionarySuggestions.length === 0) {          menuTemplate.push({ label: "No suggestions", enabled: false });        }        menuTemplate.push({ type: "separator" });      }       if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {        menuTemplate.push(          {            label: "Copy Link",            click: () => {              void runPromise(                electronShell.copyText(params.linkURL).pipe(Effect.ignore({ log: true })),              );            },          },          { type: "separator" },        );      }       if (params.mediaType === "image") {        menuTemplate.push({          label: "Copy Image",          click: () => window.webContents.copyImageAt(params.x, params.y),        });        menuTemplate.push({ type: "separator" });      }       menuTemplate.push(        { role: "cut", enabled: params.editFlags.canCut },        { role: "copy", enabled: params.editFlags.canCopy },        { role: "paste", enabled: params.editFlags.canPaste },        { role: "selectAll", enabled: params.editFlags.canSelectAll },      );       void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));    });     window.webContents.setWindowOpenHandler(({ url }) => {      if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {        void runPromise(electronShell.openExternal(url).pipe(Effect.ignore({ log: true })));      }      return { action: "deny" };    });    window.webContents.on("will-navigate", (event, url) => {      if (        isSameOriginRendererNavigation({          applicationUrl,          navigationUrl: url,        })      ) {        return;      }       event.preventDefault();      if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {        void runPromise(electronShell.openExternal(url).pipe(Effect.ignore({ log: true })));      }    });     window.on("page-title-updated", (event) => {      event.preventDefault();      window.setTitle(environment.displayName);    });    window.webContents.on("did-finish-load", () => {      window.setTitle(environment.displayName);    });    window.webContents.on(      "did-fail-load",      (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {        if (!isMainFrame) {          return;        }        void runPromise(          logWindowWarning("main window failed to load", {            errorCode,            errorDescription,            url: validatedURL,          }),        );      },    );    window.webContents.on("render-process-gone", (_event, details) => {      void runPromise(        logWindowWarning("main window render process gone", {          reason: details.reason,          exitCode: details.exitCode,        }),      );    });     const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];    if (environment.platform === "linux") {      revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));    }    bindFirstRevealTrigger(revealSubscribers, () => {      void runPromise(electronWindow.reveal(window));    });     if (environment.isDevelopment) {      void window.loadURL(applicationUrl);      window.webContents.openDevTools({ mode: "detach" });    } else {      void window.loadURL(applicationUrl);    }     window.on("closed", () => {      void runPromise(electronWindow.clearMain(Option.some(window)));    });     return window;  });   const createMain = Effect.gen(function* () {    const window = yield* createWindow();    yield* electronWindow.setMain(window);    yield* logWindowInfo("main window created");    return window;  }).pipe(Effect.withSpan("desktop.window.createMain"));   const ensureMain = Effect.gen(function* () {    const existingWindow = yield* electronWindow.currentMainOrFirst;    if (Option.isSome(existingWindow)) {      return existingWindow.value;    }    return yield* createMain;  }).pipe(Effect.withSpan("desktop.window.ensureMain"));   const revealOrCreateMain = Effect.gen(function* () {    const window = yield* ensureMain;    yield* electronWindow.reveal(window);    return window;  }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));   const createMainIfBackendReady = Effect.gen(function* () {    const backendReady = yield* Ref.get(state.backendReady);    if (!backendReady) return;    const existingWindow = yield* electronWindow.currentMainOrFirst;    if (Option.isSome(existingWindow)) return;    yield* createMain;  }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady"));   return DesktopWindow.of({    createMain,    ensureMain,    revealOrCreateMain,    activate: Effect.gen(function* () {      const existingWindow = yield* electronWindow.currentMainOrFirst;      if (Option.isSome(existingWindow)) {        yield* electronWindow.reveal(existingWindow.value);      } else {        yield* createMainIfBackendReady;      }    }).pipe(Effect.withSpan("desktop.window.activate")),    createMainIfBackendReady,    handleBackendReady: Effect.gen(function* () {      yield* Ref.set(state.backendReady, true);      yield* logWindowInfo("backend ready", { source: "http" });      yield* createMainIfBackendReady;    }).pipe(Effect.withSpan("desktop.window.handleBackendReady")),    dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {      yield* Effect.annotateCurrentSpan({ action });      const existingWindow = yield* electronWindow.focusedMainOrFirst;      const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain;       const send = () => {        if (targetWindow.isDestroyed()) return;        targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);        void runPromise(electronWindow.reveal(targetWindow));      };       if (targetWindow.webContents.isLoadingMainFrame()) {        targetWindow.webContents.once("did-finish-load", send);        return;      }       send();    }),    syncAppearance: Effect.gen(function* () {      const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;      yield* electronWindow.syncAllAppearance((window) =>        syncWindowAppearance(window, shouldUseDarkColors, environment.platform),      );    }).pipe(Effect.withSpan("desktop.window.syncAppearance")),  });}); export const layer = Layer.effect(DesktopWindow, make);