import * as Context from "effect/Context"; import * as Data from "effect/Data"; 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 * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const 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 | DesktopServerExposure.DesktopServerExposure | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme | ElectronWindow.ElectronWindow; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", )<{}> { override get message() { return "VITE_DEV_SERVER_URL is required in desktop development."; } } export type DesktopWindowError = | DesktopWindowDevServerUrlMissingError | ElectronWindow.ElectronWindowCreateError; export interface DesktopWindowShape { readonly createMain: Effect.Effect; readonly ensureMain: Effect.Effect; readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; readonly handleBackendReady: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } export class DesktopWindow extends Context.Service()( "@t3tools/desktop/window/DesktopWindow", ) {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = DesktopObservability.makeComponentLogger("desktop-window"); function resolveDesktopDevServerUrl( environment: DesktopEnvironment.DesktopEnvironmentShape, ): Effect.Effect { return Option.match(environment.devServerUrl, { onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), onSome: (url) => Effect.succeed(url.href), }); } function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, ): { icon: string } | Record { if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle const ext = process.platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), onSome: (icon) => ({ icon }), }); } function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { if (process.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, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { return; } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); 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); } } 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 serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const window = yield* electronWindow.create({ width: 1100, height: 780, minWidth: 840, minHeight: 620, show: false, autoHideMenuBar: true, backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, ...getWindowTitleBarOptions(shouldUseDarkColors), webPreferences: { preload: environment.preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); 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)); }, }, { 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)); } return { action: "deny" }; }); 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 (process.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { void runPromise(electronWindow.reveal(window)); }); if (environment.isDevelopment) { const devServerUrl = yield* resolveDesktopDevServerUrl(environment); void window.loadURL(devServerUrl); window.webContents.openDevTools({ mode: "detach" }); } else { void window.loadURL(backendHttpUrl.href); } window.on("closed", () => { void runPromise(electronWindow.clearMain(Option.some(window))); }); return window; }); const createMain = Effect.gen(function* () { const backendConfig = yield* serverExposure.backendConfig; const window = yield* createWindow(backendConfig.httpBaseUrl); 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(IpcChannels.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), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); }); export const layer = Layer.effect(DesktopWindow, make);