Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
spawnLocalServer,
type SidecarListener,
} from "./server"
import { setupTrayAndLifecycle } from "./tray"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import {
getLastFocusedWindow,
Expand Down Expand Up @@ -386,6 +387,7 @@ const main = Effect.gen(function* () {
},
})
}
setupTrayAndLifecycle()
})

Effect.runFork(main)
120 changes: 120 additions & 0 deletions packages/desktop/src/main/tray.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"

let setupTrayAndLifecycle: () => void

type MenuItem = { label?: string; type?: string; click?: () => void }

const state = {
activateHandler: undefined as undefined | (() => void),
trayClickHandler: undefined as undefined | (() => void),
menu: undefined as undefined | MenuItem[],
trayCreated: 0,
quitCalls: 0,
showCalls: 0,
}

beforeAll(async () => {
const api = {
app: {
on: (event: string, handler: () => void) => {
if (event === "activate") state.activateHandler = handler
},
quit: () => {
state.quitCalls++
},
// store.ts (a transitive importer in the same process) reads userData.
getPath: () => "/tmp/opencode-test-userdata",
},
Menu: {
buildFromTemplate: (template: MenuItem[]) => {
state.menu = template
return template
},
},
nativeImage: {
createFromPath: () => ({}),
},
Tray: function Tray() {
state.trayCreated++
return {
setToolTip: () => {},
setContextMenu: () => {},
on: (event: string, handler: () => void) => {
if (event === "click") state.trayClickHandler = handler
},
}
},
}
// Expose the API as the default export too, so modules that do
// `import electron from "electron"` (e.g. store.ts) resolve correctly under the mock.
mock.module("electron", () => ({ ...api, default: api }))
mock.module("./windows", () => ({
iconPath: () => "/fake/icon.png",
showMainWindow: () => {
state.showCalls++
},
}))
const mod = await import("./tray")
setupTrayAndLifecycle = mod.setupTrayAndLifecycle
})

const originalPlatform = process.platform

function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", { value: platform, configurable: true })
}

beforeEach(() => {
state.activateHandler = undefined
state.trayClickHandler = undefined
state.menu = undefined
state.trayCreated = 0
state.quitCalls = 0
state.showCalls = 0
setPlatform("linux")
})

describe("tray lifecycle", () => {
test("restores the window when the app is activated", () => {
setupTrayAndLifecycle()
expect(state.activateHandler).toBeDefined()
state.activateHandler?.()
expect(state.showCalls).toBe(1)
})

test("creates a tray with Show and Quit entries on linux", () => {
setupTrayAndLifecycle()
expect(state.trayCreated).toBe(1)
const labels = state.menu?.map((item) => item.label ?? item.type)
expect(labels).toEqual(["Show OpenCode", "separator", "Quit"])
})

test("tray click and Show entry both restore the window", () => {
setupTrayAndLifecycle()
state.trayClickHandler?.()
const showEntry = state.menu?.find((item) => item.label === "Show OpenCode")
showEntry?.click?.()
expect(state.showCalls).toBe(2)
})

test("tray Quit entry quits the app", () => {
setupTrayAndLifecycle()
const quitEntry = state.menu?.find((item) => item.label === "Quit")
quitEntry?.click?.()
expect(state.quitCalls).toBe(1)
})

test("does not create a tray on macOS but still wires activate", () => {
setPlatform("darwin")
setupTrayAndLifecycle()
expect(state.trayCreated).toBe(0)
expect(state.activateHandler).toBeDefined()
})
})

afterAll(() => {
setPlatform(originalPlatform)
// Restore mocked modules so the electron mock does not leak into other test
// files that import electron transitively in the same process.
mock.restore()
})
30 changes: 30 additions & 0 deletions packages/desktop/src/main/tray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { app, Menu, nativeImage, Tray } from "electron"

import { iconPath, showMainWindow } from "./windows"

// Hold the tray at module scope so it is not garbage-collected; a collected
// Tray drops its icon and breaks tray interactions for the rest of the session.
let tray: Tray | undefined

export function setupTrayAndLifecycle() {
// Hidden windows are restored from the Dock on macOS and from the tray
// everywhere else, so closing a window keeps background work running.
app.on("activate", () => showMainWindow())

// macOS keeps the app in the Dock after the window is hidden, so no tray.
if (process.platform === "darwin") return

tray = new Tray(nativeImage.createFromPath(iconPath()))
tray.setToolTip("OpenCode")
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Show OpenCode", click: () => showMainWindow() },
{ type: "separator" },
{
label: "Quit",
click: () => app.quit(),
},
]),
)
tray.on("click", () => showMainWindow())
}
9 changes: 9 additions & 0 deletions packages/desktop/src/main/window-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,13 @@ describe("window registry", () => {
expect(app.state.stored).toEqual(["b"])
expect(app.cleaned).toEqual(["a"])
})

test("reports the quit flag for close interception", () => {
const app = setup()
expect(app.registry.isQuitting()).toBe(false)
app.registry.setQuitting()
expect(app.registry.isQuitting()).toBe(true)
app.registry.setQuitting(false)
expect(app.registry.isQuitting()).toBe(false)
})
})
3 changes: 3 additions & 0 deletions packages/desktop/src/main/window-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function createWindowRegistry<W>(persistence: {
setQuitting(value = true) {
quitting = value
},
isQuitting() {
return quitting
},
register(id: string, window: W) {
windows.set(id, window)
const ids = persisted()
Expand Down
21 changes: 20 additions & 1 deletion packages/desktop/src/main/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}

function iconPath() {
export function iconPath() {
const ext = process.platform === "win32" ? "ico" : "png"
return join(iconsDir(), `icon.${ext}`)
}
Expand Down Expand Up @@ -145,6 +145,16 @@ export function restoreMainWindows() {
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}

export function showMainWindow() {
const win = getLastFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
if (win && !win.isDestroyed()) {
win.show()
win.focus()
return
}
createMainWindow()
}

export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
Expand Down Expand Up @@ -222,6 +232,15 @@ function registerWindow(win: BrowserWindow, id: string) {
registry.register(id, win)

win.on("focus", () => registry.focused(id))
// Closing the last window hides it to the tray/Dock so background work keeps
// running; other windows close normally. A real quit sets the quitting flag
// first, so the window closes normally then too.
win.on("close", (event) => {
if (registry.isQuitting()) return
if (BrowserWindow.getAllWindows().length > 1) return
event.preventDefault()
win.hide()
})
// Windows never emits before-quit on OS shutdown/logoff, but each window
// gets session-end before it closes; flag the quit so ids stay persisted.
win.on("session-end", () => registry.setQuitting())
Expand Down
Loading