From 27157abbf08a4467854031a557703fcde7eb69de Mon Sep 17 00:00:00 2001 From: fdranger <17621282681@163.com> Date: Wed, 30 Sep 2026 13:33:29 +0000 Subject: [PATCH] feat(sessions): restore saved terminal tabs on startup --- src/lib/components/AppShell.svelte | 8 + src/lib/components/ShellSettings.svelte | 29 +++- .../components/primary-session-window.test.ts | 24 +++ src/lib/components/primary-session-window.ts | 8 + src/lib/i18n/locales/en.ts | 2 + src/lib/i18n/locales/zh.ts | 2 + src/lib/stores/session-restore.svelte.test.ts | 161 ++++++++++++++++++ src/lib/stores/session-restore.svelte.ts | 143 ++++++++++++++++ 8 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/lib/stores/session-restore.svelte.test.ts create mode 100644 src/lib/stores/session-restore.svelte.ts diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index dc60cb68..24596c33 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -4,6 +4,7 @@ import {getCurrentWindow} from "@tauri-apps/api/window"; import type {Profile, Tab, Group} from "../stores/app.svelte.ts"; import * as app from "../stores/app.svelte.ts"; + import * as sessionRestore from "../stores/session-restore.svelte.ts"; import * as updates from "../stores/updates.svelte.ts"; import * as syncStatus from "../stores/sync.svelte.ts"; import HomeScreen from "./HomeScreen.svelte"; @@ -75,6 +76,10 @@ const bypassStartupReconcile = !!window.__rssh_clone || !!window.__rssh_ai_handoff; let resourcePanesAllowed = $state(bypassStartupReconcile); let navigationLoad = 0; + $effect(() => { + if (bypassStartupReconcile || !sessionRestore.ready() || !sessionRestore.enabled()) return; + sessionRestore.persist(sessionRestore.snapshot()); + }); $effect(() => { const tabs = app.tabs(); for (const paneId of Object.keys(pendingPaneSources)) { @@ -238,10 +243,13 @@ canOpenLocal: !app.isMobile, reconcile: () => invoke("reconcile_sessions", { activeIds: [] }), allowResourcePanes: () => { resourcePanesAllowed = true; }, + restoreSessions: () => sessionRestore.restore(startup.signal), loadAutoOpenLocal: async () => await invoke("get_setting", { key: "open_local_on_startup" }) === "true", openLocal: addLocalTab, }); + } else { + void sessionRestore.initializeSecondaryWindow(); } consumeCloneQuery(); consumeAiHandoff(); diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index e28ca219..ac84f0ef 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -2,9 +2,11 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; + import * as sessionRestore from "../stores/session-restore.svelte.ts"; import * as transfers from "../stores/transfers.svelte.ts"; import { isMac } from "../stores/keymap.svelte.ts"; - import { t } from "../i18n/index.svelte.ts"; + import { t, errMsg } from "../i18n/index.svelte.ts"; + import { toast } from "../stores/toast.svelte.ts"; import Select from "./Select.svelte"; import Modal from "./Modal.svelte"; @@ -17,6 +19,7 @@ let pendingCustom = $state(false); /** Auto-open one local terminal on startup — default off (unset reads as off). */ let openLocalOnStartup = $state(false); + let savingRestore = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); let copyOnSelect = $state(false); @@ -101,6 +104,19 @@ await invoke("set_setting", { key: "open_local_on_startup", value: String(openLocalOnStartup) }); } + async function saveRestore(event: Event) { + const input = event.currentTarget as HTMLInputElement; + savingRestore = true; + try { + await sessionRestore.setEnabled(input.checked); + } catch (error) { + toast.error(errMsg(error)); + } finally { + input.checked = sessionRestore.enabled(); + savingRestore = false; + } + } + async function saveTimeout() { const val = Math.max(1, Math.min(300, connectTimeout)); connectTimeout = val; @@ -151,6 +167,17 @@
+
+
+
{t("settings.shell.restore_sessions")}
+
{t("settings.shell.restore_sessions_desc")}
+
+ +
{#if !app.isMobile}
diff --git a/src/lib/components/primary-session-window.test.ts b/src/lib/components/primary-session-window.test.ts index 992c26dc..26509d63 100644 --- a/src/lib/components/primary-session-window.test.ts +++ b/src/lib/components/primary-session-window.test.ts @@ -11,6 +11,30 @@ function deferred() { } describe("initializePrimarySessionWindow", () => { + it("restores only after reconciliation, including on mobile", async () => { + const events: string[] = []; + await initializePrimarySessionWindow({ + canOpenLocal: false, + reconcile: async () => { events.push("reconcile"); }, + allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, + loadAutoOpenLocal: async () => { events.push("auto-open"); return true; }, + openLocal: () => events.push("local"), + }); + expect(events).toEqual(["reconcile", "release", "restore"]); + }); + + it("does not duplicate a restored local terminal", async () => { + const loadAutoOpenLocal = vi.fn(async () => true); + const openLocal = vi.fn(); + await initializePrimarySessionWindow({ + reconcile: async () => {}, allowResourcePanes: () => {}, + restoreSessions: async () => true, loadAutoOpenLocal, openLocal, + }); + expect(loadAutoOpenLocal).not.toHaveBeenCalled(); + expect(openLocal).not.toHaveBeenCalled(); + }); + it("releases resource panes before reading and applying auto-open", async () => { const reconcile = deferred(); const events: string[] = []; diff --git a/src/lib/components/primary-session-window.ts b/src/lib/components/primary-session-window.ts index de25f18f..2f5bc64c 100644 --- a/src/lib/components/primary-session-window.ts +++ b/src/lib/components/primary-session-window.ts @@ -3,6 +3,7 @@ interface PrimarySessionWindowDependencies { canOpenLocal?: boolean; reconcile: () => Promise; allowResourcePanes: () => void; + restoreSessions?: () => Promise; loadAutoOpenLocal: () => Promise; openLocal: () => void; } @@ -19,6 +20,13 @@ export async function initializePrimarySessionWindow( } if (dependencies.signal?.aborted) return; dependencies.allowResourcePanes(); + let restoredLocal = false; + try { + restoredLocal = await dependencies.restoreSessions?.() ?? false; + } catch { + // A failed restore must not prevent the existing startup preference. + } + if (dependencies.signal?.aborted || restoredLocal) return; if (dependencies.canOpenLocal === false) return; try { diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index d2285010..25787aba 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1200,6 +1200,8 @@ const en = { "settings.shell.local_shell": "LOCAL SHELL", "settings.shell.custom": "CUSTOM", "settings.shell.open_local_on_startup": "Open on startup", + "settings.shell.restore_sessions": "Restore sessions on startup", + "settings.shell.restore_sessions_desc": "Reopen saved SSH, Telnet and serial connections and local terminals from this device's last session. Connections start fresh using current profiles; startup commands may run again. Split panes reopen as separate tabs. Terminal output, editors, forwards and discovered containers are not restored. Off by default.", "settings.shell.open_local_on_startup_desc": "When rssh starts, automatically open one local terminal tab.", "settings.shell.connection_timeout": "CONNECTION TIMEOUT", "settings.shell.timeout_label": "Timeout (seconds)", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 97e4051a..1f148061 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -1203,6 +1203,8 @@ const zh: Messages = { "settings.shell.local_shell": "本地 SHELL", "settings.shell.custom": "自定义", "settings.shell.open_local_on_startup": "启动时打开", + "settings.shell.restore_sessions": "启动时恢复会话", + "settings.shell.restore_sessions_desc": "重新打开本机上次的已保存 SSH、Telnet、串口连接和本地终端。使用当前配置建立新连接,启动命令可能再次执行;分屏恢复为独立标签页。不恢复终端输出、编辑器、端口转发或动态发现的容器。默认关闭。", "settings.shell.open_local_on_startup_desc": "rssh 启动时自动打开一个本地终端标签页。", "settings.shell.connection_timeout": "连接超时", "settings.shell.timeout_label": "超时(秒)", diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts new file mode 100644 index 00000000..6b8c176f --- /dev/null +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +const key = "session_restore.desktop"; +let settings: Map; + +beforeEach(() => { + vi.resetModules(); + settings = new Map(); + vi.stubGlobal("navigator", { userAgent: "node" }); + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + invoke.mockReset(); + invoke.mockImplementation(async (command: string, args: Record) => { + if (command === "get_setting") return settings.get(args.key) ?? null; + if (command === "set_setting") { settings.set(args.key, args.value); return; } + if (command === "list_profiles") return [{ id: "ssh1", name: "Current SSH", host: "new-host", port: 2222 }]; + if (command === "list_telnet_profiles") return [{ id: "telnet1", name: "Telnet", host: "telnet", port: 23, login_script: "secret" }]; + if (command === "list_serial_profiles") return []; + return null; + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +async function modules() { + return { app: await import("./app.svelte.ts"), restore: await import("./session-restore.svelte.ts") }; +} +function saved(terminals: unknown[], active = -1) { + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, JSON.stringify({ version: 1, terminals, active })); +} + +describe("session restoration", () => { + it("defaults off without fetching profiles or reopening tabs", async () => { + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(restore.enabled()).toBe(false); + expect(restore.ready()).toBe(true); + expect(app.tabs()).toHaveLength(1); + expect(invoke).not.toHaveBeenCalledWith("list_profiles"); + }); + + it("reopens duplicates in order with fresh IDs and current profiles, skipping deleted profiles", async () => { + saved([ + { type: "ssh", profileId: "ssh1" }, { type: "ssh", profileId: "gone" }, + { type: "local" }, { type: "ssh", profileId: "ssh1" }, + { type: "telnet", profileId: "telnet1" }, + ], 3); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(true); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local", "ssh", "telnet"]); + expect(new Set(app.tabs().map((tab) => tab.id)).size).toBe(5); + expect(app.activeTabId()).toBe(app.tabs()[3].id); + expect(app.tabs()[1].meta).toEqual({ profileId: "ssh1", host: "new-host", port: "2222" }); + expect(app.tabs()[4].meta).not.toHaveProperty("login_script"); + }); + + it("persists only profile references, including split panes, without secret metadata or unsupported tabs", async () => { + const { app, restore } = await modules(); + app.addTab({ id: "ssh", type: "ssh", label: "Secret label", meta: { profileId: "ssh1", password: "secret", host: "host" } }); + app.addPane("ssh", "right", { id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1", login_script: "secret" } }); + app.addTab({ id: "serial", type: "serial", label: "Serial", meta: { serialProfileId: "serial1", login_script: "secret" } }); + app.addTab({ id: "forward", type: "forward", label: "Forward", meta: { forwardId: "f1" } }); + app.addTab({ id: "container", type: "docker_exec", label: "Container", meta: { connectorSpec: "secret" } }); + app.addTab({ id: "editor", type: "edit", label: "Editor", meta: { content: "secret" } }); + app.setActiveWorkspace("ssh"); + app.setActivePane("telnet"); + const snapshot = restore.snapshot(); + expect(JSON.parse(snapshot)).toEqual({ version: 1, terminals: [ + { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, + { type: "serial", profileId: "serial1" }, + ], active: 1 }); + expect(snapshot).not.toContain("secret"); + }); + + it("clears saved sessions when disabled and does not persist while disabled", async () => { + saved([{ type: "local" }]); + const { restore } = await modules(); + await restore.restore(); + await restore.setEnabled(false); + restore.persist(restore.snapshot()); + expect(settings.get(`${key}.enabled`)).toBe("false"); + expect(settings.get(`${key}.snapshot`)).toBe(""); + }); + + it.each(["bad JSON", '{"version":2,"terminals":[]}', '{"version":1,"terminals":[{"type":"ssh"}]}']) + ("handles invalid snapshots: %s", async (snapshot) => { + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, snapshot); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(app.tabs()).toHaveLength(1); + }); + + it("does not overwrite the snapshot if profile loading fails", async () => { + saved([{ type: "ssh", profileId: "ssh1" }]); + const original = settings.get(`${key}.snapshot`); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation((command, args) => command === "list_profiles" + ? Promise.reject(new Error("offline")) : implementation(command, args)); + const { restore } = await modules(); + await restore.restore(); + restore.persist(restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(original); + }); + + it("does not reopen connections after startup cancellation", async () => { + saved([{ type: "local" }]); + const controller = new AbortController(); + controller.abort(); + const { app, restore } = await modules(); + expect(await restore.restore(controller.signal)).toBe(false); + expect(app.tabs()).toHaveLength(1); + expect(restore.ready()).toBe(false); + }); + + it("does not take over connections opened manually during startup", async () => { + saved([{ type: "local" }]); + const { app, restore } = await modules(); + app.addTab({ id: "manual", type: "local", label: "Manual" }); + expect(await restore.restore()).toBe(false); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home", "manual"]); + }); + + it("serializes snapshots so the latest write wins", async () => { + const { restore } = await modules(); + await restore.restore(); + await restore.setEnabled(true); + restore.persist("first"); + restore.persist("second"); + await vi.waitFor(() => expect(settings.get(`${key}.snapshot`)).toBe("second")); + }); + + it("keeps saved ordering even when MRU reordering is enabled", async () => { + saved([{ type: "ssh", profileId: "ssh1" }, { type: "local" }], 1); + const { app, restore } = await modules(); + await app.setTabMru(true); + await restore.restore(); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local"]); + expect(app.activeTabId()).toBe(app.tabs()[2].id); + }); + + it("restores mobile SSH/Telnet while excluding desktop local and serial terminals", async () => { + vi.stubGlobal("navigator", { userAgent: "Android" }); + settings.set("session_restore.mobile.enabled", "true"); + settings.set("session_restore.mobile.snapshot", JSON.stringify({ version: 1, terminals: [ + { type: "local" }, { type: "serial", profileId: "s1" }, + { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, + ], active: 2 })); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "telnet"]); + expect(invoke).not.toHaveBeenCalledWith("list_serial_profiles"); + expect(app.activeTabId()).toBe(app.tabs()[1].id); + }); +}); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts new file mode 100644 index 00000000..bb2d6116 --- /dev/null +++ b/src/lib/stores/session-restore.svelte.ts @@ -0,0 +1,143 @@ +import { invoke } from "@tauri-apps/api/core"; +import * as app from "./app.svelte.ts"; +import { errMsg } from "../i18n/index.svelte.ts"; +import { toast } from "./toast.svelte.ts"; + +// Only references to saved profiles are persisted. Never serialize tab.meta: +// serial login scripts, editor content and connector commands may contain secrets. +type SavedTerminal = { type: "ssh" | "telnet" | "serial"; profileId: string } | { type: "local" }; +interface Snapshot { version: 1; terminals: SavedTerminal[]; active: number } +const key = `session_restore.${app.isMobile ? "mobile" : "desktop"}`; +let _enabled = $state(false); +let _ready = $state(false); +let writes = Promise.resolve(); + +export function enabled() { return _enabled; } +export function ready() { return _ready; } + +function report(error: unknown) { + console.warn("[session-restore]", error); + toast.error(errMsg(error)); +} + +function write(setting: string, value: string): Promise { + // Serialize writes so an older snapshot cannot overwrite a newer one. + const next = writes.then(() => invoke("set_setting", { key: setting, value })); + writes = next.catch((error) => console.warn("[session-restore] write failed:", error)); + return next; +} + +export async function setEnabled(value: boolean) { + await write(`${key}.enabled`, String(value)); + _enabled = value; + if (!value) await write(`${key}.snapshot`, ""); +} + +function reference(tab: app.Tab): SavedTerminal | null { + if (tab.type === "local") return app.isMobile ? null : { type: "local" }; + if (tab.type !== "ssh" && tab.type !== "telnet" && tab.type !== "serial") return null; + if (tab.type === "serial" && app.isMobile) return null; + const profileId = tab.type === "serial" ? tab.meta?.serialProfileId : tab.meta?.profileId; + return profileId ? { type: tab.type, profileId } : null; +} + +export function snapshot(): string { + const terminals: SavedTerminal[] = []; + let active = -1; + for (const tab of app.tabs()) { + const saved = reference(tab); + if (!saved || terminals.length >= 100) continue; + if (tab.id === app.activeTabId()) active = terminals.length; + terminals.push(saved); + } + return JSON.stringify({ version: 1, terminals, active } satisfies Snapshot); +} + +export function persist(value: string) { + if (_ready && _enabled) void write(`${key}.snapshot`, value).catch(report); +} + +/** Secondary windows read the preference but never restore or save their tabs. */ +export async function initializeSecondaryWindow() { + try { + _enabled = await invoke("get_setting", { key: `${key}.enabled` }) === "true"; + } catch (error) { + report(error); + } finally { + _ready = true; + } +} + +function parse(raw: string | null): Snapshot | null { + if (!raw) return null; + const data = JSON.parse(raw); + if (data?.version !== 1 || !Array.isArray(data.terminals)) return null; + const terminals = data.terminals.slice(0, 100).map((value: unknown): SavedTerminal | null => { + if (!value || typeof value !== "object") return null; + const entry = value as Record; + if (entry.type === "local") return { type: "local" }; + if ((entry.type === "ssh" || entry.type === "telnet" || entry.type === "serial") + && typeof entry.profileId === "string" && entry.profileId) { + return { type: entry.type, profileId: entry.profileId }; + } + return null; + }); + // Reject invalid entries rather than shifting the active index. + if (terminals.some((entry: SavedTerminal | null) => !entry)) return null; + return { version: 1, terminals, active: Number.isInteger(data.active) ? data.active : -1 }; +} + +/** Returns whether a local terminal was restored (avoids startup duplication). */ +export async function restore(signal?: AbortSignal): Promise { + let localRestored = false; + try { + _enabled = await invoke("get_setting", { key: `${key}.enabled` }) === "true"; + if (!_enabled || signal?.aborted) return false; + const saved = parse(await invoke("get_setting", { key: `${key}.snapshot` })); + if (!saved || signal?.aborted) return false; + const types = new Set(saved.terminals.map((entry) => entry.type)); + const [ssh, telnet, serial] = await Promise.all([ + types.has("ssh") ? app.loadProfiles() : [], + types.has("telnet") ? invoke("list_telnet_profiles") : [], + types.has("serial") && !app.isMobile ? invoke("list_serial_profiles") : [], + ]); + // Do not take over tabs the user opened while the backend was loading. + if (signal?.aborted || app.tabs().length !== 1) return false; + let activeId = "home"; + const restoredIds: string[] = []; + saved.terminals.forEach((entry, index) => { + const before = app.tabs().length; + if (entry.type === "local" && !app.isMobile) { + app.addTab({ id: `local:${crypto.randomUUID()}`, type: "local", label: "Local" }); + localRestored = true; + } else if (entry.type === "ssh") { + const profile = ssh.find((profile) => profile.id === entry.profileId); + if (profile) app.addTab({ + id: `ssh:${crypto.randomUUID()}`, type: "ssh", label: profile.name, + meta: { profileId: profile.id, host: profile.host, port: String(profile.port) }, + }); + } else if (entry.type === "telnet") { + const profile = telnet.find((profile) => profile.id === entry.profileId); + if (profile) app.connectTelnetProfile(profile); + } else if (entry.type === "serial" && !app.isMobile) { + const profile = serial.find((profile) => profile.id === entry.profileId); + if (profile) app.connectSerialProfile(profile); + } + if (app.tabs().length > before) { + restoredIds.push(app.activeTabId()); + if (index === saved.active) activeId = app.activeTabId(); + } + }); + app.setActiveTab(activeId); + // addTab/focus may apply MRU ordering. Restore the saved strip order last. + restoredIds.forEach((id, index) => { + app.moveTab(app.tabs().findIndex((tab) => tab.id === id), index + 1); + }); + } catch (error) { + _enabled = false; // Preserve the last snapshot when startup reads fail. + report(error); + } finally { + if (!signal?.aborted) _ready = true; + } + return localRestored; +}