import { app, BrowserWindow, ipcMain, nativeImage } from "electron"; import { homedir } from "os"; import { join } from "path"; import { electronApp, optimizer, is } from "@electron-toolkit/utils"; import fixPath from "fix-path"; import { setupAutoUpdater } from "./updater"; import { setupDaemonManager } from "./daemon-manager"; import { openExternalSafely } from "./external-url"; // Bundled icon used for dev-mode dock/taskbar branding. In production the // app bundle icon (from electron-builder) wins; this path is only consumed // by the `is.dev` branch below. const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png"); // macOS/Linux GUI launches inherit a minimal PATH from launchd that omits // the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.). // Run the user's login shell once to recover the real PATH so the bundled // multica CLI can find agent binaries like claude/codex/opencode. Must run // before any child_process.spawn / execFile call in the main process — // ES module imports are hoisted, so this block executes before createWindow // or any daemon-manager spawn. if (process.platform !== "win32") { fixPath(); // Fallback: prepend common install locations in case fix-path came up // short (broken shell rc, non-interactive $SHELL, missing entries). Safe // to duplicate — PATH lookups short-circuit on first match. const fallbackPaths = [ "/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".local/bin"), ]; process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`; } const PROTOCOL = "multica"; let mainWindow: BrowserWindow | null = null; // --- Deep link helpers --------------------------------------------------- function handleDeepLink(url: string): void { try { const parsed = new URL(url); if (parsed.protocol !== `${PROTOCOL}:`) return; // multica://auth/callback?token= if (parsed.hostname === "auth" && parsed.pathname === "/callback") { const token = parsed.searchParams.get("token"); if (token && mainWindow) { mainWindow.webContents.send("auth:token", token); } return; } // multica://invite/ // Dispatched from the web invite page when the user chooses "Open in // desktop app". The renderer opens the invite overlay — no tab, no // route persistence, so deep-linking the same invite twice stays safe. if (parsed.hostname === "invite") { const id = parsed.pathname.replace(/^\//, ""); if (id && mainWindow) { mainWindow.webContents.send("invite:open", decodeURIComponent(id)); } return; } } catch { // Ignore malformed URLs } } // --- Window creation ----------------------------------------------------- function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 900, minHeight: 600, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 13 }, show: false, autoHideMenuBar: true, // Windows/Linux pick up the window/taskbar icon from this option in // dev — on macOS it's ignored (dock comes from app.dock.setIcon below). ...(is.dev ? { icon: DEV_ICON_PATH } : {}), webPreferences: { preload: join(__dirname, "../preload/index.js"), sandbox: false, webSecurity: false, }, }); // Strip Origin header from WebSocket upgrade requests so the server's // origin whitelist doesn't reject connections from localhost dev origins. mainWindow.webContents.session.webRequest.onBeforeSendHeaders( { urls: ["wss://*/*", "ws://*/*"] }, (details, callback) => { delete details.requestHeaders["Origin"]; callback({ requestHeaders: details.requestHeaders }); }, ); mainWindow.on("ready-to-show", () => { mainWindow?.show(); }); mainWindow.webContents.setWindowOpenHandler((details) => { openExternalSafely(details.url); return { action: "deny" }; }); if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); } else { mainWindow.loadFile(join(__dirname, "../renderer/index.html")); } } // --- Dev / production isolation ------------------------------------------- // Give dev mode a separate app name and userData path so it gets its own // single-instance lock file and doesn't conflict with the packaged production // app. Must run BEFORE requestSingleInstanceLock() because the lock location // is derived from the userData path. (Same approach VS Code uses for // Stable / Insiders coexistence.) // DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side // without fighting for the shared single-instance lock. The suffix is // appended to the app name + userData path, so each worktree gets its own // lock file. Default (no env var) keeps behavior unchanged — the common // single-worktree case still lands at "Multica Canary". const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX ? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}` : "Multica Canary"; if (is.dev) { app.setName(DEV_APP_NAME); app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME)); } // --- Protocol registration ----------------------------------------------- if (process.defaultApp) { // In dev, register with the path to the electron binary + app path app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [ app.getAppPath(), ]); } else { app.setAsDefaultProtocolClient(PROTOCOL); } // --- Single instance lock ------------------------------------------------ const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { // Windows/Linux: second instance passes deep link via argv app.on("second-instance", (_event, argv) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } // On Windows the deep link URL is the last argv entry const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); if (deepLinkUrl) handleDeepLink(deepLinkUrl); }); app.whenReady().then(() => { electronApp.setAppUserModelId( is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop", ); // macOS: replace the default Electron dock icon with the bundled logo // so the Canary dev build is visually distinct from a stock Electron // run. `app.dock` is macOS-only — guard the call. if (is.dev && process.platform === "darwin" && app.dock) { const icon = nativeImage.createFromPath(DEV_ICON_PATH); if (!icon.isEmpty()) app.dock.setIcon(icon); } app.on("browser-window-created", (_, window) => { optimizer.watchWindowShortcuts(window); }); // IPC: open URL in default browser (used by renderer for Google login). // All scheme-allowlist enforcement lives in openExternalSafely — this // is the single audit point for renderer-controlled URLs reaching the // OS shell under the app's intentional webSecurity: false + sandbox: // false configuration. ipcMain.handle("shell:openExternal", (_event, url: string) => { return openExternalSafely(url); }); // IPC: toggle immersive mode — hides the macOS traffic lights so full-screen // modals (e.g. create-workspace) can place UI in the top-left corner // without fighting the native window controls' hit-test. ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => { if (process.platform !== "darwin") return; mainWindow?.setWindowButtonVisibility(!immersive); }); createWindow(); setupAutoUpdater(() => mainWindow); setupDaemonManager(() => mainWindow); // macOS: deep link arrives via open-url event app.on("open-url", (_event, url) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } handleDeepLink(url); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); // Check argv for deep link on cold start (Windows/Linux) const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`), ); if (deepLinkArg) { app.whenReady().then(() => handleDeepLink(deepLinkArg)); } } app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });