From 66d772099272555fc3f96d1331291356e32c5f3d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 3 Mar 2026 14:00:01 +0800 Subject: [PATCH 1/3] feat: SSH session persistence across app background/foreground (#8) * fix: ipad keyboard issue + paste key not wokring and disabled auto correct * fix: ipad keyboard issues * fix: ipad styling issues, emoji/voice dictation support, android keyboard issues * fix: ipad styling issues * feat: add debuging to fix ipad keyboard margins * fix: handle physical keyboard special keys in terminal (#6) * feat: add iPadOS build workflow without EAS dependency (#4) * fix: handle physical keyboard special keys in terminal Tab, Escape, and arrow keys from physical keyboards were filtered out by the key.length === 1 check. Add explicit handling like Enter/Backspace. * feat: add iOS native Shift+Tab support via UIKeyCommand Add Expo native module that uses UIKeyCommand + method swizzling to intercept Shift+Tab on iOS hardware keyboards and forward the backtab escape sequence (\x1b[Z]) to the active terminal. --------- Co-authored-by: swing Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: improve ipad ui logic and improve external keyboard handling * feat: fix arrow keys and modifier keys on hardware keybords * fix: fix arrow keys and modifier keys on hardware keybords * feat: add voice over support and improved terminal background conneciton * feat: add missing files * feat: add host key verification support * fix: dictation issues + android IME multi character input issues on android * fix: none auth hosts * chore: clean * chore: update readme * feat: add SSH session persistence for background/foreground transitions Adapt the mobile client to the server-side session persistence protocol (Termix PR #594). When the app goes to background and the WebSocket dies, the server keeps the SSH session alive. On foreground return, the client sends attachSession instead of connectToHost, reattaching to the existing session with buffered output replay. - Track server sessionId from sessionCreated messages - Send attachSession on reconnect when a sessionId exists - Handle sessionExpired with automatic fallback to fresh connectToHost - Handle sessionTakenOver for multi-device scenarios - Send explicit disconnect on destroy to clean up server sessions - Skip terminal clear and post-connection setup on reattach --------- Co-authored-by: LukeGus Co-authored-by: swing Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> --- .../terminal/NativeWebSocketManager.ts | 71 ++++++++++++++++--- app/tabs/sessions/terminal/Terminal.tsx | 11 +-- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index 4a0052b..48b1563 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -67,6 +67,8 @@ export class NativeWebSocketManager { private cols = 80; private rows = 24; private wsUrl: string | null = null; + private serverSessionId: string | null = null; + private pendingReattach = false; constructor(config: NativeWSConfig) { this.config = config; @@ -105,6 +107,12 @@ export class NativeWebSocketManager { destroy(): void { this.destroyed = true; this.shouldNotReconnect = true; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "disconnect" })); + } catch (_) {} + } + this.serverSessionId = null; this.clearAllTimers(); if (this.ws) { try { @@ -309,16 +317,30 @@ export class NativeWebSocketManager { this.currentConnectionFromBackground = this.isReconnectFromBackground; this.isReconnectFromBackground = false; - ws.send( - JSON.stringify({ - type: "connectToHost", - data: { - cols: this.cols, - rows: this.rows, - hostConfig: this.config.hostConfig, - }, - }), - ); + if (this.serverSessionId) { + this.pendingReattach = true; + ws.send( + JSON.stringify({ + type: "attachSession", + data: { + sessionId: this.serverSessionId, + cols: this.cols, + rows: this.rows, + }, + }), + ); + } else { + ws.send( + JSON.stringify({ + type: "connectToHost", + data: { + cols: this.cols, + rows: this.rows, + hostConfig: this.config.hostConfig, + }, + }), + ); + } this.startPingInterval(); }; @@ -381,17 +403,44 @@ export class NativeWebSocketManager { return; } } else if (msg.type === "connected") { + const isReattach = this.pendingReattach; + this.pendingReattach = false; this.config.onStateChange("connected", { hostName: this.config.hostConfig.name, fromBackground: this.currentConnectionFromBackground, + isReattach, }); - if (!this.currentConnectionFromBackground) { + if (!this.currentConnectionFromBackground && !isReattach) { this.config.onPostConnectionSetup(); } } else if (msg.type === "disconnected") { + this.serverSessionId = null; this.config.onDisconnected(this.config.hostConfig.name); } else if (msg.type === "pong") { } else if (msg.type === "resized") { + } else if (msg.type === "sessionCreated") { + this.serverSessionId = msg.sessionId as string; + } else if (msg.type === "sessionAttached") { + this.serverSessionId = msg.sessionId as string; + } else if (msg.type === "sessionExpired") { + this.serverSessionId = null; + this.pendingReattach = false; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "connectToHost", + data: { + cols: this.cols, + rows: this.rows, + hostConfig: this.config.hostConfig, + }, + }), + ); + } + } else if (msg.type === "sessionTakenOver") { + this.serverSessionId = null; + this.shouldNotReconnect = true; + this.config.onDisconnected(this.config.hostConfig.name); } } catch (_) { this.config.onData(event.data as string); diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 638c0fa..bdf79b0 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -746,14 +746,17 @@ const TerminalComponent = forwardRef( break; case "connected": { const fromBackground = data?.fromBackground as boolean; + const isReattach = data?.isReattach as boolean; setConnectionState("connected"); setRetryCount(0); - if (!fromBackground) { + if (!fromBackground && !isReattach) { setHasReceivedData(false); } - webViewRef.current?.injectJavaScript( - `window.notifyConnected(${fromBackground}); true;`, - ); + if (!isReattach) { + webViewRef.current?.injectJavaScript( + `window.notifyConnected(${fromBackground}); true;`, + ); + } logActivity("terminal", hostConfig.id, hostConfig.name).catch( () => {}, ); From 0427ba315c1aabad26535f32660f60c8d5dc2d46 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 9 Mar 2026 00:18:17 -0500 Subject: [PATCH 2/3] fix: clear terminal after reconnecting and fix letter duplcation --- app/tabs/sessions/Sessions.tsx | 1 + app/tabs/sessions/terminal/Terminal.tsx | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 52dae0e..e679592 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -1028,6 +1028,7 @@ export default function Sessions() { finalKey = `\x1b${key}`; } else { finalKey = key; + dictationSentRef.current = hiddenInputValue + key; } } } diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index bdf79b0..3c84ab3 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -403,10 +403,14 @@ const TerminalComponent = forwardRef( try { terminal.write(data); } catch(e) {} }; - window.notifyConnected = function(fromBackground) { + window.notifyConnected = function(fromBackground, isReattach) { terminal.clear(); - terminal.reset(); - terminal.write('\\x1b[2J\\x1b[H'); + if (isReattach) { + terminal.write('\\x1b[2J\\x1b[H'); + } else { + terminal.reset(); + terminal.write('\\x1b[2J\\x1b[H'); + } }; const terminalElement = document.getElementById('terminal'); @@ -749,14 +753,12 @@ const TerminalComponent = forwardRef( const isReattach = data?.isReattach as boolean; setConnectionState("connected"); setRetryCount(0); - if (!fromBackground && !isReattach) { - setHasReceivedData(false); - } if (!isReattach) { - webViewRef.current?.injectJavaScript( - `window.notifyConnected(${fromBackground}); true;`, - ); + setHasReceivedData(false); } + webViewRef.current?.injectJavaScript( + `window.notifyConnected(${fromBackground}, ${isReattach}); true;`, + ); logActivity("terminal", hostConfig.id, hostConfig.name).catch( () => {}, ); From 7dcbbe07ae47cd1c9e18e269266d456d696f454c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 9 Mar 2026 00:19:02 -0500 Subject: [PATCH 3/3] chore: increment ver --- app.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app.json b/app.json index 33cc4aa..4bed3ce 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.3.0", + "version": "1.3.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", diff --git a/package-lock.json b/package-lock.json index e5661b5..6117744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix-mobile", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix-mobile", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", diff --git a/package.json b/package.json index 21a1b82..18b2ff7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix-mobile", "main": "expo-router/entry", - "version": "1.3.0", + "version": "1.3.1", "scripts": { "start": "expo start", "android": "expo run:android",