fix(desktop): keep window tabs across app close (#34807)
This commit is contained in:
@@ -159,6 +159,7 @@ const main = Effect.gen(function* () {
|
|||||||
wslServers.stopAll()
|
wslServers.stopAll()
|
||||||
}
|
}
|
||||||
const relaunch = () => {
|
const relaunch = () => {
|
||||||
|
setAppQuitting()
|
||||||
void stopSidecars().finally(() => {
|
void stopSidecars().finally(() => {
|
||||||
app.relaunch()
|
app.relaunch()
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
@@ -234,6 +235,7 @@ const main = Effect.gen(function* () {
|
|||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
|
setAppQuitting()
|
||||||
void stopSidecars().finally(() => app.exit(0))
|
void stopSidecars().finally(() => app.exit(0))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Store from "electron-store"
|
import Store from "electron-store"
|
||||||
import electron from "electron"
|
import electron from "electron"
|
||||||
|
import { rmSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
import { SETTINGS_STORE } from "./store-keys"
|
import { SETTINGS_STORE } from "./store-keys"
|
||||||
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||||
@@ -26,3 +28,8 @@ export function getStore(name = SETTINGS_STORE) {
|
|||||||
export async function removeStoreFileIfEmpty(name: string) {
|
export async function removeStoreFileIfEmpty(name: string) {
|
||||||
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { UPDATER_ENABLED } from "./constants"
|
|||||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||||
import { getLogger } from "./logging"
|
import { getLogger } from "./logging"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
|
import { setAppQuitting } from "./windows"
|
||||||
|
|
||||||
const { autoUpdater } = pkg
|
const { autoUpdater } = pkg
|
||||||
const key = "ready"
|
const key = "ready"
|
||||||
@@ -27,7 +28,23 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
|
|||||||
return createUpdaterController({
|
return createUpdaterController({
|
||||||
enabled: UPDATER_ENABLED,
|
enabled: UPDATER_ENABLED,
|
||||||
currentVersion: app.getVersion(),
|
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: {
|
persistence: {
|
||||||
get() {
|
get() {
|
||||||
const value = store.get(key)
|
const value = store.get(key)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
|||||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||||
import type { TitlebarTheme } from "../preload/types"
|
import type { TitlebarTheme } from "../preload/types"
|
||||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
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 { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||||
import { createUnresponsiveSampler } from "./unresponsive"
|
import { createUnresponsiveSampler } from "./unresponsive"
|
||||||
|
import { createWindowRegistry } from "./window-registry"
|
||||||
|
|
||||||
const root = dirname(fileURLToPath(import.meta.url))
|
const root = dirname(fileURLToPath(import.meta.url))
|
||||||
const rendererRoot = join(root, "../renderer")
|
const rendererRoot = join(root, "../renderer")
|
||||||
@@ -41,15 +42,21 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
|
|
||||||
let backgroundColor: string | undefined
|
let backgroundColor: string | undefined
|
||||||
let relaunchHandler = () => {
|
let relaunchHandler = () => {
|
||||||
|
setAppQuitting()
|
||||||
app.relaunch()
|
app.relaunch()
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
}
|
}
|
||||||
let appQuitting = false
|
|
||||||
let lastFocusedWindowID: string | undefined
|
|
||||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||||
const windowIDs = new WeakMap<BrowserWindow, string>()
|
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 titlebarHeight = 40
|
||||||
const maxZoomLevel = 10
|
const maxZoomLevel = 10
|
||||||
const minZoomLevel = 0.2
|
const minZoomLevel = 0.2
|
||||||
@@ -58,8 +65,8 @@ export function setRelaunchHandler(handler: () => void) {
|
|||||||
relaunchHandler = handler
|
relaunchHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAppQuitting() {
|
export function setAppQuitting(quitting = true) {
|
||||||
appQuitting = true
|
registry.setQuitting(quitting)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBackgroundColor(color: string) {
|
export function setBackgroundColor(color: string) {
|
||||||
@@ -128,14 +135,13 @@ export function getWindowID(win: BrowserWindow) {
|
|||||||
export function getLastFocusedWindow() {
|
export function getLastFocusedWindow() {
|
||||||
const focused = BrowserWindow.getFocusedWindow()
|
const focused = BrowserWindow.getFocusedWindow()
|
||||||
if (focused) return focused
|
if (focused) return focused
|
||||||
if (!lastFocusedWindowID) return null
|
const win = registry.lastFocused()
|
||||||
const win = windowsByID.get(lastFocusedWindowID)
|
|
||||||
if (!win || win.isDestroyed()) return null
|
if (!win || win.isDestroyed()) return null
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreMainWindows() {
|
export function restoreMainWindows() {
|
||||||
const ids = readWindowIDs()
|
const ids = registry.persisted()
|
||||||
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
|
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) {
|
function registerWindow(win: BrowserWindow, id: string) {
|
||||||
windowIDs.set(win, id)
|
windowIDs.set(win, id)
|
||||||
windowsByID.set(id, win)
|
registry.register(id, win)
|
||||||
persistWindowID(id)
|
|
||||||
|
|
||||||
win.on("focus", () => {
|
win.on("focus", () => registry.focused(id))
|
||||||
lastFocusedWindowID = 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("closed", () => {
|
win.on("session-end", () => registry.setQuitting())
|
||||||
windowsByID.delete(id)
|
win.on("closed", () => registry.closed(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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function windowStateFile(id: string) {
|
function windowStateFile(id: string) {
|
||||||
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
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() {
|
export function registerRendererProtocol() {
|
||||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user