diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 70445c4..c4a01a1 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -11,29 +11,30 @@ import { SSHHost } from "@/types"; import { router } from "expo-router"; import { useAppContext } from "@/app/AppContext"; +export type SessionType = + | "terminal" + | "stats" + | "filemanager" + | "tunnel" + | "remoteDesktop"; + export interface TerminalSession { id: string; host: SSHHost; title: string; isActive: boolean; createdAt: Date; - type: "terminal" | "stats" | "filemanager" | "tunnel"; + type: SessionType; } interface TerminalSessionsContextType { sessions: TerminalSession[]; activeSessionId: string | null; - addSession: ( - host: SSHHost, - type?: "terminal" | "stats" | "filemanager" | "tunnel", - ) => string; + addSession: (host: SSHHost, type?: SessionType) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; - navigateToSessions: ( - host?: SSHHost, - type?: "terminal" | "stats" | "filemanager" | "tunnel", - ) => void; + navigateToSessions: (host?: SSHHost, type?: SessionType) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; lastKeyboardHeight: number; @@ -72,10 +73,7 @@ export const TerminalSessionsProvider: React.FC< const [, forceUpdate] = useState({}); const addSession = useCallback( - ( - host: SSHHost, - type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", - ): string => { + (host: SSHHost, type: SessionType = "terminal"): string => { setSessions((prev) => { const existingSessions = prev.filter( (session) => session.host.id === host.id && session.type === type, @@ -88,7 +86,9 @@ export const TerminalSessionsProvider: React.FC< ? "Files" : type === "tunnel" ? "Tunnels" - : ""; + : type === "remoteDesktop" + ? "Remote" + : ""; let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { title = typeLabel @@ -156,7 +156,9 @@ export const TerminalSessionsProvider: React.FC< ? "Files" : session.type === "tunnel" ? "Tunnels" - : ""; + : session.type === "remoteDesktop" + ? "Remote" + : ""; const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` : session.host.name; @@ -208,10 +210,7 @@ export const TerminalSessionsProvider: React.FC< ); const navigateToSessions = useCallback( - ( - host?: SSHHost, - type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", - ) => { + (host?: SSHHost, type: SessionType = "terminal") => { if (host) { addSession(host, type); } diff --git a/app/main-axios.ts b/app/main-axios.ts index e86056c..b568352 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -4,10 +4,6 @@ import type { SSHHostData, TunnelConfig, TunnelStatus, - Credential, - CredentialData, - HostInfo, - ApiResponse, FileManagerFile, FileManagerShortcut, ServerStatus, @@ -287,6 +283,23 @@ export function getCurrentServerUrl(): string | null { return configuredServerUrl; } +export function getGuacamoleWebSocketUrl( + token: string, + width?: number, + height?: number, +): string { + const base = getRootBase(8081).replace(/\/$/, ""); + const websocketBase = base.replace(/^http/i, (scheme) => + scheme.toLowerCase() === "https" ? "wss" : "ws", + ); + const params = new URLSearchParams({ token }); + + if (width) params.set("width", String(width)); + if (height) params.set("height", String(height)); + + return `${websocketBase}/guacamole/websocket/?${params.toString()}`; +} + export async function isAuthenticated(): Promise { try { const token = await getCookie("jwt"); @@ -822,6 +835,17 @@ export async function exportSSHHostWithCredentials( } } +export async function getGuacamoleTokenFromHost( + hostId: number, +): Promise<{ token: string }> { + try { + const response = await authApi.post(`/guacamole/connect-host/${hostId}`); + return response.data; + } catch (error) { + handleApiError(error, "connect Guacamole host"); + } +} + // ============================================================================ // SSH AUTOSTART MANAGEMENT // ============================================================================ diff --git a/app/tabs/hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx index c5ea5d4..432eb48 100644 --- a/app/tabs/hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -110,7 +110,7 @@ export default function Hosts() { }); } - hosts.filter((host: SSHHost) => !host.connectionType || host.connectionType === "ssh").forEach((host: SSHHost) => { + hosts.forEach((host: SSHHost) => { const folderName = host.folder || "No Folder"; if (!folderMap.has(folderName)) { folderMap.set(folderName, { diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 8960c49..94d868c 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -10,13 +10,11 @@ import { } from "react-native"; import { Terminal, - Server, FolderOpen, - Key, - Lock, MoreVertical, X, Activity, + Monitor, } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; @@ -37,6 +35,14 @@ function Host({ host, status, isLast = false }: HostProps) { const [tagsContainerWidth, setTagsContainerWidth] = useState(0); const statusLabel = status === "online" ? "UP" : status === "offline" ? "DOWN" : "UNK"; + const connectionType = (host.connectionType || "ssh").toLowerCase(); + const isRemoteDesktopHost = ["rdp", "vnc", "telnet"].includes(connectionType); + const remoteDesktopLabel = + connectionType === "vnc" + ? "Open VNC Session" + : connectionType === "telnet" + ? "Open Telnet Session" + : "Open RDP Session"; const parsedStatsConfig: StatsConfig = (() => { try { @@ -114,6 +120,11 @@ function Host({ host, status, isLast = false }: HostProps) { setShowContextMenu(false); }; + const handleRemoteDesktopPress = () => { + navigateToSessions(host, "remoteDesktop"); + setShowContextMenu(false); + }; + const handleStatsPress = () => { navigateToSessions(host, "stats"); setShowContextMenu(false); @@ -136,7 +147,7 @@ function Host({ host, status, isLast = false }: HostProps) { <> @@ -361,7 +372,28 @@ function Host({ host, status, isLast = false }: HostProps) { - {host.enableTerminal && ( + {isRemoteDesktopHost && ( + + + + + {remoteDesktopLabel} + + + {host.ip}:{host.port} + + + + )} + + {host.enableTerminal && !isRemoteDesktopHost && ( session.id === activeSessionId, + ); + const getTabBarBottomPosition = () => { if (activeSession?.type !== "terminal") { return insets.bottom; @@ -146,7 +143,7 @@ export default function Sessions() { }; const getBottomMargin = ( - sessionType: "terminal" | "stats" | "filemanager" = "terminal", + sessionType: SessionType = "terminal", ) => { if (sessionType !== "terminal") { return SESSION_TAB_BAR_HEIGHT + insets.bottom; @@ -523,10 +520,6 @@ export default function Sessions() { [], ); - const activeSession = sessions.find( - (session) => session.id === activeSessionId, - ); - const activeTerminalBgColor = activeSession?.type === "terminal" && activeSessionId ? terminalBackgroundColors[activeSessionId] || BACKGROUNDS.DARKEST @@ -625,6 +618,16 @@ export default function Sessions() { onClose={() => handleTabClose(session.id)} /> ); + } else if (session.type === "remoteDesktop") { + return ( + handleTabClose(session.id)} + /> + ); } return null; })} diff --git a/app/tabs/sessions/navigation/TabBar.tsx b/app/tabs/sessions/navigation/TabBar.tsx index 3157c14..074eada 100644 --- a/app/tabs/sessions/navigation/TabBar.tsx +++ b/app/tabs/sessions/navigation/TabBar.tsx @@ -16,7 +16,10 @@ import { ChevronDown, ChevronUp, } from "lucide-react-native"; -import { TerminalSession } from "@/app/contexts/TerminalSessionsContext"; +import { + SessionType, + TerminalSession, +} from "@/app/contexts/TerminalSessionsContext"; import { useRouter } from "expo-router"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { useOrientation } from "@/app/utils/orientation"; @@ -40,7 +43,7 @@ interface TabBarProps { onHideKeyboard?: () => void; onShowKeyboard?: () => void; keyboardIntentionallyHiddenRef: React.MutableRefObject; - activeSessionType?: "terminal" | "stats" | "filemanager"; + activeSessionType?: SessionType; } export default function TabBar({ diff --git a/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx new file mode 100644 index 0000000..768a3f7 --- /dev/null +++ b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx @@ -0,0 +1,508 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + useWindowDimensions, + View, +} from "react-native"; +import { WebView } from "react-native-webview"; +import { Keyboard, RotateCcw } from "lucide-react-native"; +import type { SSHHost } from "@/types"; +import { + getGuacamoleTokenFromHost, + getGuacamoleWebSocketUrl, +} from "@/app/main-axios"; + +type ConnectionState = + | "idle" + | "connecting" + | "connected" + | "disconnected" + | "failed"; + +interface RemoteDesktopProps { + host: SSHHost; + isVisible: boolean; + title: string; + onClose?: () => void; +} + +export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { + const { width, height } = useWindowDimensions(); + const initialSizeRef = useRef({ + width: Math.round(width), + height: Math.round(height), + }); + const webViewRef = useRef(null); + const inputRef = useRef(null); + const [connectionState, setConnectionState] = + useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [webSocketUrl, setWebSocketUrl] = useState(null); + const [webViewKey, setWebViewKey] = useState(0); + const [inputValue, setInputValue] = useState(""); + + const connect = useCallback(async () => { + try { + setConnectionState("connecting"); + setErrorMessage(null); + + const { token } = await getGuacamoleTokenFromHost(Number(host.id)); + const initialSize = initialSizeRef.current; + setWebSocketUrl( + getGuacamoleWebSocketUrl(token, initialSize.width, initialSize.height), + ); + } catch (error) { + setConnectionState("failed"); + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to start remote session", + ); + } + }, [host.id]); + + useEffect(() => { + connect(); + }, [connect, webViewKey]); + + const handleMessage = useCallback((event: any) => { + try { + const payload = JSON.parse(event.nativeEvent.data); + + if (payload.type === "state") { + if (payload.state === "connected") { + setConnectionState("connected"); + setErrorMessage(null); + } else if (payload.state === "disconnected") { + setConnectionState("disconnected"); + } + } else if (payload.type === "error") { + setConnectionState("failed"); + setErrorMessage(payload.message || "Remote session failed"); + } + } catch { + setConnectionState("failed"); + setErrorMessage("Remote session returned an invalid message"); + } + }, []); + + const htmlContent = useMemo(() => { + if (!webSocketUrl) return ""; + + return ` + + + + + + + +
+ + +`; + }, [webSocketUrl]); + + const reconnect = useCallback(() => { + setWebSocketUrl(null); + setWebViewKey((current) => current + 1); + }, []); + + const injectRemoteCommand = useCallback((script: string) => { + webViewRef.current?.injectJavaScript(`${script}; true;`); + }, []); + + const sendKeysym = useCallback( + (keysym: number) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendKeysym(${keysym})`, + ); + }, + [injectRemoteCommand], + ); + + const sendKeysyms = useCallback( + (keysyms: number[]) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendKeysyms(${JSON.stringify(keysyms)})`, + ); + }, + [injectRemoteCommand], + ); + + const sendText = useCallback( + (text: string) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendText(${JSON.stringify(text)})`, + ); + }, + [injectRemoteCommand], + ); + + const handleInputChange = useCallback( + (text: string) => { + if (text) { + sendText(text); + } + setInputValue(""); + }, + [sendText], + ); + + const protocol = (host.connectionType || "rdp").toUpperCase(); + const canSendInput = connectionState === "connected"; + + useEffect(() => { + if (!canSendInput) return; + + injectRemoteCommand( + `window.termixRemote && window.termixRemote.resize(${Math.round(width)}, ${Math.round(height)})`, + ); + }, [canSendInput, height, injectRemoteCommand, width]); + + return ( + + {htmlContent ? ( + { + setConnectionState("failed"); + setErrorMessage(event.nativeEvent.description); + }} + onHttpError={(event) => { + setConnectionState("failed"); + setErrorMessage( + `WebView HTTP error: ${event.nativeEvent.statusCode}`, + ); + }} + scrollEnabled={false} + overScrollMode="never" + bounces={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + setSupportMultipleWindows={false} + /> + ) : null} + + {(connectionState === "connecting" || connectionState === "idle") && ( + + + Connecting {protocol} + {title} + + )} + + {(connectionState === "failed" || connectionState === "disconnected") && ( + + + {connectionState === "failed" + ? "Connection Failed" + : "Disconnected"} + + {errorMessage ? ( + {errorMessage} + ) : null} + + + Reconnect + + + )} + + {canSendInput && ( + + { + if (nativeEvent.key === "Backspace") { + sendKeysym(0xff08); + } else if (nativeEvent.key === "Enter") { + sendKeysym(0xff0d); + } + }} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + style={styles.hiddenInput} + /> + + } + onPress={() => inputRef.current?.focus()} + /> + sendKeysym(0xff1b)} /> + sendKeysym(0xff09)} /> + sendKeysym(0xff0d)} /> + sendKeysym(0xff08)} /> + sendKeysym(0xff51)} /> + sendKeysym(0xff52)} /> + sendKeysym(0xff54)} /> + sendKeysym(0xff53)} /> + sendKeysyms([0xffe3, 0xffe9, 0xffff])} + /> + + + )} + + ); +} + +function RemoteKeyButton({ + label, + icon, + onPress, +}: { + label: string; + icon?: React.ReactNode; + onPress: () => void; +}) { + return ( + + {icon} + {label} + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#050608", + }, + webView: { + flex: 1, + width: "100%", + height: "100%", + backgroundColor: "#050608", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + backgroundColor: "#050608", + }, + overlayTitle: { + marginTop: 12, + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + textAlign: "center", + }, + overlayText: { + marginTop: 8, + color: "#9CA3AF", + fontSize: 13, + lineHeight: 18, + textAlign: "center", + }, + retryButton: { + marginTop: 18, + minHeight: 40, + paddingHorizontal: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: "#16A34A", + backgroundColor: "#22C55E", + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + retryText: { + color: "#ffffff", + fontSize: 14, + fontWeight: "600", + }, + toolbar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + minHeight: 48, + paddingHorizontal: 8, + paddingVertical: 6, + backgroundColor: "#111827", + borderTopWidth: 1, + borderTopColor: "#303032", + }, + toolbarContent: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + hiddenInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + color: "transparent", + }, + keyButton: { + minWidth: 44, + height: 36, + paddingHorizontal: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: "#303032", + backgroundColor: "#1F2937", + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 4, + }, + keyButtonText: { + color: "#ffffff", + fontSize: 12, + fontWeight: "600", + }, +}); diff --git a/types/index.ts b/types/index.ts index 97c094e..c2b312e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -13,7 +13,7 @@ export interface QuickAction { export interface SSHHost { id: number; - connectionType?: string; + connectionType?: "ssh" | "rdp" | "vnc" | "telnet" | string; name: string; ip: string; port: number;