diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index 6c996db..0525cfb 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -13,7 +13,9 @@ "scripts": { "dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev --inspect=9229", "build": "electron-vite build", - "package:mac": "electron-builder --mac --arm64", + "package:mac": "pnpm run build && electron-builder --mac --arm64,universal", + "package:win": "pnpm run build && electron-builder --win --x64", + "package:linux": "pnpm run build && electron-builder --linux --x64,arm64", "sign:setup": "bash scripts/sign-setup.sh", "sign:mac": "bash scripts/sign-mac.sh", "test": "vitest run", diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index d458426..218b4d2 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -5,6 +5,7 @@ import { shell } from "electron"; import { BrowserWindow, app, ipcMain, nativeTheme } from "electron/main"; import type { BrowserWindow as ElectronBrowserWindow, + IpcMainInvokeEvent, WebContents as ElectronWebContents, } from "electron/main"; import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; @@ -52,9 +53,29 @@ app.commandLine.appendSwitch( ); app.commandLine.appendSwitch("disable-quic"); app.commandLine.appendSwitch("disable-background-networking"); +// CDP is required by the hidden Playwright execution pool in packaged builds. app.commandLine.appendSwitch("remote-debugging-port", String(getPlaywrightCDPPort())); app.userAgentFallback = STANDARD_USER_AGENT; -installObservedGlobalFetch(); + +const isDevelopmentRuntime = !app.isPackaged; + +function silencePackagedConsole(): void { + if (isDevelopmentRuntime) { + return; + } + + const noop = () => undefined; + console.log = noop; + console.info = noop; + console.warn = noop; + console.error = noop; + console.debug = noop; +} + +silencePackagedConsole(); +if (isDevelopmentRuntime) { + installObservedGlobalFetch(); +} console.info("[desktop-main] boot", { electron: process.versions.electron, @@ -77,6 +98,7 @@ let settingsWindow: ElectronBrowserWindow | null = null; let mainRendererContents: ElectronWebContents | null = null; let quitReleaseInFlight = false; const forceCloseWindows = new WeakSet(); +const appWindowModes = new WeakMap(); type WindowMode = "login" | "main"; type RendererWindowMode = WindowMode | "settings"; @@ -95,6 +117,8 @@ interface WindowModeRequest { animate?: boolean; } +const WINDOW_READY_TIMEOUT_MS = 1200; + const WINDOW_SIZES: Record = { login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false }, main: { width: 1320, height: 860, minWidth: 900, minHeight: 600, resizable: true }, @@ -187,15 +211,87 @@ function currentActiveWindow(): ElectronBrowserWindow | null { return currentMainWindow() ?? currentLoginWindow(); } -function closeWindowAfterIpcReturn(window: ElectronBrowserWindow): void { +function rendererModeFromWindow(window: ElectronBrowserWindow): RendererWindowMode | null { + if (window.isDestroyed()) { + return null; + } + + const knownMode = appWindowModes.get(window); + if (knownMode) { + return knownMode; + } + + try { + const url = new URL(window.webContents.getURL()); + const mode = url.searchParams.get("window"); + return mode === "login" || mode === "main" || mode === "settings" ? mode : null; + } catch { + return null; + } +} + +function retireWindowAfterIpcReturn(window: ElectronBrowserWindow): void { + if (window.isDestroyed()) { + return; + } + forceCloseWindows.add(window); + if (window.isVisible()) { + window.hide(); + } setTimeout(() => { if (!window.isDestroyed()) { - window.close(); + window.destroy(); } }, 50); } +function retireInactiveAppWindows(mode: WindowMode, target: ElectronBrowserWindow): void { + for (const candidate of BrowserWindow.getAllWindows()) { + if (candidate === target || candidate.isDestroyed()) { + continue; + } + + const candidateMode = rendererModeFromWindow(candidate); + if ( + (mode === "main" && (candidateMode === "login" || candidateMode === "main")) + || (mode === "login" && (candidateMode === "main" || candidateMode === "settings")) + ) { + retireWindowAfterIpcReturn(candidate); + } + } +} + +async function waitForWindowReady(window: ElectronBrowserWindow): Promise { + if (window.isDestroyed() || !window.webContents.isLoading()) { + return; + } + + await new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + window.off("ready-to-show", finish); + window.webContents.off("did-finish-load", finish); + window.webContents.off("did-fail-load", finish); + resolve(); + }; + + timeout = setTimeout(finish, WINDOW_READY_TIMEOUT_MS); + window.once("ready-to-show", finish); + window.webContents.once("did-finish-load", finish); + window.webContents.once("did-fail-load", finish); + }); +} + function captureRuntimeSnapshot() { const snapshot = createRuntimeSnapshot(); applyDesktopHealthIndicatorFromSnapshot(snapshot, currentMainWindow()); @@ -241,6 +337,7 @@ async function ensureWindow(mode: WindowMode): Promise { } async function revealWindow(window: ElectronBrowserWindow): Promise { + await waitForWindowReady(window); if (window.isMinimized()) { window.restore(); } @@ -262,12 +359,28 @@ function revealActiveWindowSafely(source: string): void { }); } -async function switchWindowMode(mode: WindowMode, _request: WindowModeRequest = {}): Promise { +function windowFromIpcEvent(event: IpcMainInvokeEvent): ElectronBrowserWindow | null { + const window = BrowserWindow.fromWebContents(event.sender); + if (!window || window.isDestroyed()) { + return null; + } + return window; +} + +async function switchWindowMode( + mode: WindowMode, + _request: WindowModeRequest = {}, + sourceWindow: ElectronBrowserWindow | null = null, +): Promise { currentWindowMode = mode; persistWindowMode(mode); const target = await ensureWindow(mode); - const previous = mode === "main" ? currentLoginWindow() : currentMainWindow(); + const previous = sourceWindow && sourceWindow !== target + ? sourceWindow + : mode === "main" + ? currentLoginWindow() + : currentMainWindow(); if (!target.isVisible()) { target.center(); @@ -275,13 +388,14 @@ async function switchWindowMode(mode: WindowMode, _request: WindowModeRequest = await revealWindow(target); if (previous && previous !== target && !previous.isDestroyed()) { - closeWindowAfterIpcReturn(previous); + retireWindowAfterIpcReturn(previous); } const settings = currentSettingsWindow(); if (mode === "login" && settings) { - closeWindowAfterIpcReturn(settings); + retireWindowAfterIpcReturn(settings); } + retireInactiveAppWindows(mode, target); } async function openSettingsWindow(): Promise { @@ -303,7 +417,7 @@ async function loadRenderer(window: ElectronBrowserWindow, mode: RendererWindowM const url = new URL(devServerURL); url.searchParams.set("window", mode); await window.webContents.loadURL(url.toString()); - if (mode === "main") { + if (mode === "main" && isDevelopmentRuntime) { window.webContents.openDevTools({ mode: "detach" }); } return; @@ -319,8 +433,10 @@ async function mountRendererWebContents(window: ElectronBrowserWindow, mode: Ren contents.setWindowOpenHandler(() => ({ action: "deny" })); if (mode === "main") { - registerRendererDevtoolsProxyTarget(contents); - registerObservedRequestRendererTarget(contents); + if (isDevelopmentRuntime) { + registerRendererDevtoolsProxyTarget(contents); + registerObservedRequestRendererTarget(contents); + } mainRendererContents = contents; window.on("closed", () => { if (mainRendererContents === contents) { @@ -355,6 +471,7 @@ async function createAppWindow(mode: WindowMode): Promise nodeIntegration: false, }, }); + appWindowModes.set(window, mode); if (!initial.resizable) { window.setMinimumSize(initial.minWidth, initial.minHeight); @@ -430,6 +547,7 @@ async function createSettingsWindow(): Promise { nodeIntegration: false, }, }); + appWindowModes.set(window, "settings"); window.once("closed", () => { if (settingsWindow === window) { @@ -568,9 +686,9 @@ function registerBridgeHandlers(): void { await releaseRuntimeSession({ revoke: Boolean(revoke) }); return null; }); - ipcMain.handle("desktop:set-window-mode", async (_event, mode: WindowMode, request?: unknown) => { + ipcMain.handle("desktop:set-window-mode", async (event, mode: WindowMode, request?: unknown) => { if (mode === "login" || mode === "main") { - await switchWindowMode(mode, normalizeWindowModeRequest(request)); + await switchWindowMode(mode, normalizeWindowModeRequest(request), windowFromIpcEvent(event)); } return null; }); diff --git a/apps/desktop-client/src/renderer/App.vue b/apps/desktop-client/src/renderer/App.vue index 40ac20d..85d0109 100644 --- a/apps/desktop-client/src/renderer/App.vue +++ b/apps/desktop-client/src/renderer/App.vue @@ -1,9 +1,9 @@