From e9cbe0c2ada134eec9d34d50e51eaeb5d50bb884 Mon Sep 17 00:00:00 2001 From: Guy Smoilovsky Date: Wed, 18 Feb 2026 20:47:16 +0200 Subject: [PATCH] Restore pane and zoom state on reconnect --- src/backend/server.ts | 147 +++++++++++++++++++++++++++++-- src/backend/tmux/cli-executor.ts | 3 +- src/backend/tmux/parser.ts | 3 +- src/backend/types/protocol.ts | 1 + src/frontend/App.tsx | 13 ++- src/frontend/types/protocol.ts | 1 + tests/backend/parser.test.ts | 4 +- tests/harness/fakeTmux.ts | 7 ++ tests/integration/server.test.ts | 76 +++++++++++++++- 9 files changed, 241 insertions(+), 14 deletions(-) diff --git a/src/backend/server.ts b/src/backend/server.ts index 733c4f1..2b86d29 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -33,6 +33,13 @@ interface DataContext { controlContext?: ControlContext; } +interface ReconnectState { + baseSession?: string; + paneId?: string; + zoomed?: boolean; + updatedAt: number; +} + export interface ServerDependencies { tmux: TmuxGateway; ptyFactory: PtyFactory; @@ -78,6 +85,17 @@ const isManagedMobileSession = (name: string): boolean => name.startsWith(MOBILE const buildMobileSessionName = (clientId: string): string => `${MOBILE_SESSION_PREFIX}${clientId}`; +const normalizeClientId = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 128) { + return undefined; + } + return trimmed; +}; + export const createTmuxMobileServer = ( config: RuntimeConfig, deps: ServerDependencies @@ -115,21 +133,67 @@ export const createTmuxMobileServer = ( const terminalWss = new WebSocketServer({ noServer: true }); const controlClients = new Set(); const terminalClients = new Set(); + const reconnectStateByClientId = new Map(); let monitor: TmuxStateMonitor | undefined; let started = false; let stopPromise: Promise | null = null; + const rememberReconnectState = ( + context: ControlContext, + patch: Partial> + ): void => { + if (!context.clientId) { + return; + } + + const existing = reconnectStateByClientId.get(context.clientId); + reconnectStateByClientId.set(context.clientId, { + baseSession: patch.baseSession ?? existing?.baseSession, + paneId: patch.paneId ?? existing?.paneId, + zoomed: patch.zoomed ?? existing?.zoomed, + updatedAt: Date.now() + }); + }; + + const updateReconnectStateFromSnapshot = ( + context: ControlContext, + state: TmuxStateSnapshot + ): void => { + if (!context.authed || !context.attachedSession) { + return; + } + + const attachedState = state.sessions.find((session) => session.name === context.attachedSession); + if (!attachedState) { + return; + } + const activeWindow = + attachedState.windowStates.find((windowState) => windowState.active) ?? attachedState.windowStates[0]; + const activePane = activeWindow?.panes.find((pane) => pane.active) ?? activeWindow?.panes[0]; + if (!activeWindow || !activePane) { + return; + } + + rememberReconnectState(context, { + baseSession: context.baseSession, + paneId: activePane.id, + zoomed: activeWindow.zoomed + }); + }; + const broadcastState = (state: TmuxStateSnapshot): void => { for (const client of controlClients) { - if (client.authed) { - sendJson(client.socket, { type: "tmux_state", state }); + if (!client.authed) { + continue; } + sendJson(client.socket, { type: "tmux_state", state }); + updateReconnectStateFromSnapshot(client, state); } }; const getControlContext = (clientId: string): ControlContext | undefined => - Array.from(controlClients).find((candidate) => candidate.clientId === clientId); + Array.from(controlClients).find((candidate) => candidate.authed && candidate.clientId === clientId); const getOrCreateRuntime = (context: ControlContext): TerminalRuntime => { if (context.runtime) { @@ -152,6 +216,38 @@ export const createTmuxMobileServer = ( return runtime; }; + const tryRestoreClientView = async (context: ControlContext): Promise => { + if (!context.attachedSession) { + return; + } + + const reconnectState = reconnectStateByClientId.get(context.clientId); + if (!reconnectState?.paneId) { + return; + } + + try { + await deps.tmux.selectPane(reconnectState.paneId); + } catch (error) { + logger.log("restore pane skipped", context.clientId, reconnectState.paneId, error); + return; + } + + if (typeof reconnectState.zoomed !== "boolean") { + return; + } + + try { + const windows = await deps.tmux.listWindows(context.attachedSession); + const activeWindow = windows.find((windowState) => windowState.active) ?? windows[0]; + if (activeWindow && activeWindow.zoomed !== reconnectState.zoomed) { + await deps.tmux.zoomPane(reconnectState.paneId); + } + } catch (error) { + logger.log("restore zoom skipped", context.clientId, reconnectState.paneId, error); + } + }; + const attachControlToBaseSession = async ( context: ControlContext, baseSession: string @@ -171,7 +267,9 @@ export const createTmuxMobileServer = ( context.baseSession = baseSession; context.attachedSession = mobileSession; + rememberReconnectState(context, { baseSession }); runtime.attachToSession(mobileSession); + await tryRestoreClientView(context); sendJson(context.socket, { type: "attached", session: mobileSession }); }; @@ -192,6 +290,16 @@ export const createTmuxMobileServer = ( "sessions discovered", sessions.map((session) => `${session.name}:${session.attached ? "attached" : "detached"}`).join(",") ); + + const preferredSession = context.baseSession + ? sessions.find((session) => session.name === context.baseSession) + : undefined; + if (preferredSession) { + logger.log("reattach preferred session", preferredSession.name); + await attachControlToBaseSession(context, preferredSession.name); + return; + } + if (sessions.length === 0) { await deps.tmux.createSession(config.defaultSession); logger.log("created default session", config.defaultSession); @@ -242,6 +350,7 @@ export const createTmuxMobileServer = ( return; case "select_pane": await deps.tmux.selectPane(message.paneId); + rememberReconnectState(context, { paneId: message.paneId }); return; case "split_pane": await deps.tmux.splitWindow(message.paneId, message.orientation); @@ -251,6 +360,10 @@ export const createTmuxMobileServer = ( return; case "zoom_pane": await deps.tmux.zoomPane(message.paneId); + rememberReconnectState(context, { + paneId: message.paneId, + zoomed: !(reconnectStateByClientId.get(context.clientId)?.zoomed ?? false) + }); return; case "capture_scrollback": { const lines = message.lines ?? config.scrollbackLines; @@ -298,11 +411,11 @@ export const createTmuxMobileServer = ( const context: ControlContext = { socket, authed: false, - clientId: randomToken(12), + clientId: "", terminalClients: new Set() }; controlClients.add(context); - logger.log("control ws connected", context.clientId); + logger.log("control ws connected"); socket.on("message", async (rawData) => { const message = parseClientMessage(rawData.toString("utf8")); @@ -324,7 +437,7 @@ export const createTmuxMobileServer = ( password: message.password }); if (!authResult.ok) { - logger.log("control ws auth failed", context.clientId, authResult.reason ?? "unknown"); + logger.log("control ws auth failed", authResult.reason ?? "unknown"); sendJson(socket, { type: "auth_error", reason: authResult.reason ?? "unauthorized" @@ -332,6 +445,25 @@ export const createTmuxMobileServer = ( return; } + const requestedClientId = normalizeClientId(message.clientId); + if (requestedClientId) { + const existingContext = Array.from(controlClients).find( + (candidate) => candidate !== context && candidate.authed && candidate.clientId === requestedClientId + ); + if (existingContext) { + controlClients.delete(existingContext); + await shutdownControlContext(existingContext); + if ( + existingContext.socket.readyState === existingContext.socket.OPEN || + existingContext.socket.readyState === existingContext.socket.CONNECTING + ) { + existingContext.socket.close(4000, "reconnected"); + } + } + } + + context.clientId = requestedClientId ?? randomToken(12); + context.baseSession = reconnectStateByClientId.get(context.clientId)?.baseSession; context.authed = true; logger.log("control ws auth ok", context.clientId); sendJson(socket, { @@ -367,6 +499,7 @@ export const createTmuxMobileServer = ( }); socket.on("close", () => { + rememberReconnectState(context, { baseSession: context.baseSession }); controlClients.delete(context); void shutdownControlContext(context); logger.log("control ws closed", context.clientId); @@ -390,7 +523,7 @@ export const createTmuxMobileServer = ( socket.close(4001, "auth required"); return; } - const clientId = authMessage.clientId; + const clientId = normalizeClientId(authMessage.clientId); if (!clientId) { socket.close(4001, "unauthorized"); return; diff --git a/src/backend/tmux/cli-executor.ts b/src/backend/tmux/cli-executor.ts index 6d86f77..d759121 100644 --- a/src/backend/tmux/cli-executor.ts +++ b/src/backend/tmux/cli-executor.ts @@ -7,7 +7,8 @@ import { withoutTmuxEnv } from "../util/env.js"; const execFileAsync = promisify(execFile); const SESSION_FMT = "#{session_name}\t#{session_attached}\t#{session_windows}"; -const WINDOW_FMT = "#{window_index}\t#{window_name}\t#{window_active}\t#{window_panes}"; +const WINDOW_FMT = + "#{window_index}\t#{window_name}\t#{window_active}\t#{window_zoomed_flag}\t#{window_panes}"; const PANE_FMT = "#{pane_index}\t#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_width}x#{pane_height}"; interface TmuxCliExecutorOptions { diff --git a/src/backend/tmux/parser.ts b/src/backend/tmux/parser.ts index 59282ed..1b521cc 100644 --- a/src/backend/tmux/parser.ts +++ b/src/backend/tmux/parser.ts @@ -26,11 +26,12 @@ export const parseWindows = (raw: string): Omit[] => .map((line) => line.trim()) .filter(Boolean) .map((line) => { - const [index, name, active, panes] = splitLine(line); + const [index, name, active, zoomed, panes] = splitLine(line); return { index: Number.parseInt(index, 10), name, active: active === "1", + zoomed: zoomed === "1", paneCount: Number.parseInt(panes, 10) }; }); diff --git a/src/backend/types/protocol.ts b/src/backend/types/protocol.ts index 0c59f9c..66f44a6 100644 --- a/src/backend/types/protocol.ts +++ b/src/backend/types/protocol.ts @@ -31,6 +31,7 @@ export interface TmuxWindowState { index: number; name: string; active: boolean; + zoomed: boolean; paneCount: number; panes: TmuxPaneState[]; } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 4fa0c0b..3848d5f 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -22,6 +22,7 @@ type ModifierMode = "off" | "sticky" | "locked"; const query = new URLSearchParams(window.location.search); const token = query.get("token") ?? ""; +const clientIdStorageKey = "tmux-mobile-client-id"; const wsOrigin = (() => { const scheme = window.location.protocol === "https:" ? "wss" : "ws"; @@ -46,6 +47,7 @@ export const App = () => { const fitAddonRef = useRef(null); const controlSocketRef = useRef(null); const terminalSocketRef = useRef(null); + const persistedClientIdRef = useRef(localStorage.getItem(clientIdStorageKey) ?? ""); const [serverConfig, setServerConfig] = useState(null); const [errorMessage, setErrorMessage] = useState(""); @@ -263,7 +265,14 @@ export const App = () => { const socket = new WebSocket(`${wsOrigin}/ws/control`); socket.onopen = () => { - socket.send(JSON.stringify({ type: "auth", token, password: passwordValue || undefined })); + socket.send( + JSON.stringify({ + type: "auth", + token, + password: passwordValue || undefined, + clientId: persistedClientIdRef.current || undefined + }) + ); }; socket.onmessage = (event) => { @@ -278,6 +287,8 @@ export const App = () => { setPasswordErrorMessage(""); setAuthReady(true); setNeedsPasswordInput(false); + persistedClientIdRef.current = message.clientId; + localStorage.setItem(clientIdStorageKey, message.clientId); if (message.requiresPassword && passwordValue) { localStorage.setItem("tmux-mobile-password", passwordValue); } else { diff --git a/src/frontend/types/protocol.ts b/src/frontend/types/protocol.ts index b5a36c7..80b9b13 100644 --- a/src/frontend/types/protocol.ts +++ b/src/frontend/types/protocol.ts @@ -17,6 +17,7 @@ export interface TmuxWindowState { index: number; name: string; active: boolean; + zoomed: boolean; paneCount: number; panes: TmuxPaneState[]; } diff --git a/tests/backend/parser.test.ts b/tests/backend/parser.test.ts index 9ccab18..1f2ca89 100644 --- a/tests/backend/parser.test.ts +++ b/tests/backend/parser.test.ts @@ -11,8 +11,8 @@ describe("tmux parser", () => { }); test("parses windows and panes", () => { - const windows = parseWindows("0\tbash\t1\t2"); - expect(windows[0]).toEqual({ index: 0, name: "bash", active: true, paneCount: 2 }); + const windows = parseWindows("0\tbash\t1\t0\t2"); + expect(windows[0]).toEqual({ index: 0, name: "bash", active: true, zoomed: false, paneCount: 2 }); const panes = parsePanes("0\t%1\tbash\t1\t120x30"); expect(panes[0]).toEqual({ diff --git a/tests/harness/fakeTmux.ts b/tests/harness/fakeTmux.ts index 9159634..c16613d 100644 --- a/tests/harness/fakeTmux.ts +++ b/tests/harness/fakeTmux.ts @@ -15,6 +15,7 @@ interface WindowNode { index: number; name: string; active: boolean; + zoomed: boolean; panes: PaneNode[]; } @@ -42,6 +43,7 @@ const buildDefaultSession = (name: string): SessionNode => ({ index: 0, name: "shell", active: true, + zoomed: false, panes: [ { index: 0, @@ -88,6 +90,7 @@ export class FakeTmuxGateway implements TmuxGateway { index: window.index, name: window.name, active: window.active, + zoomed: window.zoomed, paneCount: window.panes.length })) ); @@ -129,6 +132,7 @@ export class FakeTmuxGateway implements TmuxGateway { index: window.index, name: window.name, active: window.active, + zoomed: window.zoomed, panes: window.panes.map((pane) => ({ ...pane })) })) }); @@ -158,6 +162,7 @@ export class FakeTmuxGateway implements TmuxGateway { index: nextIndex + 1, name: `win-${nextIndex + 1}`, active: true, + zoomed: false, panes: [ { index: 0, @@ -226,6 +231,8 @@ export class FakeTmuxGateway implements TmuxGateway { public async zoomPane(paneId: string): Promise { this.calls.push(`zoomPane:${paneId}`); + const { window } = this.findByPane(paneId); + window.zoomed = !window.zoomed; } public async capturePane(paneId: string, lines: number): Promise { diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 153080b..e656cf2 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -29,7 +29,8 @@ describe("tmux mobile server", () => { const authControl = async ( control: WebSocket, - token: string = "test-token" + token: string = "test-token", + clientId?: string ): Promise<{ clientId: string; attachedSession: string }> => { const authOkPromise = waitForMessage<{ type: string; clientId: string }>( control, @@ -39,7 +40,7 @@ describe("tmux mobile server", () => { control, (msg) => msg.type === "attached" ); - control.send(JSON.stringify({ type: "auth", token })); + control.send(JSON.stringify({ type: "auth", token, clientId })); const authOk = await authOkPromise; const attached = await attachedPromise; return { clientId: authOk.clientId, attachedSession: attached.session }; @@ -257,4 +258,75 @@ describe("tmux mobile server", () => { await runningServer.stop(); await runningServer.stop(); }); + + test("reuses client identity and restores pane + zoom after reconnect", async () => { + await runningServer.stop(); + await startWithSessions(["main"]); + + const controlFirst = await openSocket(`${baseWsUrl}/ws/control`); + let controlSecond: WebSocket | undefined; + try { + const firstAuth = await authControl(controlFirst); + const firstSnapshot = await buildSnapshot(tmux); + const firstSession = firstSnapshot.sessions.find( + (session) => session.name === firstAuth.attachedSession + ); + const paneId = firstSession?.windowStates[0]?.panes[0]?.id; + expect(paneId).toBeDefined(); + + controlFirst.send(JSON.stringify({ type: "select_pane", paneId })); + controlFirst.send(JSON.stringify({ type: "zoom_pane", paneId })); + await new Promise((resolve) => setTimeout(resolve, 40)); + controlFirst.close(); + await new Promise((resolve) => setTimeout(resolve, 40)); + + controlSecond = await openSocket(`${baseWsUrl}/ws/control`); + const secondAuth = await authControl(controlSecond, "test-token", firstAuth.clientId); + + expect(secondAuth.clientId).toBe(firstAuth.clientId); + expect(tmux.calls).toContain(`selectPane:${paneId}`); + expect(tmux.calls.filter((call) => call === `zoomPane:${paneId}`).length).toBeGreaterThanOrEqual(2); + } finally { + controlFirst.close(); + controlSecond?.close(); + } + }); + + test("reconnect restore is best-effort when remembered pane no longer exists", async () => { + await runningServer.stop(); + await startWithSessions(["main"]); + + const controlFirst = await openSocket(`${baseWsUrl}/ws/control`); + let controlSecond: WebSocket | undefined; + try { + const firstAuth = await authControl(controlFirst); + const firstSnapshot = await buildSnapshot(tmux); + const firstSession = firstSnapshot.sessions.find( + (session) => session.name === firstAuth.attachedSession + ); + const paneId = firstSession?.windowStates[0]?.panes[0]?.id; + expect(paneId).toBeDefined(); + + controlFirst.send(JSON.stringify({ type: "select_pane", paneId })); + controlFirst.send(JSON.stringify({ type: "zoom_pane", paneId })); + await new Promise((resolve) => setTimeout(resolve, 40)); + controlFirst.close(); + await new Promise((resolve) => setTimeout(resolve, 40)); + + await tmux.killPane(paneId as string); + + controlSecond = await openSocket(`${baseWsUrl}/ws/control`); + const secondAuth = await authControl(controlSecond, "test-token", firstAuth.clientId); + expect(secondAuth.clientId).toBe(firstAuth.clientId); + + const maybeError = await Promise.race([ + waitForMessage<{ type: string; message?: string }>(controlSecond, (msg) => msg.type === "error"), + new Promise((resolve) => setTimeout(() => resolve(null), 80)) + ]); + expect(maybeError).toBeNull(); + } finally { + controlFirst.close(); + controlSecond?.close(); + } + }); });