mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
fix: prevent session restore snapshot loss
This commit is contained in:
@@ -105,6 +105,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(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[]) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,6 @@ export function buildAndWriteSessionRestorePayload({
|
||||
now,
|
||||
});
|
||||
if (!payload) {
|
||||
storage.clear();
|
||||
return false;
|
||||
}
|
||||
return storage.write(payload);
|
||||
|
||||
@@ -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<TerminalSession[]>(initialRestoreState.sessions);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>(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));
|
||||
|
||||
@@ -128,7 +128,7 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ 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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -261,9 +261,13 @@ const pruneNode = (node: unknown, validSessionIds: ReadonlySet<string>): 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<string>): 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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +390,7 @@ function createBridgeRegistrar(context) {
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
route: "session-window",
|
||||
onRegisterBridge: registerBridges,
|
||||
});
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user