Parcourir la source

fix(desktop): keep window tabs across app close (#34807)

Luke Parker il y a 1 mois
Parent
commit
f266e829cf

+ 2 - 0
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))
     })
   }

+ 7 - 0
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)
+}

+ 18 - 1
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<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)

+ 91 - 0
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"])
+  })
+})

+ 47 - 0
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<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)
+    },
+  }
+}

+ 27 - 40
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<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
@@ -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)
-
-  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