Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/components/AppShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize the restore preference across windows

When the user toggles this setting from a clone or AI-handoff window, that window renders the same Settings UI and writes the shared database key, but this guard prevents it from ever persisting a snapshot while the primary window's per-webview _enabled value remains unchanged. Enabling restore there therefore saves no sessions, while disabling it can be undone when the primary window next changes tabs and writes another snapshot; propagate preference changes to the primary window or make the control unavailable in secondary windows.

AGENTS.md reference: AGENTS.md:L211-L216

Useful? React with 👍 / 👎.

sessionRestore.persist(sessionRestore.snapshot());
});
$effect(() => {
const tabs = app.tabs();
for (const paneId of Object.keys(pendingPaneSources)) {
Expand Down Expand Up @@ -238,10 +243,13 @@
canOpenLocal: !app.isMobile,
reconcile: () => invoke("reconcile_sessions", { activeIds: [] }),
allowResourcePanes: () => { resourcePanesAllowed = true; },
restoreSessions: () => sessionRestore.restore(startup.signal),
loadAutoOpenLocal: async () =>
await invoke<string | null>("get_setting", { key: "open_local_on_startup" }) === "true",
openLocal: addLocalTab,
});
} else {
void sessionRestore.initializeSecondaryWindow();
}
consumeCloneQuery();
consumeAiHandoff();
Expand Down
29 changes: 28 additions & 1 deletion src/lib/components/ShellSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -151,6 +167,17 @@
</script>

<div class="page">
<div class="switch-card">
<div class="switch-card-body">
<div class="switch-card-title" id="restore-sessions-label">{t("settings.shell.restore_sessions")}</div>
<div class="switch-card-desc">{t("settings.shell.restore_sessions_desc")}</div>
</div>
<label class="switch">
<input type="checkbox" checked={sessionRestore.enabled()} aria-labelledby="restore-sessions-label"
disabled={savingRestore || !sessionRestore.ready()} onchange={saveRestore} />
<span class="slider"></span>
</label>
</div>
{#if !app.isMobile}
<div class="section-label" id="local-shell-label">{t("settings.shell.local_shell")}</div>
<div class="card surface-raised shell-card">
Expand Down
24 changes: 24 additions & 0 deletions src/lib/components/primary-session-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ function deferred<T>() {
}

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<void>();
const events: string[] = [];
Expand Down
8 changes: 8 additions & 0 deletions src/lib/components/primary-session-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface PrimarySessionWindowDependencies {
canOpenLocal?: boolean;
reconcile: () => Promise<unknown>;
allowResourcePanes: () => void;
restoreSessions?: () => Promise<boolean>;
loadAutoOpenLocal: () => Promise<boolean>;
openLocal: () => void;
}
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "超时(秒)",
Expand Down
161 changes: 161 additions & 0 deletions src/lib/stores/session-restore.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

beforeEach(() => {
vi.resetModules();
settings = new Map();
vi.stubGlobal("navigator", { userAgent: "node" });
const storage = new Map<string, string>();
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<string, string>) => {
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);
});
});
Loading
Loading