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(
- + `; - }, [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 +630,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 +646,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 +662,176 @@ 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 handleWebViewMessage = useCallback( - (event: any) => { - try { - const message = JSON.parse(event.nativeEvent.data); + 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; - switch (message.type) { + case "selectionEnd": + setIsSelecting(false); + break; + } + } catch (error) { + console.error("[Terminal] Error parsing WebView message:", error); + } + }, []); + + 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) { + setHasReceivedData(false); + } + 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"); + }, + onHostKeyVerificationRequired: (scenario, data) => { + setHostKeyVerification({ scenario, data }); + }, + 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); + 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 { @@ -1165,82 +841,28 @@ const TerminalComponent = forwardRef( } catch (e) {} }, isDialogOpen: () => { - return totpRequired || showAuthDialog; + return totpRequired || showAuthDialog || hostKeyVerification !== null; }, 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: () => { return isSelecting; }, }), - [totpRequired, showAuthDialog, isSelecting], + [totpRequired, showAuthDialog, hostKeyVerification, 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 +1024,22 @@ const TerminalComponent = forwardRef( )} + {isScreenReaderEnabled && ( + + )} + ( }} reason={authDialogReason} /> + + { + wsManagerRef.current?.sendHostKeyResponse("accept"); + setHostKeyVerification(null); + }} + onReject={() => { + wsManagerRef.current?.sendHostKeyResponse("reject"); + setHostKeyVerification(null); + if (onClose) onClose(); + }} + /> ); }, 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); } diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts index 8faa476..c69d217 100644 --- a/app/utils/responsive.ts +++ b/app/utils/responsive.ts @@ -26,9 +26,11 @@ export function getResponsiveFontSize( export function getMaxKeyboardHeight( screenHeight: number, isLandscape: boolean, + isIPad: boolean = false, ): number { if (!isLandscape) return screenHeight; - return screenHeight * 0.4; + const cap = isIPad ? 0.6 : 0.4; + return screenHeight * cap; } export function getTabBarHeight(isLandscape: boolean): number { 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..4f0d276 --- /dev/null +++ b/modules/hardware-keyboard/index.ts @@ -0,0 +1,26 @@ +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 { + addListener( + eventName: "onKeyCommand", + listener: (event: KeyCommandEvent) => void, + ): EventSubscription; +} + +const HardwareKeyboard = + requireOptionalNativeModule("HardwareKeyboard"); + +export function addKeyCommandListener( + listener: (event: KeyCommandEvent) => void, +): 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 new file mode 100644 index 0000000..89c664c --- /dev/null +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -0,0 +1,106 @@ +import ExpoModulesCore +import UIKit + +public class HardwareKeyboardModule: Module { + private static var swizzled = false + private static var instances: [ObjectIdentifier: HardwareKeyboardModule] = [:] + + public func definition() -> ModuleDefinition { + Name("HardwareKeyboard") + + Events("onKeyCommand") + + OnCreate { + // Register immediately on creation, not waiting for a listener + HardwareKeyboardModule.instances[ObjectIdentifier(self)] = self + HardwareKeyboardModule.swizzleIfNeeded() + } + + OnDestroy { + HardwareKeyboardModule.instances.removeValue(forKey: ObjectIdentifier(self)) + } + } + + 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) + } + + 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) + } + } +} + +extension UIViewController { + @objc func hk_keyCommands() -> [UIKeyCommand]? { + var commands = self.hk_keyCommands() ?? [] + + // Shift+Tab + let shiftTab = UIKeyCommand(input: "\t", modifierFlags: .shift, action: #selector(hk_handleShiftTab)) + shiftTab.wantsPriorityOverSystemBehavior = true + commands.append(shiftTab) + + // Arrow keys + 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", + "[","]","\\", + ] + 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.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.emitToAll(input, shift: false, ctrl: true) + } +} 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",