From 93079bb5763de36f32420986c1944ec672000a1c Mon Sep 17 00:00:00 2001 From: Zheng Liu Date: Wed, 17 Jun 2026 23:41:29 +0800 Subject: [PATCH] fix: prevent session restore snapshot loss --- App.tsx | 3 +- .../state/sessionRestoreSettings.test.ts | 23 ++++++++++- application/state/sessionRestoreState.test.ts | 26 ++++++++++++ application/state/sessionRestoreState.ts | 1 - application/state/useSessionState.ts | 14 +++++-- components/TrayPanel.tsx | 2 +- domain/sessionRestore.test.ts | 41 +++++++++++++++++++ domain/sessionRestore.ts | 15 +++---- electron/bridges/windowManager/mainWindow.cjs | 9 ++-- electron/main/registerBridges.cjs | 1 + index.tsx | 3 ++ 11 files changed, 120 insertions(+), 18 deletions(-) diff --git a/App.tsx b/App.tsx index 8cbab1d28..1662074b9 100755 --- a/App.tsx +++ b/App.tsx @@ -105,6 +105,7 @@ function App({ settings }: { settings: SettingsState }) { // Passphrase request queue for encrypted SSH keys const [passphraseQueue, setPassphraseQueue] = useState([]); const [pendingNewWindowSession, setPendingNewWindowSession] = useState(null); + const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window'); const { theme, @@ -259,7 +260,7 @@ function App({ settings }: { settings: SettingsState }) { copySession, createSessionFromCloneSource, updateSessionRestoreCwd, - } = useSessionState(); + } = useSessionState({ persistSessionRestore: !isPeerSessionWindow }); const handleRunSnippet = useCallback( async (snippet: Snippet, targetHosts: Host[]) => { diff --git a/application/state/sessionRestoreSettings.test.ts b/application/state/sessionRestoreSettings.test.ts index 0e305a7ee..f02573bcf 100644 --- a/application/state/sessionRestoreSettings.test.ts +++ b/application/state/sessionRestoreSettings.test.ts @@ -65,7 +65,7 @@ test("session restore persistence re-arms when restore previous session is enabl const revisionStateIndex = source.indexOf("restorePreviousSessionRevision"); const keyGuardIndex = source.indexOf("detail?.key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION"); const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT"); - const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision]", source.indexOf("beforeunload")); + const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload")); assert.notEqual(adapterEventImportIndex, -1); assert.notEqual(revisionStateIndex, -1); @@ -74,6 +74,27 @@ test("session restore persistence re-arms when restore previous session is enabl assert.notEqual(effectDependencyIndex, -1); }); +test("session restore persistence can be disabled for non-main windows", () => { + const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8"); + const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8"); + const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8"); + const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8"); + const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8"); + const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8"); + + assert.match(hookSource, /persistSessionRestore\?: boolean/); + assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/); + assert.match(hookSource, /if \(!persistSessionRestore\) return;/); + assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/); + assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/); + assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/); + assert.match(indexSource, /hash === '#\/session-window'/); + assert.match(registerBridgesSource, /route: "session-window"/); + assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/); + assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/); + assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/); +}); + test("restore terminal cwd setting participates in cross-window settings sync", () => { const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8"); const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8"); diff --git a/application/state/sessionRestoreState.test.ts b/application/state/sessionRestoreState.test.ts index 2582e11d6..a63c4f24d 100644 --- a/application/state/sessionRestoreState.test.ts +++ b/application/state/sessionRestoreState.test.ts @@ -189,3 +189,29 @@ test("session restore flush clears storage instead of writing when restore is di assert.equal(clearCount, 1); assert.equal(writes.length, 0); }); + +test("session restore flush skips empty transient windows without clearing existing snapshots", () => { + const writes: SessionRestorePayload[] = []; + let clearCount = 0; + const storage = { + write: (next: SessionRestorePayload) => { + writes.push(next); + return true; + }, + clear: () => { + clearCount += 1; + }, + }; + + const wrote = buildAndWriteSessionRestorePayload({ + sessions: [], + workspaces: [], + tabOrder: [], + activeTabId: "vault", + storage, + }); + + assert.equal(wrote, false); + assert.equal(clearCount, 0); + assert.equal(writes.length, 0); +}); diff --git a/application/state/sessionRestoreState.ts b/application/state/sessionRestoreState.ts index f78269c63..eb24d3f41 100644 --- a/application/state/sessionRestoreState.ts +++ b/application/state/sessionRestoreState.ts @@ -100,7 +100,6 @@ export function buildAndWriteSessionRestorePayload({ now, }); if (!payload) { - storage.clear(); return false; } return storage.write(payload); diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts index fca4d6dd3..7ad4999c7 100644 --- a/application/state/useSessionState.ts +++ b/application/state/useSessionState.ts @@ -43,13 +43,17 @@ import { import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings'; -export const useSessionState = () => { +export const useSessionState = ({ + persistSessionRestore = true, +}: { + persistSessionRestore?: boolean; +} = {}) => { const initialRestoreState = useMemo(() => createInitialRestoredSessionState({ - restoreEnabled: resolveRestorePreviousSessionSetting( + restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting( localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION), ), payload: sessionRestoreStorage.read(), - }), []); + }), [persistSessionRestore]); const [sessions, setSessions] = useState(initialRestoreState.sessions); const [workspaces, setWorkspaces] = useState(initialRestoreState.workspaces); // Latest workspaces snapshot for synchronous existence checks outside @@ -102,6 +106,8 @@ export const useSessionState = () => { }, []); useEffect(() => { + if (!persistSessionRestore) return; + const restoreEnabled = resolveRestorePreviousSessionSetting( localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION), ); @@ -140,7 +146,7 @@ export const useSessionState = () => { window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("beforeunload", handlePageHide); }; - }, [sessions, workspaces, tabOrder, activeTabRevision, restorePreviousSessionRevision]); + }, [sessions, workspaces, tabOrder, activeTabRevision, restorePreviousSessionRevision, persistSessionRestore]); const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => { setSessions((prev) => updateSessionRestoreCwdState(prev, sessionId, cwd)); diff --git a/components/TrayPanel.tsx b/components/TrayPanel.tsx index 0654bf937..49eaaf360 100644 --- a/components/TrayPanel.tsx +++ b/components/TrayPanel.tsx @@ -128,7 +128,7 @@ const TrayPanelContent: React.FC = ({ terminalSettings }) } = useTrayPanelBackend(); const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState(); - useSessionState(); + useSessionState({ persistSessionRestore: false }); const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState(); const activeTabId = useActiveTabId(); const proxyProfileIdSet = useMemo( diff --git a/domain/sessionRestore.test.ts b/domain/sessionRestore.test.ts index 43a13a149..56a1f9afb 100644 --- a/domain/sessionRestore.test.ts +++ b/domain/sessionRestore.test.ts @@ -401,6 +401,47 @@ test("sanitizeSessionRestorePayload falls back to equal split sizes when saved s } }); +test("sanitizeSessionRestorePayload preserves split sizes when nested splits collapse", () => { + const sanitized = sanitizeSessionRestorePayload({ + version: 1, + savedAt: 1, + activeTabId: "ws-1", + tabOrder: ["ws-1"], + sessions: [session("s1", "ws-1"), session("s3", "ws-1")], + workspaces: [{ + id: "ws-1", + title: "Workspace", + root: { + id: "root-split", + type: "split", + direction: "horizontal", + sizes: [0.7, 0.3], + children: [ + { + id: "left-split", + type: "split", + direction: "vertical", + sizes: [0.4, 0.6], + children: [ + { id: "pane-1", type: "pane", sessionId: "s1" }, + { id: "pane-missing", type: "pane", sessionId: "missing" }, + ], + }, + { id: "pane-3", type: "pane", sessionId: "s3" }, + ], + }, + }], + }); + + const root = sanitized.workspaces[0].root; + assert.equal(root.type, "split"); + if (root.type === "split") { + assert.deepEqual(root.children.map((child) => child.type === "pane" ? child.sessionId : "split"), ["s1", "s3"]); + assert.deepEqual(root.sizes, [0.7, 0.3]); + assert.ok(root.sizes?.every((size) => size > 0)); + } +}); + test("resolveRestoredActiveTabId falls back to vault when no restored tab is valid", () => { assert.equal(resolveRestoredActiveTabId("missing", [], [], []), "vault"); }); diff --git a/domain/sessionRestore.ts b/domain/sessionRestore.ts index ac37a90fb..74bda95c1 100644 --- a/domain/sessionRestore.ts +++ b/domain/sessionRestore.ts @@ -261,9 +261,13 @@ const pruneNode = (node: unknown, validSessionIds: ReadonlySet): Workspa return null; } - const children = node.children - .map((child) => pruneNode(child, validSessionIds)) - .filter((child): child is WorkspaceNode => child !== null); + const restoredChildren = node.children + .map((child, originalIndex) => ({ + child: pruneNode(child, validSessionIds), + originalIndex, + })) + .filter((entry): entry is { child: WorkspaceNode; originalIndex: number } => entry.child !== null); + const children = restoredChildren.map((entry) => entry.child); if (children.length === 0) return null; if (children.length === 1) return children[0]; @@ -272,10 +276,7 @@ const pruneNode = (node: unknown, validSessionIds: ReadonlySet): Workspa const canReuseSizes = rawSizes?.length === node.children.length && rawSizes.every((size) => typeof size === "number" && Number.isFinite(size) && size > 0); const nextSizes = canReuseSizes - ? children.map((child) => { - const originalIndex = node.children.findIndex((original) => original.id === child.id); - return originalIndex >= 0 && typeof rawSizes[originalIndex] === "number" ? rawSizes[originalIndex] : 0; - }) + ? restoredChildren.map(({ originalIndex }) => rawSizes[originalIndex]) : children.map(() => 1 / children.length); const total = nextSizes.reduce((sum, size) => sum + size, 0); diff --git a/electron/bridges/windowManager/mainWindow.cjs b/electron/bridges/windowManager/mainWindow.cjs index a12a31a83..c933a7343 100644 --- a/electron/bridges/windowManager/mainWindow.cjs +++ b/electron/bridges/windowManager/mainWindow.cjs @@ -3,7 +3,10 @@ function createMainWindowApi(ctx) { with (ctx) { async function createWindow(electronModule, options) { const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule; - const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options; + const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir, route } = options; + const rendererHash = typeof route === "string" && route.trim() + ? `#/${route.trim().replace(/^#?\/*/, "")}` + : ""; // Store app reference for window state persistence electronApp = app; @@ -321,7 +324,7 @@ function createMainWindowApi(ctx) { if (isDev) { try { - await win.loadURL(getDevRendererBaseUrl(devServerUrl)); + await win.loadURL(`${getDevRendererBaseUrl(devServerUrl)}${rendererHash}`); win.webContents.openDevTools({ mode: "detach" }); return win; } catch (e) { @@ -330,7 +333,7 @@ function createMainWindowApi(ctx) { } // Production mode - load via custom protocol. - await win.loadURL("app://netcatty/index.html"); + await win.loadURL(`app://netcatty/index.html${rendererHash}`); return win; } diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index 24f7654a8..ed800ea31 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -390,6 +390,7 @@ function createBridgeRegistrar(context) { appIcon, isMac, electronDir, + route: "session-window", onRegisterBridge: registerBridges, }); try { diff --git a/index.tsx b/index.tsx index 4284adba4..edb0d1b5c 100755 --- a/index.tsx +++ b/index.tsx @@ -128,6 +128,9 @@ const getRoute = () => { if (hash === '#/terminal-popup' || hash.startsWith('#/terminal-popup')) { return 'terminal-popup'; } + if (hash === '#/session-window' || hash.startsWith('#/session-window')) { + return 'main'; + } return 'main'; };