From 9f2f5003753ddce4197d24947fd45a33c6aa3ae3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 11 Feb 2026 23:59:45 -0600 Subject: [PATCH 01/16] fix: ipad keyboard issue + paste key not wokring and disabled auto correct --- app/tabs/sessions/Sessions.tsx | 107 +++++++++++------- .../terminal/keyboard/CustomKeyboard.tsx | 4 +- .../terminal/keyboard/KeyboardBar.tsx | 4 +- 3 files changed, 69 insertions(+), 46 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 15c7d4c..725f137 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -9,6 +9,7 @@ import { KeyboardAvoidingView, Platform, TextInput, + InputAccessoryView, TouchableWithoutFeedback, Pressable, Dimensions, @@ -52,7 +53,7 @@ import { export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { height, isLandscape } = useOrientation(); + const { width, height, isLandscape } = useOrientation(); const { sessions, activeSessionId, @@ -94,13 +95,17 @@ export default function Sessions() { const isSelectingRef = useRef(false); const keyboardWasHiddenBeforeSelectionRef = useRef(false); + const isIPad = Platform.OS === "ios" && Math.min(width, height) >= 768; const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); const effectiveKeyboardHeight = isLandscape ? Math.min(lastKeyboardHeight, maxKeyboardHeight) : lastKeyboardHeight; - const currentKeyboardHeight = isLandscape + const rawKeyboardHeight = isLandscape ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; + const currentKeyboardHeight = isIPad + ? Math.max(0, rawKeyboardHeight - insets.bottom) + : rawKeyboardHeight; const customKeyboardHeight = Math.max( 200, @@ -809,6 +814,7 @@ export default function Sessions() { contextMenuHidden underlineColorAndroid="transparent" multiline + inputAccessoryViewID="terminal-noop" onChangeText={() => {}} onKeyPress={({ nativeEvent }) => { const key = nativeEvent.key; @@ -818,51 +824,58 @@ export default function Sessions() { if (!activeRef?.current) return; - let finalKey = key; - - if (activeModifiers.ctrl) { - switch (key.toLowerCase()) { - case "c": - finalKey = "\x03"; - break; - case "d": - finalKey = "\x04"; - break; - case "z": - finalKey = "\x1a"; - break; - case "l": - finalKey = "\x0c"; - break; - case "a": - finalKey = "\x01"; - break; - case "e": - finalKey = "\x05"; - break; - case "k": - finalKey = "\x0b"; - break; - case "u": - finalKey = "\x15"; - break; - case "w": - finalKey = "\x17"; - break; - default: - if (key.length === 1) { - finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); + let finalKey: string | null = null; + + switch (key) { + case "Enter": finalKey = "\r"; break; + case "Backspace": finalKey = "\x7f"; break; + case "Tab": finalKey = "\t"; break; + case "Escape": finalKey = "\x1b"; break; + case "Delete": finalKey = "\x1b[3~"; break; + case "Home": finalKey = "\x1b[H"; break; + case "End": finalKey = "\x1b[F"; break; + case "PageUp": finalKey = "\x1b[5~"; break; + case "PageDown": finalKey = "\x1b[6~"; break; + case "ArrowUp": finalKey = "\x1b[A"; break; + case "ArrowDown": finalKey = "\x1b[B"; break; + case "ArrowRight": finalKey = "\x1b[C"; break; + case "ArrowLeft": finalKey = "\x1b[D"; break; + case "F1": finalKey = "\x1bOP"; break; + case "F2": finalKey = "\x1bOQ"; break; + case "F3": finalKey = "\x1bOR"; break; + case "F4": finalKey = "\x1bOS"; break; + case "F5": finalKey = "\x1b[15~"; break; + case "F6": finalKey = "\x1b[17~"; break; + case "F7": finalKey = "\x1b[18~"; break; + case "F8": finalKey = "\x1b[19~"; break; + case "F9": finalKey = "\x1b[20~"; break; + case "F10": finalKey = "\x1b[21~"; break; + case "F11": finalKey = "\x1b[23~"; break; + case "F12": finalKey = "\x1b[24~"; break; + default: + if (key.length === 1) { + if (activeModifiers.ctrl) { + switch (key.toLowerCase()) { + case "c": finalKey = "\x03"; break; + case "d": finalKey = "\x04"; break; + case "z": finalKey = "\x1a"; break; + case "l": finalKey = "\x0c"; break; + case "a": finalKey = "\x01"; break; + case "e": finalKey = "\x05"; break; + case "k": finalKey = "\x0b"; break; + case "u": finalKey = "\x15"; break; + case "w": finalKey = "\x17"; break; + default: finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); + } + } else if (activeModifiers.alt) { + finalKey = `\x1b${key}`; + } else { + finalKey = key; } - } - } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; + } } - if (key === "Enter") { - activeRef.current.sendInput("\r"); - } else if (key === "Backspace") { - activeRef.current.sendInput("\b"); - } else if (key.length === 1) { + if (finalKey !== null) { activeRef.current.sendInput(finalKey); } }} @@ -897,6 +910,12 @@ export default function Sessions() { }} /> )} + + {Platform.OS === "ios" && ( + + + + )} ); } diff --git a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index f9ca110..701d70a 100644 --- a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -49,10 +49,12 @@ export default function CustomKeyboard({ break; case "tab": case "complete": + case "comp": sendKey("\t"); break; case "arrowUp": case "history": + case "hist": sendKey("\x1b[A"); break; case "arrowDown": @@ -92,7 +94,7 @@ export default function CustomKeyboard({ const handlePaste = async () => { try { - const clipboardContent = await Clipboard.getString(); + const clipboardContent = await Clipboard.getStringAsync(); if (clipboardContent) { sendKey(clipboardContent); } diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx index 6c0b12a..9a44ae1 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx @@ -47,10 +47,12 @@ export default function KeyboardBar({ break; case "tab": case "complete": + case "comp": sendKey("\t"); break; case "arrowUp": case "history": + case "hist": sendKey("\x1b[A"); break; case "arrowDown": @@ -72,7 +74,7 @@ export default function KeyboardBar({ const handlePaste = async () => { try { - const clipboardContent = await Clipboard.getString(); + const clipboardContent = await Clipboard.getStringAsync(); if (clipboardContent) { sendKey(clipboardContent); } From 7ceda8dde4e9da99e1ea001b152ffd22f41650b7 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 12 Feb 2026 00:20:53 -0600 Subject: [PATCH 02/16] fix: ipad keyboard issues --- app.json | 2 +- app/tabs/hosts/navigation/Host.tsx | 13 +++++++++++ app/tabs/sessions/Sessions.tsx | 15 ++---------- package-lock.json | 37 ++---------------------------- package.json | 2 +- 5 files changed, 19 insertions(+), 50 deletions(-) diff --git a/app.json b/app.json index d5713bc..33cc4aa 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.2.0", + "version": "1.3.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 8960c49..9c7ec17 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import { showToast } from "@/app/utils/toast"; import { useEffect, useRef, useState } from "react"; import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -110,16 +111,28 @@ function Host({ host, status, isLast = false }: HostProps) { }; const handleTerminalPress = () => { + if (host.authType === "none") { + showToast.error("None auth type is not supported"); + return; + } navigateToSessions(host, "terminal"); setShowContextMenu(false); }; const handleStatsPress = () => { + if (host.authType === "none") { + showToast.error("None auth type is not supported"); + return; + } navigateToSessions(host, "stats"); setShowContextMenu(false); }; const handleFileManagerPress = () => { + if (host.authType === "none") { + showToast.error("None auth type is not supported"); + return; + } navigateToSessions(host, "filemanager"); setShowContextMenu(false); }; diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 725f137..a654b68 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -9,7 +9,6 @@ import { KeyboardAvoidingView, Platform, TextInput, - InputAccessoryView, TouchableWithoutFeedback, Pressable, Dimensions, @@ -53,7 +52,7 @@ import { export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { width, height, isLandscape } = useOrientation(); + const { height, isLandscape } = useOrientation(); const { sessions, activeSessionId, @@ -95,17 +94,13 @@ export default function Sessions() { const isSelectingRef = useRef(false); const keyboardWasHiddenBeforeSelectionRef = useRef(false); - const isIPad = Platform.OS === "ios" && Math.min(width, height) >= 768; const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); const effectiveKeyboardHeight = isLandscape ? Math.min(lastKeyboardHeight, maxKeyboardHeight) : lastKeyboardHeight; - const rawKeyboardHeight = isLandscape + const currentKeyboardHeight = isLandscape ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; - const currentKeyboardHeight = isIPad - ? Math.max(0, rawKeyboardHeight - insets.bottom) - : rawKeyboardHeight; const customKeyboardHeight = Math.max( 200, @@ -814,7 +809,6 @@ export default function Sessions() { contextMenuHidden underlineColorAndroid="transparent" multiline - inputAccessoryViewID="terminal-noop" onChangeText={() => {}} onKeyPress={({ nativeEvent }) => { const key = nativeEvent.key; @@ -911,11 +905,6 @@ export default function Sessions() { /> )} - {Platform.OS === "ios" && ( - - - - )} ); } diff --git a/package-lock.json b/package-lock.json index b1f208b..e5661b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix-mobile", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix-mobile", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", @@ -113,7 +113,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1495,7 +1494,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1749,7 +1747,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3973,7 +3970,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.17.tgz", "integrity": "sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.12.4", "escape-string-regexp": "^4.0.0", @@ -4167,7 +4163,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -4178,7 +4173,6 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4250,7 +4244,6 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -4813,7 +4806,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5544,7 +5536,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -6993,7 +6984,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7087,7 +7077,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7207,7 +7196,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7469,7 +7457,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.13.tgz", "integrity": "sha512-F1puKXzw8ESnsbvaKdXtcIiyYLQ2kUHqP8LuhgtJS1wm6w55VhtOPg8yl/0i8kPbTA0YfD+KYdXjSfhPXgUPxw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.11", @@ -7595,7 +7582,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.9.tgz", "integrity": "sha512-sqoXHAOGDcr+M9NlXzj1tGoZyd3zxYDy215W6E0Z0n8fgBaqce9FAYQE2bu5X4G629AYig5go7U6sQz7Pjcm8A==", "license": "MIT", - "peer": true, "dependencies": { "@expo/config": "~12.0.9", "@expo/env": "~2.0.7" @@ -7670,7 +7656,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7727,7 +7712,6 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", "license": "MIT", - "peer": true, "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" @@ -11786,7 +11770,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -11931,7 +11914,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12239,7 +12221,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12259,7 +12240,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12312,7 +12292,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -12667,7 +12646,6 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -12693,7 +12671,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.2.tgz", "integrity": "sha512-qzmQiFrvjm62pRBcj97QI9Xckc3EjgHQoY1F2yjktd0kpjhoyePeuTEXjYRCAVIy7IV/1cfeSup34+zFThFoHQ==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -12722,7 +12699,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.1.tgz", "integrity": "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -12733,7 +12709,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -12749,7 +12724,6 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz", "integrity": "sha512-B3gYc7WztcOT4N54AtUutbe0Nuqqh/nkresY0fAXzUHYLsWuIu/yGiCCD3DKfAs6GLv5LFtWTu7N333Q+e3bkg==", "license": "MIT", - "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -12765,7 +12739,6 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.1.tgz", "integrity": "sha512-BeNsgwwe4AXUFPAoFU+DKjJ+CVQa3h54zYX77p7GVZrXiiNo3vl03WYDYVEy5R2J2HOPInXtQZB5gmj3vuzrKg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -12798,7 +12771,6 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", "license": "MIT", - "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -12900,7 +12872,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14233,7 +14204,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -14467,7 +14437,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14672,7 +14641,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15470,7 +15438,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index fe10eb2..21a1b82 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix-mobile", "main": "expo-router/entry", - "version": "1.2.0", + "version": "1.3.0", "scripts": { "start": "expo start", "android": "expo run:android", From f9e01c2602dd74ea5577424220353f0a7e750963 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 13 Feb 2026 00:02:38 -0600 Subject: [PATCH 03/16] fix: ipad styling issues, emoji/voice dictation support, android keyboard issues --- app/tabs/dialogs/SSHAuthDialog.tsx | 12 ++++ app/tabs/dialogs/TOTPDialog.tsx | 4 ++ app/tabs/sessions/Sessions.tsx | 83 +++++++++++++++++++++++-- app/tabs/sessions/terminal/Terminal.tsx | 2 +- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx index e0b6d66..1b55a08 100644 --- a/app/tabs/dialogs/SSHAuthDialog.tsx +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -270,6 +270,10 @@ const SSHAuthDialogComponent: React.FC = ({ placeholderTextColor="#6B7280" secureTextEntry autoFocus={false} + autoCorrect={false} + autoCapitalize="none" + importantForAutofill="no" + autoComplete="off" onSubmitEditing={handleSubmit} /> @@ -310,6 +314,10 @@ const SSHAuthDialogComponent: React.FC = ({ multiline numberOfLines={6} autoFocus={false} + autoCorrect={false} + autoCapitalize="none" + importantForAutofill="no" + autoComplete="off" /> @@ -339,6 +347,10 @@ const SSHAuthDialogComponent: React.FC = ({ placeholder="Key password (if encrypted)" placeholderTextColor="#6B7280" secureTextEntry + autoCorrect={false} + autoCapitalize="none" + importantForAutofill="no" + autoComplete="off" onSubmitEditing={handleSubmit} /> diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx index 5ca4be1..0165c72 100644 --- a/app/tabs/dialogs/TOTPDialog.tsx +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -173,6 +173,10 @@ const TOTPDialogComponent: React.FC = ({ secureTextEntry={isPasswordPrompt} maxLength={isPasswordPrompt ? undefined : 6} autoFocus={false} + autoCorrect={false} + autoCapitalize="none" + importantForAutofill="no" + autoComplete="off" onSubmitEditing={handleSubmit} /> ("default"); + const [hiddenInputValue, setHiddenInputValue] = useState(""); + const dictationBufferRef = useRef(""); + const dictationSentRef = useRef(""); + const dictationTimerRef = useRef | null>(null); + // Reset dictation state when switching sessions so accumulated text from + // the previous session doesn't bleed into the next one. + useEffect(() => { + if (dictationTimerRef.current) clearTimeout(dictationTimerRef.current); + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + setHiddenInputValue(""); + }, [activeSessionId]); const lastBlurTimeRef = useRef(0); const [terminalBackgroundColors, setTerminalBackgroundColors] = useState< Record @@ -127,7 +140,7 @@ export default function Sessions() { } if (isKeyboardVisible && currentKeyboardHeight > 0) { - return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; + return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + (isIPad ? insets.bottom : 0); } return KEYBOARD_BAR_HEIGHT; @@ -154,7 +167,7 @@ export default function Sessions() { if (isKeyboardVisible && currentKeyboardHeight > 0) { return ( - SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + (isIPad ? insets.bottom : 0) ); } @@ -679,7 +692,7 @@ export default function Sessions() { bottom: keyboardIntentionallyHiddenRef.current ? 0 : isKeyboardVisible && currentKeyboardHeight > 0 - ? currentKeyboardHeight + (isLandscape ? 4 : 0) + ? currentKeyboardHeight + (isLandscape ? 4 : 0) + (isIPad ? insets.bottom : 0) : 0, left: 0, right: 0, @@ -805,11 +818,71 @@ export default function Sessions() { autoCapitalize="none" spellCheck={false} textContentType="none" + importantForAutofill="no" + autoComplete="off" caretHidden contextMenuHidden underlineColorAndroid="transparent" - multiline - onChangeText={() => {}} + value={hiddenInputValue} + onChangeText={(text) => { + // Deletions are handled by onKeyPress (Backspace → \x7f). + // If text shrank, it's the emoji keyboard's delete button removing + // a character that onKeyPress already handled — ignore it to avoid + // sending the remaining text as new input. + if (text.length <= dictationSentRef.current.length) { + dictationSentRef.current = text; + dictationBufferRef.current = ""; + if (!text) setHiddenInputValue(""); + return; + } + if (!text) { + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + return; + } + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (!activeRef?.current) { + setHiddenInputValue(""); + dictationBufferRef.current = ""; + return; + } + // iOS dictation sends incremental updates (e.g. "h" → "he" → "hel" + // → "hello"). We accumulate in a ref and debounce so only the final + // result is sent. Emoji/paste arrive as a single event so the timer + // fires immediately after with the full text. + dictationBufferRef.current = text; + setHiddenInputValue(text); + if (dictationTimerRef.current) + clearTimeout(dictationTimerRef.current); + dictationTimerRef.current = setTimeout(() => { + const finalText = dictationBufferRef.current; + const alreadySent = dictationSentRef.current; + dictationBufferRef.current = ""; + setHiddenInputValue(""); + // Only send the new suffix that hasn't been sent yet. + // iOS keeps all dictated text in the field across words, so + // alreadySent tracks the cumulative text we've already forwarded. + if (finalText.startsWith(alreadySent)) { + const newText = finalText.slice(alreadySent.length); + if (newText) { + dictationSentRef.current = finalText; + activeRef.current?.sendInput(newText); + } + } else { + // Text was replaced/autocorrected — send the whole thing + dictationSentRef.current = finalText; + if (finalText) activeRef.current?.sendInput(finalText); + } + }, 300); + }} + onSubmitEditing={() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + activeRef?.current?.sendInput("\r"); + }} onKeyPress={({ nativeEvent }) => { const key = nativeEvent.key; const activeRef = activeSessionId diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 949a6b0..221f77e 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -357,7 +357,7 @@ const TerminalComponent = forwardRef( const fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); - + terminal.open(document.getElementById('terminal')); fitAddon.fit(); From 73f47c1233171cc6251f90144d9b9f9ea06a0627 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 13 Feb 2026 00:30:39 -0600 Subject: [PATCH 04/16] fix: ipad styling issues --- app/contexts/KeyboardContext.tsx | 6 +++++- app/tabs/sessions/Sessions.tsx | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/contexts/KeyboardContext.tsx b/app/contexts/KeyboardContext.tsx index 5ba8f51..1f2c8ab 100644 --- a/app/contexts/KeyboardContext.tsx +++ b/app/contexts/KeyboardContext.tsx @@ -38,7 +38,11 @@ export const KeyboardProvider: React.FC<{ children: React.ReactNode }> = ({ if (Platform.OS === "android") { newHeight = screenHeight - keyboardTop; } else { - newHeight = e.endCoordinates.height; + // Use screenHeight - screenY so the value represents how much the + // keyboard overlaps the bottom of the screen. On iPhone this equals + // e.endCoordinates.height. On iPad it correctly accounts for the + // keyboard position (e.g. docked vs floating). + newHeight = Math.max(0, screenHeight - keyboardTop); } if (newHeight > 0) { diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 48602a1..924e1ea 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -53,7 +53,6 @@ export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); const { height, isLandscape } = useOrientation(); - const isIPad = Platform.OS === "ios" && Platform.isPad; const { sessions, activeSessionId, @@ -140,7 +139,7 @@ export default function Sessions() { } if (isKeyboardVisible && currentKeyboardHeight > 0) { - return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + (isIPad ? insets.bottom : 0); + return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; } return KEYBOARD_BAR_HEIGHT; @@ -167,7 +166,7 @@ export default function Sessions() { if (isKeyboardVisible && currentKeyboardHeight > 0) { return ( - SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + (isIPad ? insets.bottom : 0) + SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight ); } @@ -692,7 +691,7 @@ export default function Sessions() { bottom: keyboardIntentionallyHiddenRef.current ? 0 : isKeyboardVisible && currentKeyboardHeight > 0 - ? currentKeyboardHeight + (isLandscape ? 4 : 0) + (isIPad ? insets.bottom : 0) + ? currentKeyboardHeight + (isLandscape ? 4 : 0) : 0, left: 0, right: 0, From 11f496d5698b9c5bc1649988b100c7f5fd5153d7 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 14 Feb 2026 15:57:59 -0600 Subject: [PATCH 05/16] feat: add debuging to fix ipad keyboard margins --- app/contexts/KeyboardContext.tsx | 6 +----- app/tabs/sessions/Sessions.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/contexts/KeyboardContext.tsx b/app/contexts/KeyboardContext.tsx index 1f2c8ab..5ba8f51 100644 --- a/app/contexts/KeyboardContext.tsx +++ b/app/contexts/KeyboardContext.tsx @@ -38,11 +38,7 @@ export const KeyboardProvider: React.FC<{ children: React.ReactNode }> = ({ if (Platform.OS === "android") { newHeight = screenHeight - keyboardTop; } else { - // Use screenHeight - screenY so the value represents how much the - // keyboard overlaps the bottom of the screen. On iPhone this equals - // e.endCoordinates.height. On iPad it correctly accounts for the - // keyboard position (e.g. docked vs floating). - newHeight = Math.max(0, screenHeight - keyboardTop); + newHeight = e.endCoordinates.height; } if (newHeight > 0) { diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 924e1ea..519fee1 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -718,6 +718,14 @@ export default function Sessions() { )} + {isKeyboardVisible && ( + + + {`kbH=${keyboardHeight} curKbH=${currentKeyboardHeight} maxKbH=${Math.round(maxKeyboardHeight)}\nwindowH=${Math.round(height)} insB=${insets.bottom} landscape=${isLandscape}\nbarBottom=${isKeyboardVisible && currentKeyboardHeight > 0 ? currentKeyboardHeight + (isLandscape ? 4 : 0) : 0}`} + + + )} + {sessions.length > 0 && (activeSession?.type === "stats" || activeSession?.type === "filemanager") && From 43c8becb14d9c4d231c2db4f95a938ba14b4ad86 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 15 Feb 2026 05:59:36 +0800 Subject: [PATCH 06/16] 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> --- .github/workflows/build-ipados.yml | 75 +++++++++++++++++++ app/tabs/sessions/Sessions.tsx | 32 +++++++- .../hardware-keyboard/expo-module.config.json | 6 ++ modules/hardware-keyboard/index.ts | 23 ++++++ .../ios/HardwareKeyboardModule.swift | 67 +++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-ipados.yml create mode 100644 modules/hardware-keyboard/expo-module.config.json create mode 100644 modules/hardware-keyboard/index.ts create mode 100644 modules/hardware-keyboard/ios/HardwareKeyboardModule.swift diff --git a/.github/workflows/build-ipados.yml b/.github/workflows/build-ipados.yml new file mode 100644 index 0000000..6b8f264 --- /dev/null +++ b/.github/workflows/build-ipados.yml @@ -0,0 +1,75 @@ +name: Build iPadOS App + +on: + workflow_dispatch: + +jobs: + build-ipados: + runs-on: macos-15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Generate iOS project + run: npx expo prebuild --platform ios --clean + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install CocoaPods dependencies + run: | + cd ios + pod install + + - name: Get version + id: version + run: | + VERSION=$(node -p "require('./app.json').expo.version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Create release directory + run: mkdir -p release + + - name: Build unsigned IPA + run: | + cd ios + + # Build for generic iOS device (unsigned) + xcodebuild -workspace Termix.xcworkspace \ + -scheme Termix \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath ../release/Termix.xcarchive \ + archive \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM="" \ + PROVISIONING_PROFILE_SPECIFIER="" + + - name: Create unsigned IPA + run: | + cd release + mkdir -p Payload + cp -r Termix.xcarchive/Products/Applications/Termix.app Payload/ + zip -r termix_ios_${{ steps.version.outputs.version }}.ipa Payload + rm -rf Payload + + - name: Upload iPadOS IPA + uses: actions/upload-artifact@v4 + with: + name: termix_ipados_${{ steps.version.outputs.version }} + path: release/termix_ios_${{ steps.version.outputs.version }}.ipa + retention-days: 30 + if-no-files-found: error diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 519fee1..f163ea8 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -48,6 +48,7 @@ import { BORDER_COLORS, BORDERS, } from "@/app/constants/designTokens"; +import { addKeyCommandListener } from "@/modules/hardware-keyboard"; export default function Sessions() { const insets = useSafeAreaInsets(); @@ -387,6 +388,19 @@ export default function Sessions() { customKeyboardHeight, ]); + useEffect(() => { + if (Platform.OS !== "ios") return; + const sub = addKeyCommandListener((event) => { + if (event.input === "\t" && event.shift) { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + activeRef?.current?.sendInput("\x1b[Z"); + } + }); + return () => sub.remove(); + }, [activeSessionId]); + useFocusEffect( React.useCallback(() => { if ( @@ -949,7 +963,23 @@ export default function Sessions() { } } - if (finalKey !== null) { + if (key === "Enter") { + activeRef.current.sendInput("\r"); + } else if (key === "Backspace") { + activeRef.current.sendInput("\b"); + } else if (key === "Tab") { + activeRef.current.sendInput("\t"); + } else if (key === "Escape") { + activeRef.current.sendInput("\x1b"); + } else if (key === "ArrowUp") { + activeRef.current.sendInput("\x1b[A"); + } else if (key === "ArrowDown") { + activeRef.current.sendInput("\x1b[B"); + } else if (key === "ArrowRight") { + activeRef.current.sendInput("\x1b[C"); + } else if (key === "ArrowLeft") { + activeRef.current.sendInput("\x1b[D"); + } else if (key.length === 1) { activeRef.current.sendInput(finalKey); } }} diff --git a/modules/hardware-keyboard/expo-module.config.json b/modules/hardware-keyboard/expo-module.config.json new file mode 100644 index 0000000..a2ccccc --- /dev/null +++ b/modules/hardware-keyboard/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["HardwareKeyboardModule"] + } +} diff --git a/modules/hardware-keyboard/index.ts b/modules/hardware-keyboard/index.ts new file mode 100644 index 0000000..88e432b --- /dev/null +++ b/modules/hardware-keyboard/index.ts @@ -0,0 +1,23 @@ +import { requireNativeModule } from "expo-modules-core"; +import type { EventSubscription } from "expo-modules-core"; + +interface KeyCommandEvent { + input: string; + shift: boolean; +} + +interface HardwareKeyboardModule { + addListener( + eventName: "onKeyCommand", + listener: (event: KeyCommandEvent) => void, + ): EventSubscription; +} + +const HardwareKeyboard = + requireNativeModule("HardwareKeyboard"); + +export function addKeyCommandListener( + listener: (event: KeyCommandEvent) => void, +): EventSubscription { + return HardwareKeyboard.addListener("onKeyCommand", listener); +} diff --git a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift new file mode 100644 index 0000000..ccddc80 --- /dev/null +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -0,0 +1,67 @@ +import ExpoModulesCore +import UIKit + +public class HardwareKeyboardModule: Module { + static weak var instance: HardwareKeyboardModule? + private static var swizzled = false + + public func definition() -> ModuleDefinition { + Name("HardwareKeyboard") + + Events("onKeyCommand") + + OnStartObserving { + HardwareKeyboardModule.instance = self + HardwareKeyboardModule.swizzleIfNeeded() + } + + OnStopObserving { + HardwareKeyboardModule.instance = nil + } + } + + static func swizzleIfNeeded() { + guard !swizzled else { return } + swizzled = true + + guard + let original = class_getInstanceMethod( + UIViewController.self, + #selector(getter: UIViewController.keyCommands) + ), + let replacement = class_getInstanceMethod( + UIViewController.self, + #selector(UIViewController.hk_keyCommands) + ) + else { return } + + method_exchangeImplementations(original, replacement) + } + + func emit(_ input: String, shift: Bool) { + sendEvent("onKeyCommand", [ + "input": input, + "shift": shift, + ]) + } +} + +extension UIViewController { + @objc func hk_keyCommands() -> [UIKeyCommand]? { + var commands = self.hk_keyCommands() ?? [] + + let shiftTab = UIKeyCommand( + input: "\t", + modifierFlags: .shift, + action: #selector(hk_handleShiftTab) + ) + shiftTab.wantsPriorityOverSystemBehavior = true + commands.append(shiftTab) + + return commands + } + + @objc func hk_handleShiftTab() { + HardwareKeyboardModule.instance?.emit("\t", shift: true) + } +} From d766ba0c18d8bd7bd6811631c22ebfba146dd1a6 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 14 Feb 2026 20:47:42 -0600 Subject: [PATCH 07/16] feat: improve ipad ui logic and improve external keyboard handling --- app/tabs/sessions/Sessions.tsx | 68 ++++++------------- app/utils/responsive.ts | 6 +- modules/hardware-keyboard/index.ts | 9 ++- .../ios/HardwareKeyboardModule.swift | 24 ++++++- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index f163ea8..34cf665 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -54,6 +54,7 @@ export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); const { height, isLandscape } = useOrientation(); + const isIPad = Platform.OS === "ios" && Platform.isPad; const { sessions, activeSessionId, @@ -107,7 +108,7 @@ export default function Sessions() { const isSelectingRef = useRef(false); const keyboardWasHiddenBeforeSelectionRef = useRef(false); - const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); + const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape, isIPad); const effectiveKeyboardHeight = isLandscape ? Math.min(lastKeyboardHeight, maxKeyboardHeight) : lastKeyboardHeight; @@ -391,14 +392,24 @@ export default function Sessions() { useEffect(() => { if (Platform.OS !== "ios") return; const sub = addKeyCommandListener((event) => { - if (event.input === "\t" && event.shift) { - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - activeRef?.current?.sendInput("\x1b[Z"); + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (!activeRef?.current) return; + + if (event.shift && event.input === "\t") { + activeRef.current.sendInput("\x1b[Z"); + return; + } + + if (event.ctrl) { + const ch = event.input.toLowerCase(); + const code = ch.charCodeAt(0) & 0x1f; + activeRef.current.sendInput(String.fromCharCode(code)); + return; } }); - return () => sub.remove(); + return () => sub?.remove(); }, [activeSessionId]); useFocusEffect( @@ -705,7 +716,7 @@ export default function Sessions() { bottom: keyboardIntentionallyHiddenRef.current ? 0 : isKeyboardVisible && currentKeyboardHeight > 0 - ? currentKeyboardHeight + (isLandscape ? 4 : 0) + ? currentKeyboardHeight + (isLandscape && !isIPad ? 4 : 0) : 0, left: 0, right: 0, @@ -732,15 +743,7 @@ export default function Sessions() { )} - {isKeyboardVisible && ( - - - {`kbH=${keyboardHeight} curKbH=${currentKeyboardHeight} maxKbH=${Math.round(maxKeyboardHeight)}\nwindowH=${Math.round(height)} insB=${insets.bottom} landscape=${isLandscape}\nbarBottom=${isKeyboardVisible && currentKeyboardHeight > 0 ? currentKeyboardHeight + (isLandscape ? 4 : 0) : 0}`} - - - )} - - {sessions.length > 0 && +{sessions.length > 0 && (activeSession?.type === "stats" || activeSession?.type === "filemanager") && isCustomKeyboardVisible && ( @@ -943,18 +946,7 @@ export default function Sessions() { default: if (key.length === 1) { if (activeModifiers.ctrl) { - switch (key.toLowerCase()) { - case "c": finalKey = "\x03"; break; - case "d": finalKey = "\x04"; break; - case "z": finalKey = "\x1a"; break; - case "l": finalKey = "\x0c"; break; - case "a": finalKey = "\x01"; break; - case "e": finalKey = "\x05"; break; - case "k": finalKey = "\x0b"; break; - case "u": finalKey = "\x15"; break; - case "w": finalKey = "\x17"; break; - default: finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); - } + finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); } else if (activeModifiers.alt) { finalKey = `\x1b${key}`; } else { @@ -963,23 +955,7 @@ export default function Sessions() { } } - if (key === "Enter") { - activeRef.current.sendInput("\r"); - } else if (key === "Backspace") { - activeRef.current.sendInput("\b"); - } else if (key === "Tab") { - activeRef.current.sendInput("\t"); - } else if (key === "Escape") { - activeRef.current.sendInput("\x1b"); - } else if (key === "ArrowUp") { - activeRef.current.sendInput("\x1b[A"); - } else if (key === "ArrowDown") { - activeRef.current.sendInput("\x1b[B"); - } else if (key === "ArrowRight") { - activeRef.current.sendInput("\x1b[C"); - } else if (key === "ArrowLeft") { - activeRef.current.sendInput("\x1b[D"); - } else if (key.length === 1) { + if (finalKey !== null) { activeRef.current.sendInput(finalKey); } }} diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts index 8faa476..b7833a1 100644 --- a/app/utils/responsive.ts +++ b/app/utils/responsive.ts @@ -26,9 +26,13 @@ export function getResponsiveFontSize( export function getMaxKeyboardHeight( screenHeight: number, isLandscape: boolean, + isIPad: boolean = false, ): number { if (!isLandscape) return screenHeight; - return screenHeight * 0.4; + // iPad landscape keyboards are taller than iPhone landscape keyboards. + // Use a higher cap (60%) so the real keyboard height isn't clamped. + const cap = isIPad ? 0.6 : 0.4; + return screenHeight * cap; } export function getTabBarHeight(isLandscape: boolean): number { diff --git a/modules/hardware-keyboard/index.ts b/modules/hardware-keyboard/index.ts index 88e432b..4f0d276 100644 --- a/modules/hardware-keyboard/index.ts +++ b/modules/hardware-keyboard/index.ts @@ -1,9 +1,11 @@ -import { requireNativeModule } from "expo-modules-core"; +import { requireOptionalNativeModule } from "expo-modules-core"; import type { EventSubscription } from "expo-modules-core"; interface KeyCommandEvent { input: string; shift: boolean; + ctrl: boolean; + alt: boolean; } interface HardwareKeyboardModule { @@ -14,10 +16,11 @@ interface HardwareKeyboardModule { } const HardwareKeyboard = - requireNativeModule("HardwareKeyboard"); + requireOptionalNativeModule("HardwareKeyboard"); export function addKeyCommandListener( listener: (event: KeyCommandEvent) => void, -): EventSubscription { +): EventSubscription | null { + if (!HardwareKeyboard) return null; return HardwareKeyboard.addListener("onKeyCommand", listener); } diff --git a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift index ccddc80..ce5b351 100644 --- a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -38,10 +38,12 @@ public class HardwareKeyboardModule: Module { method_exchangeImplementations(original, replacement) } - func emit(_ input: String, shift: Bool) { + func emit(_ input: String, shift: Bool, ctrl: Bool = false, alt: Bool = false) { sendEvent("onKeyCommand", [ "input": input, "shift": shift, + "ctrl": ctrl, + "alt": alt, ]) } } @@ -58,10 +60,30 @@ extension UIViewController { shiftTab.wantsPriorityOverSystemBehavior = true commands.append(shiftTab) + // Ctrl key combinations + let ctrlInputs = [ + "a","b","c","d","e","f","g","h","k","l","n","p","q","r","s","t","u","v","w","x","y","z", + "[","]","\\", + ] + for input in ctrlInputs { + let cmd = UIKeyCommand( + input: input, + modifierFlags: .control, + action: #selector(hk_handleCtrlKey(_:)) + ) + cmd.wantsPriorityOverSystemBehavior = true + commands.append(cmd) + } + return commands } @objc func hk_handleShiftTab() { HardwareKeyboardModule.instance?.emit("\t", shift: true) } + + @objc func hk_handleCtrlKey(_ sender: UIKeyCommand) { + guard let input = sender.input else { return } + HardwareKeyboardModule.instance?.emit(input, shift: false, ctrl: true) + } } From 1ab47982fda641d934e532afc1fd6481918522dd Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 14 Feb 2026 21:30:29 -0600 Subject: [PATCH 08/16] feat: fix arrow keys and modifier keys on hardware keybords --- app/tabs/sessions/Sessions.tsx | 15 ++++++++- .../ios/HardwareKeyboardModule.swift | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 34cf665..3a86d56 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -408,6 +408,19 @@ export default function Sessions() { activeRef.current.sendInput(String.fromCharCode(code)); return; } + + // Arrow keys and Escape from UIKeyCommand (not fired by onKeyPress) + const specialMap: Record = { + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowLeft: "\x1b[D", + ArrowRight: "\x1b[C", + Escape: "\x1b", + }; + if (specialMap[event.input]) { + activeRef.current.sendInput(specialMap[event.input]); + return; + } }); return () => sub?.remove(); }, [activeSessionId]); @@ -831,7 +844,7 @@ export default function Sessions() { backgroundColor: "transparent", zIndex: -1, }} - pointerEvents="none" + pointerEvents="box-none" autoFocus={false} showSoftInputOnFocus={true} keyboardType={keyboardType} diff --git a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift index ce5b351..1c07e03 100644 --- a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -52,6 +52,7 @@ extension UIViewController { @objc func hk_keyCommands() -> [UIKeyCommand]? { var commands = self.hk_keyCommands() ?? [] + // Shift+Tab let shiftTab = UIKeyCommand( input: "\t", modifierFlags: .shift, @@ -60,6 +61,28 @@ extension UIViewController { shiftTab.wantsPriorityOverSystemBehavior = true commands.append(shiftTab) + // Arrow keys (no modifier) — registered individually so we can use distinct selectors + let upCmd = UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(hk_handleArrowUp)) + upCmd.wantsPriorityOverSystemBehavior = true + commands.append(upCmd) + + let downCmd = UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(hk_handleArrowDown)) + downCmd.wantsPriorityOverSystemBehavior = true + commands.append(downCmd) + + let leftCmd = UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(hk_handleArrowLeft)) + leftCmd.wantsPriorityOverSystemBehavior = true + commands.append(leftCmd) + + let rightCmd = UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(hk_handleArrowRight)) + rightCmd.wantsPriorityOverSystemBehavior = true + commands.append(rightCmd) + + // Escape + let esc = UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(hk_handleEscape)) + esc.wantsPriorityOverSystemBehavior = true + commands.append(esc) + // Ctrl key combinations let ctrlInputs = [ "a","b","c","d","e","f","g","h","k","l","n","p","q","r","s","t","u","v","w","x","y","z", @@ -82,6 +105,15 @@ extension UIViewController { HardwareKeyboardModule.instance?.emit("\t", shift: true) } + @objc func hk_handleArrowUp() { HardwareKeyboardModule.instance?.emit("ArrowUp", shift: false) } + @objc func hk_handleArrowDown() { HardwareKeyboardModule.instance?.emit("ArrowDown", shift: false) } + @objc func hk_handleArrowLeft() { HardwareKeyboardModule.instance?.emit("ArrowLeft", shift: false) } + @objc func hk_handleArrowRight() { HardwareKeyboardModule.instance?.emit("ArrowRight", shift: false) } + + @objc func hk_handleEscape() { + HardwareKeyboardModule.instance?.emit("Escape", shift: false) + } + @objc func hk_handleCtrlKey(_ sender: UIKeyCommand) { guard let input = sender.input else { return } HardwareKeyboardModule.instance?.emit(input, shift: false, ctrl: true) From c1b0df859bfcb7ab41d33a44d89584b5f32e6e63 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 15 Feb 2026 00:20:16 -0600 Subject: [PATCH 09/16] fix: fix arrow keys and modifier keys on hardware keybords --- .../ios/HardwareKeyboardModule.swift | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift index 1c07e03..89c664c 100644 --- a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -2,21 +2,22 @@ import ExpoModulesCore import UIKit public class HardwareKeyboardModule: Module { - static weak var instance: HardwareKeyboardModule? private static var swizzled = false + private static var instances: [ObjectIdentifier: HardwareKeyboardModule] = [:] public func definition() -> ModuleDefinition { Name("HardwareKeyboard") Events("onKeyCommand") - OnStartObserving { - HardwareKeyboardModule.instance = self + OnCreate { + // Register immediately on creation, not waiting for a listener + HardwareKeyboardModule.instances[ObjectIdentifier(self)] = self HardwareKeyboardModule.swizzleIfNeeded() } - OnStopObserving { - HardwareKeyboardModule.instance = nil + OnDestroy { + HardwareKeyboardModule.instances.removeValue(forKey: ObjectIdentifier(self)) } } @@ -38,13 +39,11 @@ public class HardwareKeyboardModule: Module { method_exchangeImplementations(original, replacement) } - func emit(_ input: String, shift: Bool, ctrl: Bool = false, alt: Bool = false) { - sendEvent("onKeyCommand", [ - "input": input, - "shift": shift, - "ctrl": ctrl, - "alt": alt, - ]) + static func emitToAll(_ input: String, shift: Bool, ctrl: Bool = false, alt: Bool = false) { + let payload: [String: Any] = ["input": input, "shift": shift, "ctrl": ctrl, "alt": alt] + for instance in instances.values { + instance.sendEvent("onKeyCommand", payload) + } } } @@ -53,15 +52,11 @@ extension UIViewController { var commands = self.hk_keyCommands() ?? [] // Shift+Tab - let shiftTab = UIKeyCommand( - input: "\t", - modifierFlags: .shift, - action: #selector(hk_handleShiftTab) - ) + let shiftTab = UIKeyCommand(input: "\t", modifierFlags: .shift, action: #selector(hk_handleShiftTab)) shiftTab.wantsPriorityOverSystemBehavior = true commands.append(shiftTab) - // Arrow keys (no modifier) — registered individually so we can use distinct selectors + // Arrow keys let upCmd = UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(hk_handleArrowUp)) upCmd.wantsPriorityOverSystemBehavior = true commands.append(upCmd) @@ -89,11 +84,7 @@ extension UIViewController { "[","]","\\", ] for input in ctrlInputs { - let cmd = UIKeyCommand( - input: input, - modifierFlags: .control, - action: #selector(hk_handleCtrlKey(_:)) - ) + let cmd = UIKeyCommand(input: input, modifierFlags: .control, action: #selector(hk_handleCtrlKey(_:))) cmd.wantsPriorityOverSystemBehavior = true commands.append(cmd) } @@ -101,21 +92,15 @@ extension UIViewController { return commands } - @objc func hk_handleShiftTab() { - HardwareKeyboardModule.instance?.emit("\t", shift: true) - } - - @objc func hk_handleArrowUp() { HardwareKeyboardModule.instance?.emit("ArrowUp", shift: false) } - @objc func hk_handleArrowDown() { HardwareKeyboardModule.instance?.emit("ArrowDown", shift: false) } - @objc func hk_handleArrowLeft() { HardwareKeyboardModule.instance?.emit("ArrowLeft", shift: false) } - @objc func hk_handleArrowRight() { HardwareKeyboardModule.instance?.emit("ArrowRight", shift: false) } - - @objc func hk_handleEscape() { - HardwareKeyboardModule.instance?.emit("Escape", shift: false) - } + @objc func hk_handleShiftTab() { HardwareKeyboardModule.emitToAll("\t", shift: true) } + @objc func hk_handleArrowUp() { HardwareKeyboardModule.emitToAll("ArrowUp", shift: false) } + @objc func hk_handleArrowDown() { HardwareKeyboardModule.emitToAll("ArrowDown", shift: false) } + @objc func hk_handleArrowLeft() { HardwareKeyboardModule.emitToAll("ArrowLeft", shift: false) } + @objc func hk_handleArrowRight() { HardwareKeyboardModule.emitToAll("ArrowRight", shift: false) } + @objc func hk_handleEscape() { HardwareKeyboardModule.emitToAll("Escape", shift: false) } @objc func hk_handleCtrlKey(_ sender: UIKeyCommand) { guard let input = sender.input else { return } - HardwareKeyboardModule.instance?.emit(input, shift: false, ctrl: true) + HardwareKeyboardModule.emitToAll(input, shift: false, ctrl: true) } } From 4f1c9481bf53b303d4d03af78d23deef0a42a642 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 16 Feb 2026 18:59:14 -0600 Subject: [PATCH 10/16] feat: add voice over support and improved terminal background conneciton --- app/tabs/sessions/terminal/Terminal.tsx | 915 +++++++----------------- 1 file changed, 270 insertions(+), 645 deletions(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 221f77e..0c1ca94 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -11,16 +11,10 @@ import { Text, ActivityIndicator, Dimensions, - TouchableWithoutFeedback, - Keyboard, - TextInput, - TouchableOpacity, + AccessibilityInfo, } from "react-native"; -import * as Clipboard from "expo-clipboard"; import { WebView } from "react-native-webview"; import { - getCurrentServerUrl, - getCookie, logActivity, getSnippets, } from "../../../main-axios"; @@ -31,6 +25,10 @@ import { TOTPDialog, SSHAuthDialog } from "@/app/tabs/dialogs"; import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; import type { TerminalConfig } from "@/types"; +import { + NativeWebSocketManager, + type TerminalHostConfig, +} from "./NativeWebSocketManager"; interface TerminalProps { hostConfig: { @@ -75,6 +73,12 @@ const TerminalComponent = forwardRef( ref, ) => { const webViewRef = useRef(null); + const wsManagerRef = useRef(null); + const terminalColsRef = useRef(80); + const terminalRowsRef = useRef(24); + const pendingDataRef = useRef([]); + const dataFlushTimerRef = useRef | null>(null); + const { config } = useTerminalCustomization(); const [webViewKey, setWebViewKey] = useState(0); const [screenDimensions, setScreenDimensions] = useState( @@ -91,12 +95,8 @@ const TerminalComponent = forwardRef( const [retryCount, setRetryCount] = useState(0); const [hasReceivedData, setHasReceivedData] = useState(false); const [htmlContent, setHtmlContent] = useState(""); - const [currentHostId, setCurrentHostId] = useState(null); const [terminalBackgroundColor, setTerminalBackgroundColor] = useState("#09090b"); - const connectionTimeoutRef = useRef | null>( - null, - ); const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); @@ -107,6 +107,57 @@ const TerminalComponent = forwardRef( >("auth_failed"); const [isSelecting, setIsSelecting] = useState(false); + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); + const isScreenReaderEnabledRef = useRef(false); + const [accessibilityText, setAccessibilityText] = useState(""); + const accessibilityBufferRef = useRef([]); + const accessibilityTimerRef = useRef | null>(null); + + useEffect(() => { + AccessibilityInfo.isScreenReaderEnabled().then((enabled) => { + setIsScreenReaderEnabled(enabled); + isScreenReaderEnabledRef.current = enabled; + }); + const subscription = AccessibilityInfo.addEventListener( + "screenReaderChanged", + (enabled) => { + setIsScreenReaderEnabled(enabled); + isScreenReaderEnabledRef.current = enabled; + }, + ); + return () => subscription.remove(); + }, []); + + const writeToAccessibility = useCallback((rawData: string) => { + const cleaned = rawData + .replace(/\x1b\[[0-9;]*[mGKHJABCDsu]/g, "") + .replace(/\x1b\][^\x07]*\x07/g, "") + .replace(/\x1b[()][AB012]/g, "") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .trim(); + + if (!cleaned) return; + + const lines = cleaned.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length === 0) return; + + accessibilityBufferRef.current.push(...lines); + if (accessibilityBufferRef.current.length > 5) { + accessibilityBufferRef.current = accessibilityBufferRef.current.slice(-5); + } + + if (accessibilityTimerRef.current) { + clearTimeout(accessibilityTimerRef.current); + } + accessibilityTimerRef.current = setTimeout(() => { + accessibilityTimerRef.current = null; + const text = accessibilityBufferRef.current.join("\n"); + accessibilityBufferRef.current = []; + setAccessibilityText(text); + AccessibilityInfo.announceForAccessibility(text); + }, 500); + }, []); + useEffect(() => { const subscription = Dimensions.addEventListener( "change", @@ -129,51 +180,9 @@ const TerminalComponent = forwardRef( [onClose], ); - const getWebSocketUrl = async () => { - const serverUrl = getCurrentServerUrl(); - - if (!serverUrl) { - showToast.error( - "No server URL found - please configure a server first", - ); - return null; - } - - const jwtToken = await getCookie("jwt"); - if (!jwtToken || jwtToken.trim() === "") { - showToast.error("Authentication required - please log in again"); - return null; - } - - const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; - const wsHost = serverUrl.replace(/^https?:\/\//, ""); - const cleanHost = wsHost.replace(/\/$/, ""); - const wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; - - return wsUrl; - }; - - const generateHTML = useCallback(async () => { - const wsUrl = await getWebSocketUrl(); + const generateHTML = useCallback(() => { const { width, height } = screenDimensions; - if (!wsUrl) { - return ` - - - - - Terminal - - -
-

No Server Configured

-

Please configure a server first

-
- -`; - } - const terminalConfig: Partial = { ...MOBILE_DEFAULT_TERMINAL_CONFIG, ...config, @@ -186,6 +195,9 @@ const TerminalComponent = forwardRef( const terminalWidth = Math.floor(width / charWidth); const terminalHeight = Math.floor(height / lineHeight); + void terminalWidth; + void terminalHeight; + const themeName = terminalConfig.theme || "termix"; const themeColors = TERMINAL_THEMES[themeName]?.colors || TERMINAL_THEMES.termix.colors; @@ -221,7 +233,7 @@ const TerminalComponent = forwardRef( width: 100vw; height: 100vh; } - + #terminal { width: 100vw; height: 100vh; @@ -230,24 +242,24 @@ const TerminalComponent = forwardRef( margin: 0; box-sizing: border-box; } - + .xterm { width: 100% !important; height: 100% !important; } - + .xterm-viewport { width: 100% !important; height: 100% !important; } - + .xterm { font-feature-settings: "liga" 1, "calt" 1; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - + .xterm .xterm-screen { font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; font-variant-ligatures: contextual; @@ -256,7 +268,7 @@ const TerminalComponent = forwardRef( .xterm .xterm-screen .xterm-char { font-feature-settings: "liga" 1, "calt" 1; } - + .xterm .xterm-viewport::-webkit-scrollbar { width: 8px; background: transparent; @@ -303,7 +315,7 @@ const TerminalComponent = forwardRef(
- + `; - }, [hostConfig, screenDimensions, config.fontSize]); + }, [hostConfig, screenDimensions, config.fontSize, onBackgroundColorChange]); useEffect(() => { - const updateHtml = async () => { - const html = await generateHTML(); - setHtmlContent(html); - }; - updateHtml(); + setHtmlContent(generateHTML()); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleTotpSubmit = useCallback( - (code: string) => { - const responseType = isPasswordPrompt - ? "password_response" - : "totp_response"; - - webViewRef.current?.injectJavaScript(` - (function() { - if (window.ws && window.ws.readyState === WebSocket.OPEN) { - window.ws.send(JSON.stringify({ - type: '${responseType}', - data: { code: ${JSON.stringify(code)} } - })); - } - })(); - true; - `); - - setTotpRequired(false); - setTotpPrompt(""); - setIsPasswordPrompt(false); - setIsConnecting(true); - setShowConnectingOverlay(true); - }, - [isPasswordPrompt], - ); - - const handleAuthDialogSubmit = useCallback( - (credentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - }) => { - const updatedHostConfig = { - ...hostConfig, - password: credentials.password, - key: credentials.sshKey, - keyPassword: credentials.keyPassword, - authType: credentials.password ? "password" : "key", - }; - - const messageData = { - password: credentials.password, - sshKey: credentials.sshKey, - keyPassword: credentials.keyPassword, - hostConfig: updatedHostConfig, - }; - - webViewRef.current?.injectJavaScript(` - (function() { - if (window.ws && window.ws.readyState === WebSocket.OPEN && window.terminal) { - const data = ${JSON.stringify(messageData)}; - data.cols = window.terminal.cols; - data.rows = window.terminal.rows; - - window.ws.send(JSON.stringify({ - type: 'reconnect_with_credentials', - data: data - })); - } - })(); - true; - `); - setShowAuthDialog(false); - setIsConnecting(true); - }, - [hostConfig], - ); - const handlePostConnectionSetup = useCallback(async () => { const terminalConfig: Partial = { ...MOBILE_DEFAULT_TERMINAL_CONFIG, @@ -993,17 +618,9 @@ const TerminalComponent = forwardRef( terminalConfig.environmentVariables.forEach((envVar, index) => { setTimeout( () => { - const key = envVar.key.replace(/'/g, "\\'"); - const value = envVar.value.replace(/'/g, "\\'"); - webViewRef.current?.injectJavaScript(` - if (window.ws && window.ws.readyState === WebSocket.OPEN) { - window.ws.send(JSON.stringify({ - type: 'input', - data: 'export ${key}="${value}"\\n' - })); - } - true; - `); + const key = envVar.key; + const value = envVar.value; + wsManagerRef.current?.sendInput(`export ${key}="${value}"\n`); }, 100 * (index + 1), ); @@ -1017,19 +634,10 @@ const TerminalComponent = forwardRef( try { const snippets = await getSnippets(); const snippet = snippets.find( - (s) => s.id === terminalConfig.startupSnippetId, + (s: any) => s.id === terminalConfig.startupSnippetId, ); if (snippet) { - const content = snippet.content.replace(/'/g, "\\'"); - webViewRef.current?.injectJavaScript(` - if (window.ws && window.ws.readyState === WebSocket.OPEN) { - window.ws.send(JSON.stringify({ - type: 'input', - data: '${content}\\n' - })); - } - true; - `); + wsManagerRef.current?.sendInput(`${snippet.content}\n`); } } catch (err) { console.warn("Failed to execute startup snippet:", err); @@ -1042,120 +650,175 @@ const TerminalComponent = forwardRef( 100 * (terminalConfig.environmentVariables?.length || 0) + (terminalConfig.startupSnippetId ? 400 : 200); setTimeout(() => { - const moshCommand = terminalConfig.moshCommand!.replace( - /'/g, - "\\'", - ); - webViewRef.current?.injectJavaScript(` - if (window.ws && window.ws.readyState === WebSocket.OPEN) { - window.ws.send(JSON.stringify({ - type: 'input', - data: '${moshCommand}\\n' - })); - } - true; - `); + wsManagerRef.current?.sendInput(`${terminalConfig.moshCommand!}\n`); }, moshDelay); } }, 500); }, [config, hostConfig.terminalConfig]); + const handleTotpSubmit = useCallback( + (code: string) => { + wsManagerRef.current?.sendTotpResponse(code, isPasswordPrompt); + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + setConnectionState("connecting"); + }, + [isPasswordPrompt], + ); + + const handleAuthDialogSubmit = useCallback( + (credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => { + wsManagerRef.current?.sendReconnectWithCredentials( + credentials, + terminalColsRef.current, + terminalRowsRef.current, + ); + setShowAuthDialog(false); + setConnectionState("connecting"); + }, + [], + ); + const handleWebViewMessage = useCallback( (event: any) => { try { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { + case "terminalReady": + terminalColsRef.current = message.data.cols; + terminalRowsRef.current = message.data.rows; + wsManagerRef.current?.connect(message.data.cols, message.data.rows); + break; + + case "resize": + terminalColsRef.current = message.data.cols; + terminalRowsRef.current = message.data.rows; + wsManagerRef.current?.sendResize(message.data.cols, message.data.rows); + break; + + case "selectionStart": + setIsSelecting(true); + break; + + case "selectionEnd": + setIsSelecting(false); + break; + } + } catch (error) { + console.error("[Terminal] Error parsing WebView message:", error); + } + }, + [], + ); + + // Create/destroy manager on hostConfig.id change + useEffect(() => { + wsManagerRef.current?.destroy(); + + wsManagerRef.current = new NativeWebSocketManager({ + hostConfig: hostConfig as TerminalHostConfig, + onStateChange: (state, data) => { + switch (state) { case "connecting": setConnectionState( - message.data.retryCount > 0 ? "reconnecting" : "connecting", + (data?.retryCount as number) > 0 ? "reconnecting" : "connecting", ); - setRetryCount(message.data.retryCount); + setRetryCount((data?.retryCount as number) || 0); break; - - case "connected": + case "connected": { + const fromBackground = data?.fromBackground as boolean; setConnectionState("connected"); setRetryCount(0); - setHasReceivedData(false); + if (!fromBackground) { + // Fresh connection — hide terminal until data arrives + setHasReceivedData(false); + } + // Always clear stale terminal content on every (re)connect + webViewRef.current?.injectJavaScript( + `window.notifyConnected(${fromBackground}); true;`, + ); logActivity("terminal", hostConfig.id, hostConfig.name).catch( () => {}, ); break; - - case "totpRequired": - setTotpPrompt(message.data.prompt); - setIsPasswordPrompt(message.data.isPassword); - setTotpRequired(true); - break; - - case "authDialogNeeded": - setAuthDialogReason(message.data.reason); - setShowAuthDialog(true); - setConnectionState("disconnected"); - break; - - case "setupPostConnection": - handlePostConnectionSetup(); - break; - + } case "dataReceived": setHasReceivedData(true); break; - - case "disconnected": - setConnectionState("disconnected"); - showToast.warning(`Disconnected from ${message.data.hostName}`); - if (onClose) onClose(); - break; - - case "connectionFailed": - setConnectionState("failed"); - handleConnectionFailure( - `${message.data.hostName}: ${message.data.message}`, + } + }, + onData: (data) => { + pendingDataRef.current.push(data); + if (!dataFlushTimerRef.current) { + dataFlushTimerRef.current = setTimeout(() => { + dataFlushTimerRef.current = null; + const batch = pendingDataRef.current.join(""); + pendingDataRef.current = []; + webViewRef.current?.injectJavaScript( + `window.writeToTerminal(${JSON.stringify(batch)}); true;`, ); - break; - - case "backgrounded": - setConnectionState("disconnected"); - break; - - case "foregrounded": - setConnectionState("reconnecting"); - break; + }, 16); + } + if (isScreenReaderEnabledRef.current) { + writeToAccessibility(data); + } + }, + onTotpRequired: (prompt, isPassword) => { + setTotpPrompt(prompt); + setIsPasswordPrompt(isPassword); + setTotpRequired(true); + }, + onAuthDialogNeeded: (reason) => { + setAuthDialogReason(reason); + setShowAuthDialog(true); + setConnectionState("disconnected"); + }, + onPostConnectionSetup: () => handlePostConnectionSetup(), + onDisconnected: (hostName) => { + setConnectionState("disconnected"); + showToast.warning(`Disconnected from ${hostName}`); + if (onClose) onClose(); + }, + onConnectionFailed: (message) => handleConnectionFailure(message), + }); - case "selectionStart": - setIsSelecting(true); - break; + setWebViewKey((prev) => prev + 1); + setConnectionState("connecting"); + setHasReceivedData(false); + setRetryCount(0); - case "selectionEnd": - setIsSelecting(false); - break; + const html = generateHTML(); + setHtmlContent(html); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hostConfig.id]); - case "connectionStatus": - break; - } - } catch (error) { - console.error("[Terminal] Error parsing WebView message:", error); + // Cleanup on unmount + useEffect(() => { + return () => { + wsManagerRef.current?.destroy(); + wsManagerRef.current = null; + if (dataFlushTimerRef.current) { + clearTimeout(dataFlushTimerRef.current); + dataFlushTimerRef.current = null; } - }, - [ - handleConnectionFailure, - onClose, - hostConfig.id, - handlePostConnectionSetup, - ], - ); + if (accessibilityTimerRef.current) { + clearTimeout(accessibilityTimerRef.current); + accessibilityTimerRef.current = null; + } + }; + }, []); useImperativeHandle( ref, () => ({ sendInput: (data: string) => { - try { - const escaped = JSON.stringify(data); - webViewRef.current?.injectJavaScript( - `window.nativeInput(${escaped}); true;`, - ); - } catch (e) {} + wsManagerRef.current?.sendInput(data); }, fit: () => { try { @@ -1168,27 +831,16 @@ const TerminalComponent = forwardRef( return totpRequired || showAuthDialog; }, notifyBackgrounded: () => { - try { - webViewRef.current?.injectJavaScript(` - window.notifyBackgrounded && window.notifyBackgrounded(); - true; - `); - } catch (e) {} + wsManagerRef.current?.notifyBackgrounded(); }, notifyForegrounded: () => { - try { - webViewRef.current?.injectJavaScript(` - window.notifyForegrounded && window.notifyForegrounded(); - true; - `); - } catch (e) {} + wsManagerRef.current?.notifyForegrounded(); }, scrollToBottom: () => { try { - webViewRef.current?.injectJavaScript(` - window.resetScroll && window.resetScroll(); - true; - `); + webViewRef.current?.injectJavaScript( + `window.resetScroll && window.resetScroll(); true;`, + ); } catch (e) {} }, isSelecting: () => { @@ -1198,49 +850,6 @@ const TerminalComponent = forwardRef( [totpRequired, showAuthDialog, isSelecting], ); - useEffect(() => { - if (hostConfig.id !== currentHostId) { - setCurrentHostId(hostConfig.id); - setWebViewKey((prev) => prev + 1); - setConnectionState("connecting"); - setHasReceivedData(false); - setRetryCount(0); - - const updateHtml = async () => { - const html = await generateHTML(); - setHtmlContent(html); - }; - updateHtml(); - } - }, [hostConfig.id, currentHostId]); - - useEffect(() => { - return () => { - webViewRef.current?.injectJavaScript(` - (function() { - try { - clearAllTimeouts(); - stopPingInterval(); - if (window.ws) { - window.ws.close(1000, 'Component unmounted'); - window.ws = null; - } - } catch(e) { - console.error('[CLEANUP] Error:', e); - } - })(); - true; - `); - - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - }; - }, []); - - const focusTerminal = useCallback(() => {}, []); - return ( ( textAlign: "center", }} > - Retry {retryCount}/3 + Retry {retryCount}/5 )} @@ -1398,6 +1007,22 @@ const TerminalComponent = forwardRef( )} + {isScreenReaderEnabled && ( + + )} + Date: Mon, 16 Feb 2026 21:55:40 -0600 Subject: [PATCH 11/16] feat: add missing files --- .../terminal/NativeWebSocketManager.ts | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 app/tabs/sessions/terminal/NativeWebSocketManager.ts diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts new file mode 100644 index 0000000..a603f3e --- /dev/null +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -0,0 +1,471 @@ +import { getCurrentServerUrl, getCookie } from "../../../main-axios"; + +export interface TerminalHostConfig { + id: number; + name: string; + ip: string; + port: number; + username: string; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; +} + +export type WsState = + | "connecting" + | "connected" + | "disconnected" + | "dataReceived" + | "connectionFailed"; + +export interface NativeWSConfig { + hostConfig: TerminalHostConfig; + onStateChange: (state: WsState, data?: Record) => void; + onData: (data: string) => void; + onTotpRequired: (prompt: string, isPassword: boolean) => void; + onAuthDialogNeeded: (reason: "no_keyboard" | "auth_failed" | "timeout") => void; + onPostConnectionSetup: () => void; + onDisconnected: (hostName: string) => void; + onConnectionFailed: (message: string) => void; +} + +export class NativeWebSocketManager { + private config: NativeWSConfig; + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private readonly maxReconnectAttempts = 5; + private reconnectTimeout: ReturnType | null = null; + private connectionTimeout: ReturnType | null = null; + private pingInterval: ReturnType | null = null; + private shouldNotReconnect = false; + private hasNotifiedFailure = false; + private isAppInBackground = false; + private backgroundTime: number | null = null; + private isReconnectFromBackground = false; + private currentConnectionFromBackground = false; + private destroyed = false; + private cols = 80; + private rows = 24; + private wsUrl: string | null = null; + + constructor(config: NativeWSConfig) { + this.config = config; + } + + async connect(cols: number, rows: number): Promise { + if (this.destroyed) return; + + this.cols = cols; + this.rows = rows; + + const serverUrl = getCurrentServerUrl(); + if (!serverUrl) { + this.config.onConnectionFailed( + "No server URL found - please configure a server first", + ); + return; + } + + const jwtToken = await getCookie("jwt"); + if (!jwtToken || jwtToken.trim() === "") { + this.config.onConnectionFailed( + "Authentication required - please log in again", + ); + return; + } + + const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; + const wsHost = serverUrl.replace(/^https?:\/\//, ""); + const cleanHost = wsHost.replace(/\/$/, ""); + this.wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; + + this.connectWebSocket(); + } + + destroy(): void { + this.destroyed = true; + this.shouldNotReconnect = true; + this.clearAllTimers(); + if (this.ws) { + try { + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onclose = null; + this.ws.onerror = null; + if ( + this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING + ) { + this.ws.close(1000, "Component unmounted"); + } + } catch (_) {} + this.ws = null; + } + } + + sendInput(data: string): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "input", data })); + } catch (e) {} + } + } + + sendResize(cols: number, rows: number): void { + this.cols = cols; + this.rows = rows; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "resize", data: { cols, rows } })); + } catch (e) {} + } + } + + sendTotpResponse(code: string, isPassword: boolean): void { + const responseType = isPassword ? "password_response" : "totp_response"; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: responseType, data: { code } })); + } catch (e) {} + } + } + + sendReconnectWithCredentials( + credentials: { password?: string; sshKey?: string; keyPassword?: string }, + cols: number, + rows: number, + ): void { + this.cols = cols; + this.rows = rows; + const updatedHostConfig = { + ...this.config.hostConfig, + password: credentials.password, + key: credentials.sshKey, + keyPassword: credentials.keyPassword, + authType: (credentials.password ? "password" : "key") as + | "password" + | "key", + }; + + const messageData = { + password: credentials.password, + sshKey: credentials.sshKey, + keyPassword: credentials.keyPassword, + hostConfig: updatedHostConfig, + cols, + rows, + }; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send( + JSON.stringify({ type: "reconnect_with_credentials", data: messageData }), + ); + } catch (e) {} + } + } + + notifyBackgrounded(): void { + this.isAppInBackground = true; + this.backgroundTime = Date.now(); + this.reconnectAttempts = 0; + this.stopPingInterval(); + this.clearReconnectTimeout(); + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + // RN JS thread stays alive longer but the WS may still drop; + // record state only and reconnect non-destructively on foreground. + } + + notifyForegrounded(): void { + const wasInBackground = this.isAppInBackground; + this.isAppInBackground = false; + + if (!wasInBackground) return; + if (this.destroyed) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + // Socket survived — just resume pinging, nothing to do + this.startPingInterval(); + return; + } + + // Socket is dead — reconnect + this.isReconnectFromBackground = true; + this.reconnectAttempts = 1; + + // Null out handlers so the stale close event doesn't trigger scheduleReconnect + if (this.ws) { + try { + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.close(); + } catch (_) {} + this.ws = null; + } + this.stopPingInterval(); + + this.connectWebSocket(); + } + + // ─── Private ───────────────────────────────────────────────────────────── + + private connectWebSocket(): void { + if (this.destroyed) return; + + if (!this.wsUrl) { + this.notifyFailureOnce("No WebSocket URL available - server not configured"); + return; + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + // Clean up any existing WS + if (this.ws) { + try { + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + if ( + this.ws.readyState === WebSocket.CONNECTING || + this.ws.readyState === WebSocket.OPEN + ) { + this.ws.close(); + } + } catch (_) {} + } + + this.config.onStateChange("connecting", { + retryCount: this.reconnectAttempts, + }); + + const ws = new WebSocket(this.wsUrl); + this.ws = ws; + + this.connectionTimeout = setTimeout(() => { + if (ws.readyState === WebSocket.CONNECTING) { + try { + ws.onclose = null; + ws.close(); + } catch (_) {} + if (!this.shouldNotReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect(); + } else { + this.notifyFailureOnce("Connection timeout - server not responding"); + } + } + }, 10000); + + ws.onopen = () => { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.clearReconnectTimeout(); + + this.hasNotifiedFailure = false; + this.reconnectAttempts = 0; + + this.currentConnectionFromBackground = this.isReconnectFromBackground; + this.isReconnectFromBackground = false; + + ws.send( + JSON.stringify({ + type: "connectToHost", + data: { + cols: this.cols, + rows: this.rows, + hostConfig: this.config.hostConfig, + }, + }), + ); + + this.startPingInterval(); + }; + + ws.onmessage = (event: MessageEvent) => { + if (this.destroyed) return; + try { + const msg = JSON.parse(event.data as string); + + if (msg.type === "data") { + this.config.onData(msg.data as string); + this.config.onStateChange("dataReceived", { + hostName: this.config.hostConfig.name, + }); + } else if (msg.type === "totp_required") { + this.config.onTotpRequired( + (msg.prompt as string) || "Verification code:", + false, + ); + } else if (msg.type === "password_required") { + this.config.onTotpRequired( + (msg.prompt as string) || "Password:", + true, + ); + } else if ( + msg.type === "keyboard_interactive_available" || + msg.type === "auth_method_not_available" + ) { + this.config.onAuthDialogNeeded("no_keyboard"); + } else if (msg.type === "error") { + const message = (msg.message as string) || "Unknown error"; + if (this.isUnrecoverableError(message)) { + this.shouldNotReconnect = true; + this.notifyFailureOnce("Authentication failed: " + message); + try { + ws.close(1000); + } catch (_) {} + return; + } + } else if (msg.type === "connected") { + this.config.onStateChange("connected", { + hostName: this.config.hostConfig.name, + fromBackground: this.currentConnectionFromBackground, + }); + if (!this.currentConnectionFromBackground) { + this.config.onPostConnectionSetup(); + } + } else if (msg.type === "disconnected") { + this.config.onDisconnected(this.config.hostConfig.name); + } else if (msg.type === "pong") { + // connection healthy + } else if (msg.type === "resized") { + // acknowledged + } + } catch (_) { + // Raw data fallback + this.config.onData(event.data as string); + } + }; + + ws.onclose = (event: CloseEvent) => { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.stopPingInterval(); + + if (this.isAppInBackground) { + // DO NOT reconnect while backgrounded; reconnect on foreground + return; + } + + if (this.destroyed) return; + + if (this.shouldNotReconnect) { + this.notifyFailureOnce("Connection closed"); + return; + } + + if (event.code === 1000 || event.code === 1001) { + this.notifyFailureOnce("Connection closed"); + return; + } + + this.scheduleReconnect(); + }; + + ws.onerror = () => { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + }; + } + + private scheduleReconnect(): void { + if (this.shouldNotReconnect || this.destroyed) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.notifyFailureOnce("Maximum reconnection attempts reached"); + return; + } + + this.reconnectAttempts += 1; + + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 5000); + + this.config.onStateChange("connecting", { + retryCount: this.reconnectAttempts, + }); + + this.clearReconnectTimeout(); + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + if (this.destroyed) return; + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + this.connectWebSocket(); + }, delay); + } + + private startPingInterval(): void { + this.stopPingInterval(); + this.pingInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "ping" })); + } catch (_) {} + } + }, 25000); + } + + private stopPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + private clearReconnectTimeout(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + } + + private clearAllTimers(): void { + this.stopPingInterval(); + this.clearReconnectTimeout(); + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + } + + private notifyFailureOnce(message: string): void { + if (this.hasNotifiedFailure) return; + this.hasNotifiedFailure = true; + this.config.onConnectionFailed( + `${this.config.hostConfig.name}: ${message}`, + ); + } + + private isUnrecoverableError(message: string): boolean { + if (!message) return false; + const m = message.toLowerCase(); + return ( + m.includes("password") || + m.includes("authentication") || + m.includes("permission denied") || + m.includes("invalid") || + m.includes("incorrect") || + m.includes("denied") + ); + } +} From 1ef2a93ed6c5fcee1be9bea9926422fcc5ab4915 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 16 Feb 2026 22:09:09 -0600 Subject: [PATCH 12/16] feat: add host key verification support --- .../dialogs/HostKeyVerificationDialog.tsx | 337 ++++++++++++++++++ app/tabs/dialogs/index.ts | 1 + .../terminal/NativeWebSocketManager.ts | 41 +++ app/tabs/sessions/terminal/Terminal.tsx | 31 +- 4 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 app/tabs/dialogs/HostKeyVerificationDialog.tsx diff --git a/app/tabs/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx new file mode 100644 index 0000000..5177f2b --- /dev/null +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -0,0 +1,337 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + TouchableOpacity, + Modal, + ScrollView, + Platform, + KeyboardAvoidingView, +} from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { Shield, AlertTriangle, Copy } from "lucide-react-native"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { HostKeyData } from "./NativeWebSocketManager"; + +interface HostKeyVerificationDialogProps { + visible: boolean; + scenario: "new" | "changed"; + data: HostKeyData | null; + onAccept: () => void; + onReject: () => void; +} + +const formatFingerprint = (fp: string) => + fp.match(/.{1,2}/g)?.join(":") || fp; + +const FingerprintRow: React.FC<{ + label: string; + algorithm: string; + fingerprint: string; + keyType: string; +}> = ({ label, algorithm, fingerprint, keyType }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await Clipboard.setStringAsync(formatFingerprint(fingerprint)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_) {} + }, [fingerprint]); + + return ( + + + {label} + + + + {algorithm.toUpperCase()} ({keyType}) + + + + {formatFingerprint(fingerprint)} + + + + + {copied ? "Copied!" : "Copy"} + + + + + + ); +}; + +const HostKeyVerificationDialogComponent: React.FC< + HostKeyVerificationDialogProps +> = ({ visible, scenario, data, onAccept, onReject }) => { + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + + const isChanged = scenario === "changed"; + const accentColor = isChanged ? "#ef4444" : "#22c55e"; + const accentBorder = isChanged ? "#dc2626" : "#16a34a"; + + const hostLabel = data + ? `${data.hostname || data.ip}:${data.port}` + : ""; + + return ( + + + + + {/* Header */} + + {isChanged ? ( + + ) : ( + + )} + + {isChanged ? "Host Key Changed!" : "Verify Host Key"} + + + + {/* Subtitle */} + + {hostLabel} + + + {/* Info / Warning box */} + + + {isChanged + ? "The host key has changed." + : "First time connecting."} + + + + {/* Fingerprints */} + {data && isChanged && data.oldFingerprint ? ( + <> + + + + ) : data ? ( + + ) : null} + + {/* Buttons */} + + + + Cancel + + + + + + {isChanged ? "Accept New Key" : "Connect & Trust"} + + + + + + + + ); +}; + +export const HostKeyVerificationDialog = React.memo( + HostKeyVerificationDialogComponent, +); diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts index b7bbf81..c395ab7 100644 --- a/app/tabs/dialogs/index.ts +++ b/app/tabs/dialogs/index.ts @@ -1,2 +1,3 @@ export { TOTPDialog } from "./TOTPDialog"; export { SSHAuthDialog } from "./SSHAuthDialog"; +export { HostKeyVerificationDialog } from "./HostKeyVerificationDialog"; diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index a603f3e..954d266 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -21,12 +21,24 @@ export type WsState = | "dataReceived" | "connectionFailed"; +export interface HostKeyData { + ip: string; + port: number; + hostname?: string; + fingerprint: string; + oldFingerprint?: string; + keyType: string; + oldKeyType?: string; + algorithm: string; +} + export interface NativeWSConfig { hostConfig: TerminalHostConfig; onStateChange: (state: WsState, data?: Record) => void; onData: (data: string) => void; onTotpRequired: (prompt: string, isPassword: boolean) => void; onAuthDialogNeeded: (reason: "no_keyboard" | "auth_failed" | "timeout") => void; + onHostKeyVerificationRequired?: (scenario: "new" | "changed", data: HostKeyData) => void; onPostConnectionSetup: () => void; onDisconnected: (hostName: string) => void; onConnectionFailed: (message: string) => void; @@ -133,6 +145,19 @@ export class NativeWebSocketManager { } } + sendHostKeyResponse(action: "accept" | "reject"): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send( + JSON.stringify({ + type: "host_key_verification_response", + data: { action }, + }), + ); + } catch (e) {} + } + } + sendReconnectWithCredentials( credentials: { password?: string; sshKey?: string; keyPassword?: string }, cols: number, @@ -318,6 +343,22 @@ export class NativeWebSocketManager { msg.type === "auth_method_not_available" ) { this.config.onAuthDialogNeeded("no_keyboard"); + } else if (msg.type === "host_key_verification_required") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + if (this.config.onHostKeyVerificationRequired) { + this.config.onHostKeyVerificationRequired("new", msg.data as HostKeyData); + } + } else if (msg.type === "host_key_changed") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + if (this.config.onHostKeyVerificationRequired) { + this.config.onHostKeyVerificationRequired("changed", msg.data as HostKeyData); + } } else if (msg.type === "error") { const message = (msg.message as string) || "Unknown error"; if (this.isUnrecoverableError(message)) { diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 0c1ca94..e9f533b 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -21,13 +21,14 @@ import { import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; -import { TOTPDialog, SSHAuthDialog } from "@/app/tabs/dialogs"; +import { TOTPDialog, SSHAuthDialog, HostKeyVerificationDialog } from "@/app/tabs/dialogs"; import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; import type { TerminalConfig } from "@/types"; import { NativeWebSocketManager, type TerminalHostConfig, + type HostKeyData, } from "./NativeWebSocketManager"; interface TerminalProps { @@ -106,6 +107,10 @@ const TerminalComponent = forwardRef( "no_keyboard" | "auth_failed" | "timeout" >("auth_failed"); const [isSelecting, setIsSelecting] = useState(false); + const [hostKeyVerification, setHostKeyVerification] = useState<{ + scenario: "new" | "changed"; + data: HostKeyData; + } | null>(null); const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); const isScreenReaderEnabledRef = useRef(false); @@ -779,6 +784,9 @@ const TerminalComponent = forwardRef( setShowAuthDialog(true); setConnectionState("disconnected"); }, + onHostKeyVerificationRequired: (scenario, data) => { + setHostKeyVerification({ scenario, data }); + }, onPostConnectionSetup: () => handlePostConnectionSetup(), onDisconnected: (hostName) => { setConnectionState("disconnected"); @@ -828,7 +836,7 @@ const TerminalComponent = forwardRef( } catch (e) {} }, isDialogOpen: () => { - return totpRequired || showAuthDialog; + return totpRequired || showAuthDialog || hostKeyVerification !== null; }, notifyBackgrounded: () => { wsManagerRef.current?.notifyBackgrounded(); @@ -847,7 +855,7 @@ const TerminalComponent = forwardRef( return isSelecting; }, }), - [totpRequired, showAuthDialog, isSelecting], + [totpRequired, showAuthDialog, hostKeyVerification, isSelecting], ); return ( @@ -877,7 +885,7 @@ const TerminalComponent = forwardRef( > ( }} reason={authDialogReason} /> + + { + wsManagerRef.current?.sendHostKeyResponse("accept"); + setHostKeyVerification(null); + }} + onReject={() => { + wsManagerRef.current?.sendHostKeyResponse("reject"); + setHostKeyVerification(null); + if (onClose) onClose(); + }} + /> ); }, From 44b36778f0df6a763653e66bf7cc3eff4d36ecae Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 16 Feb 2026 23:16:56 -0600 Subject: [PATCH 13/16] fix: dictation issues + android IME multi character input issues on android --- app/tabs/sessions/Sessions.tsx | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 3a86d56..36d6f89 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -867,7 +867,37 @@ export default function Sessions() { // a character that onKeyPress already handled — ignore it to avoid // sending the remaining text as new input. if (text.length <= dictationSentRef.current.length) { - dictationSentRef.current = text; + // If text went to "" while a pending timer/buffer exists, this is an + // Android IME post-commit clear — flush the buffer immediately. + const hasPendingBuffer = + Platform.OS === "android" && + !text && + dictationBufferRef.current && + dictationTimerRef.current !== null; + + if (hasPendingBuffer) { + clearTimeout(dictationTimerRef.current!); + dictationTimerRef.current = null; + const pendingText = dictationBufferRef.current; + const alreadySent = dictationSentRef.current; + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + setHiddenInputValue(""); + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (activeRef?.current) { + if (pendingText.startsWith(alreadySent)) { + const newText = pendingText.slice(alreadySent.length); + if (newText) activeRef.current.sendInput(newText); + } else { + if (pendingText) activeRef.current.sendInput(pendingText); + } + } + return; + } + + if (text) dictationSentRef.current = text; dictationBufferRef.current = ""; if (!text) setHiddenInputValue(""); return; @@ -897,6 +927,7 @@ export default function Sessions() { const finalText = dictationBufferRef.current; const alreadySent = dictationSentRef.current; dictationBufferRef.current = ""; + dictationTimerRef.current = null; setHiddenInputValue(""); // Only send the new suffix that hasn't been sent yet. // iOS keeps all dictated text in the field across words, so From b25663918bb2dab3fb31863a8a14f312b94e0f1c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 17 Feb 2026 16:54:12 -0600 Subject: [PATCH 14/16] fix: none auth hosts --- app/tabs/hosts/navigation/Host.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 9c7ec17..8960c49 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -20,7 +20,6 @@ import { } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; -import { showToast } from "@/app/utils/toast"; import { useEffect, useRef, useState } from "react"; import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -111,28 +110,16 @@ function Host({ host, status, isLast = false }: HostProps) { }; const handleTerminalPress = () => { - if (host.authType === "none") { - showToast.error("None auth type is not supported"); - return; - } navigateToSessions(host, "terminal"); setShowContextMenu(false); }; const handleStatsPress = () => { - if (host.authType === "none") { - showToast.error("None auth type is not supported"); - return; - } navigateToSessions(host, "stats"); setShowContextMenu(false); }; const handleFileManagerPress = () => { - if (host.authType === "none") { - showToast.error("None auth type is not supported"); - return; - } navigateToSessions(host, "filemanager"); setShowContextMenu(false); }; From d248c4ffe456883cd48adbc0d34e5c05f8254e3d Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 17 Feb 2026 17:04:43 -0600 Subject: [PATCH 15/16] chore: clean --- .../dialogs/HostKeyVerificationDialog.tsx | 12 +- app/tabs/sessions/Sessions.tsx | 128 +++++++++++------- .../terminal/NativeWebSocketManager.ts | 50 ++++--- app/tabs/sessions/terminal/Terminal.tsx | 107 ++++++++------- app/utils/responsive.ts | 2 - 5 files changed, 170 insertions(+), 129 deletions(-) diff --git a/app/tabs/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx index 5177f2b..21b6ba8 100644 --- a/app/tabs/dialogs/HostKeyVerificationDialog.tsx +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -29,8 +29,7 @@ interface HostKeyVerificationDialogProps { onReject: () => void; } -const formatFingerprint = (fp: string) => - fp.match(/.{1,2}/g)?.join(":") || fp; +const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp; const FingerprintRow: React.FC<{ label: string; @@ -144,9 +143,7 @@ const HostKeyVerificationDialogComponent: React.FC< const accentColor = isChanged ? "#ef4444" : "#22c55e"; const accentBorder = isChanged ? "#dc2626" : "#16a34a"; - const hostLabel = data - ? `${data.hostname || data.ip}:${data.port}` - : ""; + const hostLabel = data ? `${data.hostname || data.ip}:${data.port}` : ""; return ( - {/* Header */} - {/* Subtitle */} - {/* Info / Warning box */} - {/* Fingerprints */} {data && isChanged && data.oldFingerprint ? ( <> ) : null} - {/* Buttons */} | null>(null); - // Reset dictation state when switching sessions so accumulated text from - // the previous session doesn't bleed into the next one. useEffect(() => { if (dictationTimerRef.current) clearTimeout(dictationTimerRef.current); dictationBufferRef.current = ""; @@ -409,13 +407,12 @@ export default function Sessions() { return; } - // Arrow keys and Escape from UIKeyCommand (not fired by onKeyPress) const specialMap: Record = { - ArrowUp: "\x1b[A", - ArrowDown: "\x1b[B", - ArrowLeft: "\x1b[D", + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowLeft: "\x1b[D", ArrowRight: "\x1b[C", - Escape: "\x1b", + Escape: "\x1b", }; if (specialMap[event.input]) { activeRef.current.sendInput(specialMap[event.input]); @@ -756,7 +753,7 @@ export default function Sessions() { )} -{sessions.length > 0 && + {sessions.length > 0 && (activeSession?.type === "stats" || activeSession?.type === "filemanager") && isCustomKeyboardVisible && ( @@ -862,13 +859,7 @@ export default function Sessions() { underlineColorAndroid="transparent" value={hiddenInputValue} onChangeText={(text) => { - // Deletions are handled by onKeyPress (Backspace → \x7f). - // If text shrank, it's the emoji keyboard's delete button removing - // a character that onKeyPress already handled — ignore it to avoid - // sending the remaining text as new input. if (text.length <= dictationSentRef.current.length) { - // If text went to "" while a pending timer/buffer exists, this is an - // Android IME post-commit clear — flush the buffer immediately. const hasPendingBuffer = Platform.OS === "android" && !text && @@ -915,10 +906,6 @@ export default function Sessions() { dictationBufferRef.current = ""; return; } - // iOS dictation sends incremental updates (e.g. "h" → "he" → "hel" - // → "hello"). We accumulate in a ref and debounce so only the final - // result is sent. Emoji/paste arrive as a single event so the timer - // fires immediately after with the full text. dictationBufferRef.current = text; setHiddenInputValue(text); if (dictationTimerRef.current) @@ -929,9 +916,6 @@ export default function Sessions() { dictationBufferRef.current = ""; dictationTimerRef.current = null; setHiddenInputValue(""); - // Only send the new suffix that hasn't been sent yet. - // iOS keeps all dictated text in the field across words, so - // alreadySent tracks the cumulative text we've already forwarded. if (finalText.startsWith(alreadySent)) { const newText = finalText.slice(alreadySent.length); if (newText) { @@ -939,7 +923,6 @@ export default function Sessions() { activeRef.current?.sendInput(newText); } } else { - // Text was replaced/autocorrected — send the whole thing dictationSentRef.current = finalText; if (finalText) activeRef.current?.sendInput(finalText); } @@ -962,31 +945,81 @@ export default function Sessions() { let finalKey: string | null = null; switch (key) { - case "Enter": finalKey = "\r"; break; - case "Backspace": finalKey = "\x7f"; break; - case "Tab": finalKey = "\t"; break; - case "Escape": finalKey = "\x1b"; break; - case "Delete": finalKey = "\x1b[3~"; break; - case "Home": finalKey = "\x1b[H"; break; - case "End": finalKey = "\x1b[F"; break; - case "PageUp": finalKey = "\x1b[5~"; break; - case "PageDown": finalKey = "\x1b[6~"; break; - case "ArrowUp": finalKey = "\x1b[A"; break; - case "ArrowDown": finalKey = "\x1b[B"; break; - case "ArrowRight": finalKey = "\x1b[C"; break; - case "ArrowLeft": finalKey = "\x1b[D"; break; - case "F1": finalKey = "\x1bOP"; break; - case "F2": finalKey = "\x1bOQ"; break; - case "F3": finalKey = "\x1bOR"; break; - case "F4": finalKey = "\x1bOS"; break; - case "F5": finalKey = "\x1b[15~"; break; - case "F6": finalKey = "\x1b[17~"; break; - case "F7": finalKey = "\x1b[18~"; break; - case "F8": finalKey = "\x1b[19~"; break; - case "F9": finalKey = "\x1b[20~"; break; - case "F10": finalKey = "\x1b[21~"; break; - case "F11": finalKey = "\x1b[23~"; break; - case "F12": finalKey = "\x1b[24~"; break; + case "Enter": + finalKey = "\r"; + break; + case "Backspace": + finalKey = "\x7f"; + break; + case "Tab": + finalKey = "\t"; + break; + case "Escape": + finalKey = "\x1b"; + break; + case "Delete": + finalKey = "\x1b[3~"; + break; + case "Home": + finalKey = "\x1b[H"; + break; + case "End": + finalKey = "\x1b[F"; + break; + case "PageUp": + finalKey = "\x1b[5~"; + break; + case "PageDown": + finalKey = "\x1b[6~"; + break; + case "ArrowUp": + finalKey = "\x1b[A"; + break; + case "ArrowDown": + finalKey = "\x1b[B"; + break; + case "ArrowRight": + finalKey = "\x1b[C"; + break; + case "ArrowLeft": + finalKey = "\x1b[D"; + break; + case "F1": + finalKey = "\x1bOP"; + break; + case "F2": + finalKey = "\x1bOQ"; + break; + case "F3": + finalKey = "\x1bOR"; + break; + case "F4": + finalKey = "\x1bOS"; + break; + case "F5": + finalKey = "\x1b[15~"; + break; + case "F6": + finalKey = "\x1b[17~"; + break; + case "F7": + finalKey = "\x1b[18~"; + break; + case "F8": + finalKey = "\x1b[19~"; + break; + case "F9": + finalKey = "\x1b[20~"; + break; + case "F10": + finalKey = "\x1b[21~"; + break; + case "F11": + finalKey = "\x1b[23~"; + break; + case "F12": + finalKey = "\x1b[24~"; + break; default: if (key.length === 1) { if (activeModifiers.ctrl) { @@ -1034,7 +1067,6 @@ export default function Sessions() { }} /> )} - ); } diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index 954d266..4a0052b 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -37,8 +37,13 @@ export interface NativeWSConfig { onStateChange: (state: WsState, data?: Record) => void; onData: (data: string) => void; onTotpRequired: (prompt: string, isPassword: boolean) => void; - onAuthDialogNeeded: (reason: "no_keyboard" | "auth_failed" | "timeout") => void; - onHostKeyVerificationRequired?: (scenario: "new" | "changed", data: HostKeyData) => void; + onAuthDialogNeeded: ( + reason: "no_keyboard" | "auth_failed" | "timeout", + ) => void; + onHostKeyVerificationRequired?: ( + scenario: "new" | "changed", + data: HostKeyData, + ) => void; onPostConnectionSetup: () => void; onDisconnected: (hostName: string) => void; onConnectionFailed: (message: string) => void; @@ -187,7 +192,10 @@ export class NativeWebSocketManager { if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { this.ws.send( - JSON.stringify({ type: "reconnect_with_credentials", data: messageData }), + JSON.stringify({ + type: "reconnect_with_credentials", + data: messageData, + }), ); } catch (e) {} } @@ -203,8 +211,6 @@ export class NativeWebSocketManager { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } - // RN JS thread stays alive longer but the WS may still drop; - // record state only and reconnect non-destructively on foreground. } notifyForegrounded(): void { @@ -215,16 +221,13 @@ export class NativeWebSocketManager { if (this.destroyed) return; if (this.ws && this.ws.readyState === WebSocket.OPEN) { - // Socket survived — just resume pinging, nothing to do this.startPingInterval(); return; } - // Socket is dead — reconnect this.isReconnectFromBackground = true; this.reconnectAttempts = 1; - // Null out handlers so the stale close event doesn't trigger scheduleReconnect if (this.ws) { try { this.ws.onclose = null; @@ -240,13 +243,13 @@ export class NativeWebSocketManager { this.connectWebSocket(); } - // ─── Private ───────────────────────────────────────────────────────────── - private connectWebSocket(): void { if (this.destroyed) return; if (!this.wsUrl) { - this.notifyFailureOnce("No WebSocket URL available - server not configured"); + this.notifyFailureOnce( + "No WebSocket URL available - server not configured", + ); return; } @@ -254,7 +257,6 @@ export class NativeWebSocketManager { return; } - // Clean up any existing WS if (this.ws) { try { this.ws.onopen = null; @@ -283,7 +285,10 @@ export class NativeWebSocketManager { ws.onclose = null; ws.close(); } catch (_) {} - if (!this.shouldNotReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { + if ( + !this.shouldNotReconnect && + this.reconnectAttempts < this.maxReconnectAttempts + ) { this.scheduleReconnect(); } else { this.notifyFailureOnce("Connection timeout - server not responding"); @@ -349,7 +354,10 @@ export class NativeWebSocketManager { this.connectionTimeout = null; } if (this.config.onHostKeyVerificationRequired) { - this.config.onHostKeyVerificationRequired("new", msg.data as HostKeyData); + this.config.onHostKeyVerificationRequired( + "new", + msg.data as HostKeyData, + ); } } else if (msg.type === "host_key_changed") { if (this.connectionTimeout) { @@ -357,7 +365,10 @@ export class NativeWebSocketManager { this.connectionTimeout = null; } if (this.config.onHostKeyVerificationRequired) { - this.config.onHostKeyVerificationRequired("changed", msg.data as HostKeyData); + this.config.onHostKeyVerificationRequired( + "changed", + msg.data as HostKeyData, + ); } } else if (msg.type === "error") { const message = (msg.message as string) || "Unknown error"; @@ -380,12 +391,9 @@ export class NativeWebSocketManager { } else if (msg.type === "disconnected") { this.config.onDisconnected(this.config.hostConfig.name); } else if (msg.type === "pong") { - // connection healthy } else if (msg.type === "resized") { - // acknowledged } } catch (_) { - // Raw data fallback this.config.onData(event.data as string); } }; @@ -398,7 +406,6 @@ export class NativeWebSocketManager { this.stopPingInterval(); if (this.isAppInBackground) { - // DO NOT reconnect while backgrounded; reconnect on foreground return; } @@ -439,7 +446,10 @@ export class NativeWebSocketManager { this.reconnectAttempts += 1; - const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 5000); + const delay = Math.min( + 1000 * Math.pow(2, this.reconnectAttempts - 1), + 5000, + ); this.config.onStateChange("connecting", { retryCount: this.reconnectAttempts, diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index e9f533b..638c0fa 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -14,14 +14,15 @@ import { AccessibilityInfo, } from "react-native"; import { WebView } from "react-native-webview"; -import { - logActivity, - getSnippets, -} from "../../../main-axios"; +import { logActivity, getSnippets } from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; -import { TOTPDialog, SSHAuthDialog, HostKeyVerificationDialog } from "@/app/tabs/dialogs"; +import { + TOTPDialog, + SSHAuthDialog, + HostKeyVerificationDialog, +} from "@/app/tabs/dialogs"; import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; import type { TerminalConfig } from "@/types"; @@ -78,7 +79,9 @@ const TerminalComponent = forwardRef( const terminalColsRef = useRef(80); const terminalRowsRef = useRef(24); const pendingDataRef = useRef([]); - const dataFlushTimerRef = useRef | null>(null); + const dataFlushTimerRef = useRef | null>( + null, + ); const { config } = useTerminalCustomization(); const [webViewKey, setWebViewKey] = useState(0); @@ -116,7 +119,9 @@ const TerminalComponent = forwardRef( const isScreenReaderEnabledRef = useRef(false); const [accessibilityText, setAccessibilityText] = useState(""); const accessibilityBufferRef = useRef([]); - const accessibilityTimerRef = useRef | null>(null); + const accessibilityTimerRef = useRef | null>( + null, + ); useEffect(() => { AccessibilityInfo.isScreenReaderEnabled().then((enabled) => { @@ -148,7 +153,8 @@ const TerminalComponent = forwardRef( accessibilityBufferRef.current.push(...lines); if (accessibilityBufferRef.current.length > 5) { - accessibilityBufferRef.current = accessibilityBufferRef.current.slice(-5); + accessibilityBufferRef.current = + accessibilityBufferRef.current.slice(-5); } if (accessibilityTimerRef.current) { @@ -380,7 +386,6 @@ const TerminalComponent = forwardRef( fitAddon.fit(); - // Disable autocomplete and suggestions on all input elements setTimeout(() => { const inputs = document.querySelectorAll('input, textarea, .xterm-helper-textarea'); inputs.forEach(input => { @@ -394,12 +399,10 @@ const TerminalComponent = forwardRef( }); }, 100); - // Called by RN via injectJavaScript to write server data window.writeToTerminal = function(data) { try { terminal.write(data); } catch(e) {} }; - // Called by RN after WS connects — always clear so stale output isn't left window.notifyConnected = function(fromBackground) { terminal.clear(); terminal.reset(); @@ -590,7 +593,6 @@ const TerminalComponent = forwardRef( terminal.reset(); terminal.write('\\x1b[2J\\x1b[H'); - // Tell RN the terminal is ready with its initial size setTimeout(function() { fitAddon.fit(); if (window.ReactNativeWebView) { @@ -604,7 +606,12 @@ const TerminalComponent = forwardRef( `; - }, [hostConfig, screenDimensions, config.fontSize, onBackgroundColorChange]); + }, [ + hostConfig, + screenDimensions, + config.fontSize, + onBackgroundColorChange, + ]); useEffect(() => { setHtmlContent(generateHTML()); @@ -689,40 +696,39 @@ const TerminalComponent = forwardRef( [], ); - const handleWebViewMessage = useCallback( - (event: any) => { - try { - const message = JSON.parse(event.nativeEvent.data); + const handleWebViewMessage = useCallback((event: any) => { + try { + const message = JSON.parse(event.nativeEvent.data); + + switch (message.type) { + case "terminalReady": + terminalColsRef.current = message.data.cols; + terminalRowsRef.current = message.data.rows; + wsManagerRef.current?.connect(message.data.cols, message.data.rows); + break; + + case "resize": + terminalColsRef.current = message.data.cols; + terminalRowsRef.current = message.data.rows; + wsManagerRef.current?.sendResize( + message.data.cols, + message.data.rows, + ); + break; - switch (message.type) { - case "terminalReady": - terminalColsRef.current = message.data.cols; - terminalRowsRef.current = message.data.rows; - wsManagerRef.current?.connect(message.data.cols, message.data.rows); - break; + case "selectionStart": + setIsSelecting(true); + break; - case "resize": - terminalColsRef.current = message.data.cols; - terminalRowsRef.current = message.data.rows; - wsManagerRef.current?.sendResize(message.data.cols, message.data.rows); - break; - - case "selectionStart": - setIsSelecting(true); - break; - - case "selectionEnd": - setIsSelecting(false); - break; - } - } catch (error) { - console.error("[Terminal] Error parsing WebView message:", error); + case "selectionEnd": + setIsSelecting(false); + break; } - }, - [], - ); + } catch (error) { + console.error("[Terminal] Error parsing WebView message:", error); + } + }, []); - // Create/destroy manager on hostConfig.id change useEffect(() => { wsManagerRef.current?.destroy(); @@ -732,7 +738,9 @@ const TerminalComponent = forwardRef( switch (state) { case "connecting": setConnectionState( - (data?.retryCount as number) > 0 ? "reconnecting" : "connecting", + (data?.retryCount as number) > 0 + ? "reconnecting" + : "connecting", ); setRetryCount((data?.retryCount as number) || 0); break; @@ -741,10 +749,8 @@ const TerminalComponent = forwardRef( setConnectionState("connected"); setRetryCount(0); if (!fromBackground) { - // Fresh connection — hide terminal until data arrives setHasReceivedData(false); } - // Always clear stale terminal content on every (re)connect webViewRef.current?.injectJavaScript( `window.notifyConnected(${fromBackground}); true;`, ); @@ -803,10 +809,9 @@ const TerminalComponent = forwardRef( const html = generateHTML(); setHtmlContent(html); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostConfig.id]); - // Cleanup on unmount useEffect(() => { return () => { wsManagerRef.current?.destroy(); @@ -885,7 +890,11 @@ const TerminalComponent = forwardRef( > Date: Tue, 17 Feb 2026 17:06:32 -0600 Subject: [PATCH 16/16] chore: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c80ce5..051cfe5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe # Features -- **SSH Terminal** - SSH terminal with multi-session support. Switch between two keyboard modes: the system keyboard and a custom terminal keyboard that is optimized for terminal use. The custom keyboard is completely configurable to your preferences. +- **SSH Terminal** - SSH terminal with multi-session support. Switch between two keyboard modes: the system keyboard and a custom terminal keyboard that is optimized for terminal use. The custom keyboard is completely configurable to your preferences. Has support for background-keepalive, VoiceOver, dication, Bluetooth/physical keyboards, emojis, etc. - **SSH File Manager** - View, edit, modify, and move files and folders via SSH. - **SSH Server Stats** - Get information on a servers status such as CPU, RAM, HDD, etc. - **SSH Tunnels** - Start, stop, and manage SSH tunnels.