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

This commit is contained in:
Luke Parker 2026-07-02 09:23:11 +10:00 committed by GitHub
parent 3df5556223
commit f266e829cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 41 deletions

View file

@ -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))
})
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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"])
})
})

View file

@ -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)
},
}
}

View file

@ -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)
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