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/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. 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/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx new file mode 100644 index 0000000..21b6ba8 --- /dev/null +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -0,0 +1,329 @@ +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 ( + + + + + + {isChanged ? ( + + ) : ( + + )} + + {isChanged ? "Host Key Changed!" : "Verify Host Key"} + + + + + {hostLabel} + + + + + {isChanged + ? "The host key has changed." + : "First time connecting."} + + + + {data && isChanged && data.oldFingerprint ? ( + <> + + + > + ) : data ? ( + + ) : null} + + + + + Cancel + + + + + + {isChanged ? "Accept New Key" : "Connect & Trust"} + + + + + + + + ); +}; + +export const HostKeyVerificationDialog = React.memo( + HostKeyVerificationDialogComponent, +); 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); + useEffect(() => { + if (dictationTimerRef.current) clearTimeout(dictationTimerRef.current); + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + setHiddenInputValue(""); + }, [activeSessionId]); const lastBlurTimeRef = useRef(0); const [terminalBackgroundColors, setTerminalBackgroundColors] = useState< Record @@ -94,7 +106,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; @@ -375,6 +387,41 @@ export default function Sessions() { customKeyboardHeight, ]); + useEffect(() => { + if (Platform.OS !== "ios") return; + const sub = addKeyCommandListener((event) => { + 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; + } + + 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]); + useFocusEffect( React.useCallback(() => { if ( @@ -679,7 +726,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, @@ -794,7 +841,7 @@ export default function Sessions() { backgroundColor: "transparent", zIndex: -1, }} - pointerEvents="none" + pointerEvents="box-none" autoFocus={false} showSoftInputOnFocus={true} keyboardType={keyboardType} @@ -805,11 +852,88 @@ export default function Sessions() { autoCapitalize="none" spellCheck={false} textContentType="none" + importantForAutofill="no" + autoComplete="off" caretHidden contextMenuHidden underlineColorAndroid="transparent" - multiline - onChangeText={() => {}} + value={hiddenInputValue} + onChangeText={(text) => { + if (text.length <= dictationSentRef.current.length) { + 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; + } + if (!text) { + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + return; + } + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (!activeRef?.current) { + setHiddenInputValue(""); + dictationBufferRef.current = ""; + return; + } + dictationBufferRef.current = text; + setHiddenInputValue(text); + if (dictationTimerRef.current) + clearTimeout(dictationTimerRef.current); + dictationTimerRef.current = setTimeout(() => { + const finalText = dictationBufferRef.current; + const alreadySent = dictationSentRef.current; + dictationBufferRef.current = ""; + dictationTimerRef.current = null; + setHiddenInputValue(""); + if (finalText.startsWith(alreadySent)) { + const newText = finalText.slice(alreadySent.length); + if (newText) { + dictationSentRef.current = finalText; + activeRef.current?.sendInput(newText); + } + } else { + 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 @@ -818,51 +942,97 @@ 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) { + 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) { 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); } }} diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts new file mode 100644 index 0000000..4a0052b --- /dev/null +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -0,0 +1,522 @@ +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 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; +} + +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) {} + } + } + + 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, + 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; + } + } + + notifyForegrounded(): void { + const wasInBackground = this.isAppInBackground; + this.isAppInBackground = false; + + if (!wasInBackground) return; + if (this.destroyed) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.startPingInterval(); + return; + } + + this.isReconnectFromBackground = true; + this.reconnectAttempts = 1; + + 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 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; + } + + 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 === "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)) { + 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") { + } else if (msg.type === "resized") { + } + } catch (_) { + 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) { + 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") + ); + } +} diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 949a6b0..638c0fa 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -11,26 +11,26 @@ 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"; +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 } 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 { hostConfig: { @@ -75,6 +75,14 @@ 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 +99,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(""); @@ -106,6 +110,64 @@ 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); + 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( @@ -129,51 +191,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 +206,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 +244,7 @@ const TerminalComponent = forwardRef( width: 100vw; height: 100vh; } - + #terminal { width: 100vw; height: 100vh; @@ -230,24 +253,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 +279,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 +326,7 @@ const TerminalComponent = forwardRef( - +
Please configure a server first