diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 0f12a858b..189ed8700 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -51,6 +51,14 @@ import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import { + registerWindowLifecycle, + selectCommandWindow, + selectNextMainWindow, + shouldOpenWindowForExternalEvent, + shouldQueueDeepLinks, + takeQueuedDeepLinksForReadyWindow, +} from "./window-lifecycle" import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() @@ -59,6 +67,7 @@ let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null let server: Server.Listener | null = null const loadingComplete = defer() +const deepLinkReadyWindows = new WeakSet() const pendingDeepLinks: string[] = [] @@ -89,13 +98,25 @@ function setupApp() { logger.log("deep link received via second-instance", { urls }) emitDeepLinks(urls) } - focusMainWindow() + focusMainWindow({ openIfMissing: true }) }) app.on("open-url", (event: Event, url: string) => { event.preventDefault() logger.log("deep link received via open-url", { url }) emitDeepLinks([url]) + focusMainWindow({ openIfMissing: true }) + }) + + registerWindowLifecycle({ + onWindowAllClosed: (listener) => app.on("window-all-closed", listener), + onActivate: (listener) => app.on("activate", listener), + quit: () => app.quit(), + getWindowCount: () => BrowserWindow.getAllWindows().length, + openWindow: () => { + if (isInitialized()) openMainWindow() + }, + platform: process.platform, }) app.on("before-quit", () => { @@ -123,16 +144,54 @@ function setupApp() { function emitDeepLinks(urls: string[]) { if (urls.length === 0) return - pendingDeepLinks.push(...urls) - if (mainWindow) sendDeepLinks(mainWindow, urls) + const windowReady = mainWindow ? deepLinkReadyWindows.has(mainWindow) : false + if (shouldQueueDeepLinks(Boolean(mainWindow), windowReady)) pendingDeepLinks.push(...urls) + if (mainWindow && windowReady) sendDeepLinks(mainWindow, urls) +} + +function flushPendingDeepLinksForReadyWindow(win: BrowserWindow | null) { + if (!win || !deepLinkReadyWindows.has(win)) return + const urls = takeQueuedDeepLinksForReadyWindow(pendingDeepLinks, true) + if (urls.length) sendDeepLinks(win, urls) +} + +function reportDeepLinkReady(win: BrowserWindow | null) { + if (!win) return + deepLinkReadyWindows.add(win) + if (win !== mainWindow) return + flushPendingDeepLinksForReadyWindow(win) +} + +function isInitialized() { + return initStep.phase === "done" } -function focusMainWindow() { +function focusMainWindow(options: { openIfMissing?: boolean } = {}) { + if (!mainWindow && options.openIfMissing && shouldOpenWindowForExternalEvent(false, isInitialized())) openMainWindow() if (!mainWindow) return mainWindow.show() mainWindow.focus() } +function mainWindowGlobals() { + return { + updaterEnabled: UPDATER_ENABLED, + deepLinks: pendingDeepLinks, + } +} + +function openMainWindow() { + const win = createMainWindow(mainWindowGlobals()) + mainWindow = win + win.on("closed", () => { + if (mainWindow !== win) return + mainWindow = selectNextMainWindow(win, BrowserWindow.getAllWindows()) + flushPendingDeepLinksForReadyWindow(mainWindow) + }) + wireMenu() + return win +} + function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) @@ -186,10 +245,7 @@ async function initialize() { logger.log("loading task finished") })() - const globals = { - updaterEnabled: UPDATER_ENABLED, - deepLinks: pendingDeepLinks, - } + const globals = mainWindowGlobals() if (needsMigration) { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) @@ -206,25 +262,29 @@ async function initialize() { await loadingComplete.promise } - mainWindow = createMainWindow(globals) - wireMenu() + openMainWindow() overlay?.close() } function wireMenu() { if (!mainWindow) return + const commandWindow = () => selectCommandWindow(BrowserWindow.getFocusedWindow(), mainWindow) createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + trigger: (id) => { + const win = commandWindow() + if (win) sendMenuCommand(win, id) + }, checkForUpdates: () => { void checkForUpdates(true) }, - reload: () => mainWindow?.reload(), + reload: () => commandWindow()?.reload(), relaunch: () => { killSidecar() app.relaunch() app.exit(0) }, + newWindow: () => openMainWindow(), }) } @@ -258,6 +318,7 @@ registerIpcHandlers({ checkUpdate: async () => checkUpdate(), installUpdate: async () => installUpdate(), setBackgroundColor: (color) => setBackgroundColor(color), + reportDeepLinkReady: (win) => reportDeepLinkReady(win), reportCiSmokeReady: () => reportCiSmokeReady(), }) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 53390696b..4258f3072 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -30,6 +30,7 @@ type Deps = { checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise | void setBackgroundColor: (color: string) => void + reportDeepLinkReady: (win: BrowserWindow | null) => void reportCiSmokeReady: () => Promise | void } @@ -60,6 +61,9 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("check-update", () => deps.checkUpdate()) ipcMain.handle("install-update", () => deps.installUpdate()) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + ipcMain.handle("report-deep-link-ready", (event: IpcMainInvokeEvent) => + deps.reportDeepLinkReady(BrowserWindow.fromWebContents(event.sender)), + ) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 15cd4987f..118080e2e 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -1,13 +1,13 @@ import { Menu, shell } from "electron" import { UPDATER_ENABLED } from "./constants" -import { createMainWindow } from "./windows" type Deps = { trigger: (id: string) => void checkForUpdates: () => void reload: () => void relaunch: () => void + newWindow: () => void } export function createMenu(deps: Deps) { @@ -47,7 +47,7 @@ export function createMenu(deps: Deps) { { label: "New Window", accelerator: "Cmd+Shift+N", - click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }), + click: () => deps.newWindow(), }, { type: "separator" }, { role: "close" }, diff --git a/packages/desktop-electron/src/main/window-lifecycle.test.ts b/packages/desktop-electron/src/main/window-lifecycle.test.ts new file mode 100644 index 000000000..16091a543 --- /dev/null +++ b/packages/desktop-electron/src/main/window-lifecycle.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test" + +import { + registerWindowLifecycle, + selectCommandWindow, + selectNextMainWindow, + shouldCreateWindowOnActivate, + shouldOpenWindowForExternalEvent, + shouldQueueDeepLinks, + shouldQuitWhenAllWindowsClosed, + takeQueuedDeepLinksForReadyWindow, +} from "./window-lifecycle" + +function createFakeApp() { + const listeners = new Map void>() + let quitCount = 0 + + return { + on(event: string, listener: () => void) { + listeners.set(event, listener) + }, + quit() { + quitCount++ + }, + emit(event: string) { + listeners.get(event)?.() + }, + quitCount() { + return quitCount + }, + } +} + +test("macOS keeps the app running when the last window closes", () => { + expect(shouldQuitWhenAllWindowsClosed("darwin")).toBe(false) +}) + +test("non-macOS platforms keep the existing quit-on-last-window behavior", () => { + expect(shouldQuitWhenAllWindowsClosed("win32")).toBe(true) + expect(shouldQuitWhenAllWindowsClosed("linux")).toBe(true) +}) + +test("macOS recreates a window on activate only when no windows are open", () => { + expect(shouldCreateWindowOnActivate("darwin", 0)).toBe(true) + expect(shouldCreateWindowOnActivate("darwin", 1)).toBe(false) +}) + +test("non-macOS activate does not create a window through macOS lifecycle rules", () => { + expect(shouldCreateWindowOnActivate("win32", 0)).toBe(false) + expect(shouldCreateWindowOnActivate("linux", 0)).toBe(false) +}) + +test("registered macOS lifecycle keeps the app alive and reopens a window on activate", () => { + const fake = createFakeApp() + let openCount = 0 + + registerWindowLifecycle({ + onWindowAllClosed: (listener) => fake.on("window-all-closed", listener), + onActivate: (listener) => fake.on("activate", listener), + quit: () => fake.quit(), + getWindowCount: () => 0, + openWindow: () => { + openCount++ + }, + platform: "darwin", + }) + + fake.emit("window-all-closed") + fake.emit("activate") + + expect(fake.quitCount()).toBe(0) + expect(openCount).toBe(1) +}) + +test("registered non-macOS lifecycle quits when all windows close", () => { + const fake = createFakeApp() + let openCount = 0 + + registerWindowLifecycle({ + onWindowAllClosed: (listener) => fake.on("window-all-closed", listener), + onActivate: (listener) => fake.on("activate", listener), + quit: () => fake.quit(), + getWindowCount: () => 0, + openWindow: () => { + openCount++ + }, + platform: "win32", + }) + + fake.emit("window-all-closed") + fake.emit("activate") + + expect(fake.quitCount()).toBe(1) + expect(openCount).toBe(0) +}) + +test("main window fallback keeps an older open window as the command target", () => { + const olderWindow = { isDestroyed: () => false } + const closingWindow = { isDestroyed: () => true } + + expect(selectNextMainWindow(closingWindow, [olderWindow])).toBe(olderWindow) +}) + +test("main window fallback ignores destroyed windows", () => { + const closingWindow = { isDestroyed: () => true } + const destroyedWindow = { isDestroyed: () => true } + + expect(selectNextMainWindow(closingWindow, [destroyedWindow])).toBeNull() +}) + +test("deep links are queued until the current window reports it is ready to receive them", () => { + expect(shouldQueueDeepLinks(false, false)).toBe(true) + expect(shouldQueueDeepLinks(true, false)).toBe(true) + expect(shouldQueueDeepLinks(true, true)).toBe(false) +}) + +test("queued deep links flush once when the current window becomes ready", () => { + const pending = ["opencode://open-project?directory=/a", "opencode://new-session?directory=/b"] + + expect(takeQueuedDeepLinksForReadyWindow(pending, false)).toEqual([]) + expect(pending).toEqual(["opencode://open-project?directory=/a", "opencode://new-session?directory=/b"]) + expect(takeQueuedDeepLinksForReadyWindow(pending, true)).toEqual([ + "opencode://open-project?directory=/a", + "opencode://new-session?directory=/b", + ]) + expect(pending).toEqual([]) + expect(takeQueuedDeepLinksForReadyWindow(pending, true)).toEqual([]) +}) + +test("headless external events reopen a window only after initialization is done", () => { + expect(shouldOpenWindowForExternalEvent(false, true)).toBe(true) + expect(shouldOpenWindowForExternalEvent(false, false)).toBe(false) + expect(shouldOpenWindowForExternalEvent(true, true)).toBe(false) +}) + +test("menu commands prefer the focused window over the newest tracked window", () => { + const focusedWindow = { isDestroyed: () => false } + const currentWindow = { isDestroyed: () => false } + + expect(selectCommandWindow(focusedWindow, currentWindow)).toBe(focusedWindow) +}) + +test("menu commands fall back to the tracked window when there is no focused window", () => { + const currentWindow = { isDestroyed: () => false } + + expect(selectCommandWindow(null, currentWindow)).toBe(currentWindow) +}) diff --git a/packages/desktop-electron/src/main/window-lifecycle.ts b/packages/desktop-electron/src/main/window-lifecycle.ts new file mode 100644 index 000000000..2aefac12b --- /dev/null +++ b/packages/desktop-electron/src/main/window-lifecycle.ts @@ -0,0 +1,53 @@ +export function shouldQuitWhenAllWindowsClosed(platform: NodeJS.Platform) { + return platform !== "darwin" +} + +export function shouldCreateWindowOnActivate(platform: NodeJS.Platform, windowCount: number) { + return platform === "darwin" && windowCount === 0 +} + +type RegisterWindowLifecycleOptions = { + onWindowAllClosed: (listener: () => void) => void + onActivate: (listener: () => void) => void + quit: () => void + getWindowCount: () => number + openWindow: () => void + platform: NodeJS.Platform +} + +export function registerWindowLifecycle(options: RegisterWindowLifecycleOptions) { + options.onWindowAllClosed(() => { + if (shouldQuitWhenAllWindowsClosed(options.platform)) options.quit() + }) + + options.onActivate(() => { + if (shouldCreateWindowOnActivate(options.platform, options.getWindowCount())) options.openWindow() + }) +} + +type WindowLike = { + isDestroyed: () => boolean +} + +export function selectNextMainWindow(closedWindow: T, windows: T[]) { + return windows.find((win) => win !== closedWindow && !win.isDestroyed()) ?? null +} + +export function shouldQueueDeepLinks(hasWindow: boolean, windowReady: boolean) { + return !hasWindow || !windowReady +} + +export function takeQueuedDeepLinksForReadyWindow(pending: string[], windowReady: boolean) { + if (!windowReady || pending.length === 0) return [] + return pending.splice(0) +} + +export function shouldOpenWindowForExternalEvent(hasWindow: boolean, initialized: boolean) { + return !hasWindow && initialized +} + +export function selectCommandWindow(focusedWindow: T | null, currentWindow: T | null) { + if (focusedWindow && !focusedWindow.isDestroyed()) return focusedWindow + if (currentWindow && !currentWindow.isDestroyed()) return currentWindow + return null +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index c504b1aea..637519ffe 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -32,6 +32,7 @@ const api: ElectronAPI = { storeKeys: (name) => ipcRenderer.invoke("store-keys", name), storeLength: (name) => ipcRenderer.invoke("store-length", name), reportCiSmokeReady: () => ipcRenderer.invoke("report-ci-smoke-ready"), + reportDeepLinkReady: () => ipcRenderer.invoke("report-deep-link-ready"), getWindowCount: () => ipcRenderer.invoke("get-window-count"), onSqliteMigrationProgress: (cb) => { diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index a6a559da9..ba4c25220 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -37,6 +37,7 @@ export type ElectronAPI = { storeKeys: (name: string) => Promise storeLength: (name: string) => Promise reportCiSmokeReady: () => Promise + reportDeepLinkReady: () => Promise getWindowCount: () => Promise onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 46d73f1f8..246b40cd7 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -65,7 +65,9 @@ async function reportCiSmokeReady(sidecar: { url: string; username?: string | nu const listenForDeepLinks = () => { const startUrls = window.__OPENCODE__?.deepLinks ?? [] if (startUrls.length) emitDeepLinks(startUrls) - return window.api.onDeepLink((urls) => emitDeepLinks(urls)) + const dispose = window.api.onDeepLink((urls) => emitDeepLinks(urls)) + void window.api.reportDeepLinkReady() + return dispose } const createPlatform = (): Platform => {