diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 0ff9a76881..d5d5e3ea24 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -159,6 +159,7 @@ const main = Effect.gen(function* () { wslServers.stopAll() } const relaunch = () => { + setAppQuitting() void stopSidecars().finally(() => { app.relaunch() app.exit(0) @@ -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)) }) } diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index e0cc365931..687429ed43 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -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" @@ -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) +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index fae74e3013..cd483ed72f 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -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" @@ -27,7 +28,23 @@ export function setupAutoUpdater(stop: () => Promise) { 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) diff --git a/packages/desktop/src/main/window-registry.test.ts b/packages/desktop/src/main/window-registry.test.ts new file mode 100644 index 0000000000..756f597ebb --- /dev/null +++ b/packages/desktop/src/main/window-registry.test.ts @@ -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"]) + }) +}) diff --git a/packages/desktop/src/main/window-registry.ts b/packages/desktop/src/main/window-registry.ts new file mode 100644 index 0000000000..f40902ef78 --- /dev/null +++ b/packages/desktop/src/main/window-registry.ts @@ -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(persistence: { + read: () => unknown + write: (ids: string[]) => void + cleanup: (id: string) => void +}) { + const windows = new Map() + 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) + }, + } +} diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index aae0401c83..a2c7ca617b 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -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") @@ -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>() const pinchZoomEnabled = new WeakMap() const windowIDs = new WeakMap() -const windowsByID = new Map() +const registry = createWindowRegistry({ + 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 @@ -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) { @@ -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)) } @@ -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) + registry.register(id, win) - 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) -} - -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