Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 73 additions & 12 deletions packages/desktop-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -59,6 +67,7 @@ let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
const loadingComplete = defer<void>()
const deepLinkReadyWindows = new WeakSet<BrowserWindow>()

const pendingDeepLinks: string[] = []

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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)])
Expand All @@ -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(),
})
}

Expand Down Expand Up @@ -258,6 +318,7 @@ registerIpcHandlers({
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),
setBackgroundColor: (color) => setBackgroundColor(color),
reportDeepLinkReady: (win) => reportDeepLinkReady(win),
reportCiSmokeReady: () => reportCiSmokeReady(),
})

Expand Down
4 changes: 4 additions & 0 deletions packages/desktop-electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Deps = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void
reportDeepLinkReady: (win: BrowserWindow | null) => void
reportCiSmokeReady: () => Promise<void> | void
}

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop-electron/src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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" },
Expand Down
147 changes: 147 additions & 0 deletions packages/desktop-electron/src/main/window-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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)
})
Loading
Loading