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
2 changes: 2 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const main = Effect.gen(function* () {
wslServers.stopAll()
}
const relaunch = () => {
setAppQuitting()
void stopSidecars().finally(() => {
app.relaunch()
app.exit(0)
Expand Down Expand Up @@ -234,6 +235,7 @@ const main = Effect.gen(function* () {

for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
setAppQuitting()
void stopSidecars().finally(() => app.exit(0))
})
}
Expand Down
7 changes: 7 additions & 0 deletions packages/desktop/src/main/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Store from "electron-store"
import electron from "electron"
import { rmSync } from "node:fs"
import { join } from "node:path"

import { SETTINGS_STORE } from "./store-keys"
import { deleteStoreFileIfEmpty } from "./store-cleanup"
Expand All @@ -26,3 +28,8 @@ export function getStore(name = SETTINGS_STORE) {
export async function removeStoreFileIfEmpty(name: string) {
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
}

export function removeStoreFile(name: string) {
rmSync(join(electron.app.getPath("userData"), name), { force: true })
cache.delete(name)
}
19 changes: 18 additions & 1 deletion packages/desktop/src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
import { setAppQuitting } from "./windows"

const { autoUpdater } = pkg
const key = "ready"
Expand All @@ -27,7 +28,23 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
backend: autoUpdater,
backend: {
checkForUpdates: () => autoUpdater.checkForUpdates(),
downloadUpdate: () => autoUpdater.downloadUpdate(),
quitAndInstall: () => {
// quitAndInstall closes all windows before emitting before-quit, so
// flag the quit first to keep window ids persisted for restore.
setAppQuitting()
try {
autoUpdater.quitAndInstall()
} catch (error) {
// The install failed and the app keeps running; clear the flag so
// deliberate window closes prune ids again.
setAppQuitting(false)
throw error
}
},
},
persistence: {
get() {
const value = store.get(key)
Expand Down
91 changes: 91 additions & 0 deletions packages/desktop/src/main/window-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test"
import { createWindowRegistry } from "./window-registry"

function setup(initial: unknown = []) {
const state = { stored: initial }
const cleaned: string[] = []
const registry = createWindowRegistry<{ name: string }>({
read: () => state.stored,
write: (ids) => {
state.stored = ids
},
cleanup: (id) => cleaned.push(id),
})
return { registry, state, cleaned }
}

describe("window registry", () => {
test("restores persisted ids and ignores malformed entries", () => {
expect(setup(["a", "", 42, "b"]).registry.persisted()).toEqual(["a", "b"])
expect(setup("junk").registry.persisted()).toEqual([])
expect(setup(undefined).registry.persisted()).toEqual([])
})

test("registers windows and persists each id once", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
expect(app.state.stored).toEqual(["a", "b"])
})

test("forgets a deliberately closed window while others remain open", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.closed("a")
expect(app.state.stored).toEqual(["b"])
expect(app.cleaned).toEqual(["a"])
})

test("keeps the id when the last window closes so relaunch restores it", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.closed("a")
expect(app.state.stored).toEqual(["a"])
expect(app.cleaned).toEqual([])

const restarted = createWindowRegistry<{ name: string }>({
read: () => app.state.stored,
write: (ids) => {
app.state.stored = ids
},
cleanup: () => {},
})
expect(restarted.persisted()).toEqual(["a"])
})

test("keeps every id when windows close during quit", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.setQuitting()
app.registry.closed("a")
app.registry.closed("b")
expect(app.state.stored).toEqual(["a", "b"])
expect(app.cleaned).toEqual([])
})

test("tracks the last focused window and falls back on close", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.focused("a")
expect(app.registry.lastFocused()).toEqual({ name: "a" })
app.registry.closed("a")
expect(app.registry.lastFocused()).toEqual({ name: "b" })
app.registry.closed("b")
expect(app.registry.lastFocused()).toBeUndefined()
})

test("resumes forgetting closed windows after the quit flag resets", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.setQuitting()
app.registry.setQuitting(false)
app.registry.closed("a")
expect(app.state.stored).toEqual(["b"])
expect(app.cleaned).toEqual(["a"])
})
})
47 changes: 47 additions & 0 deletions packages/desktop/src/main/window-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Tracks open windows and the persisted window id list used to restore
// windows (and their per-window persisted state) across app launches.
export function createWindowRegistry<W>(persistence: {
read: () => unknown
write: (ids: string[]) => void
cleanup: (id: string) => void
}) {
const windows = new Map<string, W>()
let quitting = false
let lastFocusedID: string | undefined

const persisted = () => {
const value = persistence.read()
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
}

return {
persisted,
setQuitting(value = true) {
quitting = value
},
register(id: string, window: W) {
windows.set(id, window)
const ids = persisted()
if (!ids.includes(id)) persistence.write([...ids, id])
},
focused(id: string) {
lastFocusedID = id
},
lastFocused() {
if (!lastFocusedID) return
return windows.get(lastFocusedID)
},
closed(id: string) {
windows.delete(id)
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
// Only a deliberate close (app keeps running with other windows open)
// forgets a window. Closing the last window quits the app and fires
// `closed` before `before-quit`, so treat it as a quit and keep the id
// for restore on next launch.
if (quitting || windows.size === 0) return
persistence.write(persisted().filter((item) => item !== id))
persistence.cleanup(id)
},
}
}
67 changes: 27 additions & 40 deletions packages/desktop/src/main/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { getStore, removeStoreFile } from "./store"
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { createWindowRegistry } from "./window-registry"

const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
Expand Down Expand Up @@ -41,15 +42,21 @@ protocol.registerSchemesAsPrivileged([

let backgroundColor: string | undefined
let relaunchHandler = () => {
setAppQuitting()
app.relaunch()
app.exit(0)
}
let appQuitting = false
let lastFocusedWindowID: string | undefined
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const windowsByID = new Map<string, BrowserWindow>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
cleanup: (id) => {
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
removeStoreFile(windowDataFile(id))
},
})
const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
Expand All @@ -58,8 +65,8 @@ export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}

export function setAppQuitting() {
appQuitting = true
export function setAppQuitting(quitting = true) {
registry.setQuitting(quitting)
}

export function setBackgroundColor(color: string) {
Expand Down Expand Up @@ -128,14 +135,13 @@ export function getWindowID(win: BrowserWindow) {
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
if (!lastFocusedWindowID) return null
const win = windowsByID.get(lastFocusedWindowID)
const win = registry.lastFocused()
if (!win || win.isDestroyed()) return null
return win
}

export function restoreMainWindows() {
const ids = readWindowIDs()
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}

Expand Down Expand Up @@ -213,44 +219,25 @@ export function createMainWindow(id: string = randomUUID()) {

function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
windowsByID.set(id, win)
persistWindowID(id)

win.on("focus", () => {
lastFocusedWindowID = id
})
win.on("closed", () => {
windowsByID.delete(id)
if (lastFocusedWindowID === id) lastFocusedWindowID = windowsByID.keys().next().value
if (!appQuitting) removeWindowID(id)
})
}

function readWindowIDs() {
const value = getStore().get(WINDOW_IDS_KEY)
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
}
registry.register(id, win)

function writeWindowIDs(ids: string[]) {
getStore().set(WINDOW_IDS_KEY, [...new Set(ids)])
}

function persistWindowID(id: string) {
const ids = readWindowIDs()
if (ids.includes(id)) return
writeWindowIDs([...ids, id])
}

function removeWindowID(id: string) {
writeWindowIDs(readWindowIDs().filter((item) => item !== id))
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
win.on("focus", () => registry.focused(id))
// 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())
win.on("closed", () => registry.closed(id))
}

function windowStateFile(id: string) {
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}

// Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names
// the per-window renderer store this window persists its tabs into.
function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}

export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return

Expand Down
Loading