diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 3b9244d..7366de0 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -9,7 +9,7 @@ on: required: true options: - both - - ios + - apple - android action: type: choice @@ -24,10 +24,10 @@ on: jobs: build: name: build-${{ matrix.platform }} - runs-on: ${{ matrix.platform == 'ios' && 'blacksmith-6vcpu-macos-latest' || 'blacksmith-8vcpu-ubuntu-2404' }} + runs-on: ${{ matrix.platform == 'apple' && 'blacksmith-6vcpu-macos-latest' || 'blacksmith-8vcpu-ubuntu-2404' }} strategy: matrix: - platform: ${{ github.event.inputs.platform == 'both' && fromJSON('["ios", "android"]') || fromJSON(format('["{0}"]', github.event.inputs.platform)) }} + platform: ${{ github.event.inputs.platform == 'both' && fromJSON('["apple", "android"]') || fromJSON(format('["{0}"]', github.event.inputs.platform)) }} node: [20.x] steps: - name: Setup repo @@ -37,7 +37,7 @@ jobs: uses: actions/setup-node@v4.0.2 with: node-version: ${{ matrix.node }} - cache: 'npm' + cache: "npm" - name: Setup Expo and EAS uses: expo/expo-github-action@v7 @@ -54,7 +54,8 @@ jobs: echo "EAS configuration:" cat eas.json echo "Validating configuration..." - eas build:configure --platform ${{ matrix.platform }} + EAS_PLATFORM=${{ matrix.platform == 'apple' && 'ios' || matrix.platform }} + eas build:configure --platform $EAS_PLATFORM - name: Build app run: | @@ -72,9 +73,11 @@ jobs: VERSION=$(node -p "require('./app.json').expo.version") echo "App version: $VERSION" + EAS_PLATFORM=${{ matrix.platform == 'apple' && 'ios' || matrix.platform }} + eas build --local \ --non-interactive \ - --platform=${{ matrix.platform }} \ + --platform=$EAS_PLATFORM \ --profile=$PROFILE if [ "${{ matrix.platform }}" = "android" ]; then @@ -111,7 +114,7 @@ jobs: fi fi fi - elif [ "${{ matrix.platform }}" = "ios" ]; then + elif [ "${{ matrix.platform }}" = "apple" ]; then echo "Searching for iOS build artifacts..." IPA_FILE=$(find . -maxdepth 1 -type f -name "build-*.ipa" 2>/dev/null | head -1) @@ -175,7 +178,8 @@ jobs: if [ -n "$BUILD_FILE" ] && [ -f "$BUILD_FILE" ]; then echo "Submitting file: $BUILD_FILE" - eas submit -p ${{ matrix.platform }} --profile production --path "$BUILD_FILE" + EAS_PLATFORM=${{ matrix.platform == 'apple' && 'ios' || matrix.platform }} + eas submit -p $EAS_PLATFORM --profile production --path "$BUILD_FILE" else echo "No build file found to submit" exit 1 @@ -191,7 +195,7 @@ jobs: if-no-files-found: error - name: Upload iOS IPA - if: ${{ github.event.inputs.action == 'file' && matrix.platform == 'ios' }} + if: ${{ github.event.inputs.action == 'file' && matrix.platform == 'apple' }} uses: actions/upload-artifact@v4 with: name: termix_ios_universal @@ -258,7 +262,7 @@ jobs: echo "ERROR: Android APK not found: $APK_FILE" exit 1 fi - elif [ "${{ matrix.platform }}" = "ios" ]; then + elif [ "${{ matrix.platform }}" = "apple" ]; then IPA_FILE="build-artifacts/termix_ios.ipa" if [ -f "$IPA_FILE" ]; then echo "Uploading $IPA_FILE to Termix-SSH/Termix..." diff --git a/.github/workflows/build-ipados.yml b/.github/workflows/build-ipados.yml index 1854876..1b41ace 100644 --- a/.github/workflows/build-ipados.yml +++ b/.github/workflows/build-ipados.yml @@ -13,8 +13,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77b768c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + branches: + - main + - dev-1.4.0 + push: + branches: + - main + - dev-1.4.0 + +jobs: + check: + name: checks + runs-on: ubuntu-latest + + 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 ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Type-check + run: npx tsc --noEmit --pretty false + + - name: Validate Expo config + run: npx expo config --type public --json > /tmp/expo-config.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48e105b..4c2cab0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,13 +9,13 @@ ## Installation 1. Clone the repository: - ```sh - git clone https://github.com/Termix-SSH/Mobile - ``` + ```sh + git clone https://github.com/Termix-SSH/Mobile + ``` 2. Install the dependencies: - ```sh - npm install - ``` + ```sh + npm install + ``` ## Running the development server @@ -32,22 +32,22 @@ This will start the Expo development server. You can use `Expo Go` or use it in 1. **Fork the repository**: Click the "Fork" button at the top right of the [repository page](https://github.com/Termix-SSH/Mobile). 2. **Create a new branch**: - ```sh - git checkout -b feature/my-new-feature - ``` + ```sh + git checkout -b feature/my-new-feature + ``` 3. **Make your changes**: Implement your feature, fix, or improvement. 4. **Commit your changes**: - ```sh - git commit -m "Feature request my new feature" - ``` + ```sh + git commit -m "Feature request my new feature" + ``` 5. **Push to your fork**: - ```sh - git push origin feature/my-feature-request - ``` + ```sh + git push origin feature/my-feature-request + ``` 6. **Open a pull request**: Go to the original repository and create a PR with a clear description. ## Support If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support -channel, however, response times may be longer. \ No newline at end of file +channel, however, response times may be longer. diff --git a/README.md b/README.md index 69b4809..ea88485 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ ## Overview -Full remote SSH control of your servers with Termix, the ultimate SSH server management tool. It connects to your existing Termix server to provide you with SSH server access. +Requires a Termix server. Install at https://docs.termix.site/install + +Termix gives you full control of your servers from your phone. Connect to your Termix server and manage everything from one place.
@@ -30,35 +32,55 @@ Full remote SSH control of your servers with Termix, the ultimate SSH server man **SSH Terminal:** -SSH terminal with multi-session support. Switch between two keyboard modes: the system keyboard and a custom terminal keyboard optimized for terminal use. The custom keyboard is completely configurable. Has support for background-keepalive, VoiceOver, dictation, Bluetooth/physical keyboards, emojis, etc. +Open multiple terminal sessions at once with tab-based navigation. Switch between a standard keyboard and a custom terminal keyboard built for SSH use. Customize the keyboard layout, save snippets for quick reuse, and use hardware keyboards, dictation, and VoiceOver. Sessions stay alive in the background. -**SSH File Manager:** -View, edit, modify, and move files and folders via SSH. +**File Manager:** +Browse, view, edit, move, and manage files and folders over SSH. -**SSH Server Stats:** -Get information on a server's status such as CPU, RAM, disk usage, and more. +**Server Stats:** +Monitor CPU, memory, disk, network, uptime, running processes, open ports, firewall rules, and login history in real time. **SSH Tunnels:** -Start, stop, and manage SSH tunnels directly from your mobile device. +Create, start, and stop SSH tunnels. -**Server Configuration:** -Easily connect to your existing Termix server via IP/domain. Supports reverse proxy access login pages, OIDC login, and standard username/password logins. +**Docker:** +View and manage Docker containers directly from your phone. + + + + +**Remote Desktop:** +Access a graphical desktop on your server (RDP, VNC, and Telnet supported). + + + + + + +**Host Management:** +Organize your servers into folders. Save credentials and SSH keys per host. Use Quick Connect to jump into a session fast. + + + + +**Settings:** +Customize your terminal appearance, keyboard layout, and keys. Manage API keys, active sessions, and two-factor authentication for your Termix account. @@ -83,8 +105,16 @@ Termix Mobile is available for both [Android](https://docs.termix.site/install/c Termix Mobile Screenshot 1 Termix Mobile Screenshot 2 Termix Mobile Screenshot 3 + + Termix Mobile Screenshot 4 Termix Mobile Screenshot 5 +Termix Mobile Screenshot 6 + + +Termix Mobile Screenshot 7 +Termix Mobile Screenshot 8 +Termix Mobile Screenshot 9 diff --git a/app.json b/app.json index 5326bbf..2ea1169 100644 --- a/app.json +++ b/app.json @@ -2,12 +2,12 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.3.2", + "version": "1.4.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", "githubUrl": "https://github.com/Termix-SSH/Mobile", - "backgroundColor": "#18181b", + "backgroundColor": "#0c0d0b", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { @@ -20,7 +20,8 @@ }, "infoPlist": { "CFBundleAllowMixedLocalizations": true, - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "NSFaceIDUsageDescription": "Use Face ID to unlock Termix." } }, "android": { @@ -28,7 +29,7 @@ "package": "com.karmaa.termix", "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#18181b" + "backgroundColor": "#0c0d0b" } }, "web": { @@ -37,15 +38,21 @@ }, "plugins": [ "expo-router", + [ + "expo-local-authentication", + { + "faceIDPermission": "Use Face ID to unlock Termix." + } + ], [ "expo-splash-screen", { - "backgroundColor": "#18181b", + "backgroundColor": "#0c0d0b", "image": "./assets/images/splash-icon.png", "imageWidth": 200, "dark": { "image": "./assets/images/splash-icon.png", - "backgroundColor": "#18181b" + "backgroundColor": "#0c0d0b" } } ], @@ -54,7 +61,8 @@ { "android": { "newArchEnabled": true, - "usesCleartextTraffic": true + "usesCleartextTraffic": true, + "gradle": "8.13.1" }, "ios": { "newArchEnabled": true, @@ -64,7 +72,10 @@ ], "./plugins/withIOSNetworkSecurity.js", "./plugins/withNetworkSecurityConfig.js", - "expo-dev-client" + "./plugins/withAndroidLocalNetworkSsl.js", + "expo-dev-client", + "expo-secure-store", + "expo-web-browser" ], "experiments": { "typedRoutes": true, @@ -78,6 +89,9 @@ "projectId": "cf86f530-ca4b-44bf-bb68-4d178b264910" } }, + "assetBundlePatterns": [ + "assets/**/*" + ], "owner": "termix" } } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 0f607b2..b9f11a6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,70 +1,22 @@ import { Tabs, usePathname } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTerminalSessions } from "../contexts/TerminalSessionsContext"; -import { useOrientation } from "../utils/orientation"; -import { getTabBarHeight } from "../utils/responsive"; +import { CustomTabBar } from "@/app/components/CustomTabBar"; export default function TabLayout() { - const insets = useSafeAreaInsets(); const { sessions } = useTerminalSessions(); const pathname = usePathname(); - const { isLandscape } = useOrientation(); - const isSessionsTab = pathname === "/sessions"; - const hasActiveSessions = sessions.length > 0; - const shouldHideMainTabBar = isSessionsTab && hasActiveSessions; - - const tabBarHeight = getTabBarHeight(isLandscape); + // Hide the main tab bar when viewing a full-screen session. + const hideTabBar = pathname === "/sessions" && sessions.length > 0; return ( null : (props) => } + screenOptions={{ headerShown: false }} > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> + + + ); } diff --git a/app/(tabs)/hosts.tsx b/app/(tabs)/hosts.tsx index fff8fde..95c8568 100644 --- a/app/(tabs)/hosts.tsx +++ b/app/(tabs)/hosts.tsx @@ -1,5 +1,18 @@ import Hosts from "@/app/tabs/hosts/Hosts"; +import { Screen } from "@/app/components/Screen"; +import { ConnectEmptyState } from "@/app/components/ConnectEmptyState"; +import { useAppContext } from "@/app/AppContext"; export default function HostsScreen() { + const { isAuthenticated } = useAppContext(); + + if (!isAuthenticated) { + return ( + + + + ); + } + return ; } diff --git a/app/(tabs)/sessions.tsx b/app/(tabs)/sessions.tsx index 1f964ca..8c44b29 100644 --- a/app/(tabs)/sessions.tsx +++ b/app/(tabs)/sessions.tsx @@ -1,5 +1,18 @@ import Sessions from "@/app/tabs/sessions/Sessions"; +import { Screen } from "@/app/components/Screen"; +import { ConnectEmptyState } from "@/app/components/ConnectEmptyState"; +import { useAppContext } from "@/app/AppContext"; export default function SessionsScreen() { + const { isAuthenticated } = useAppContext(); + + if (!isAuthenticated) { + return ( + + + + ); + } + return ; } diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 1fc7a5d..707b18a 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -5,16 +5,16 @@ import React, { ReactNode, useEffect, useRef, + useCallback, } from "react"; import { AppState, AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { getVersionInfo, initializeServerConfig, - isAuthenticated as checkAuthStatus, getLatestGitHubRelease, setAuthStateCallback, - clearServerConfig, + getCurrentServerUrl, } from "./main-axios"; import Constants from "expo-constants"; @@ -23,11 +23,10 @@ interface Server { ip: string; } +/** Steps the auth flow can be opened directly to. */ +export type AuthStep = "server" | "login" | "signup"; + interface AppContextType { - showServerManager: boolean; - setShowServerManager: (show: boolean) => void; - showLoginForm: boolean; - setShowLoginForm: (show: boolean) => void; selectedServer: Server | null; setSelectedServer: (server: Server | null) => void; isAuthenticated: boolean; @@ -36,6 +35,16 @@ interface AppContextType { setShowUpdateScreen: (show: boolean) => void; isLoading: boolean; setIsLoading: (loading: boolean) => void; + + /** Whether a server URL is currently configured (drives empty states). */ + hasServerConfigured: boolean; + setHasServerConfigured: (has: boolean) => void; + + /** Auth flow overlay control. */ + authFlowVisible: boolean; + authFlowInitialStep: AuthStep; + openAuthFlow: (step?: AuthStep) => void; + closeAuthFlow: () => void; } const AppContext = createContext(undefined); @@ -53,13 +62,24 @@ interface AppProviderProps { } export const AppProvider: React.FC = ({ children }) => { - const [showServerManager, setShowServerManager] = useState(false); - const [showLoginForm, setShowLoginForm] = useState(false); const [selectedServer, setSelectedServer] = useState(null); const [isAuthenticated, setAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [showUpdateScreen, setShowUpdateScreen] = useState(false); + const [hasServerConfigured, setHasServerConfigured] = useState(false); + + const [authFlowVisible, setAuthFlowVisible] = useState(false); + const [authFlowInitialStep, setAuthFlowInitialStep] = + useState("server"); + + const openAuthFlow = useCallback((step: AuthStep = "server") => { + setAuthFlowInitialStep(step); + setAuthFlowVisible(true); + }, []); + + const closeAuthFlow = useCallback(() => { + setAuthFlowVisible(false); + }, []); const checkShouldShowUpdateScreen = async (): Promise => { try { @@ -99,12 +119,15 @@ export const AppProvider: React.FC = ({ children }) => { const serverConfig = await AsyncStorage.getItem("serverConfig"); const legacyServer = await AsyncStorage.getItem("server"); - const version = await getVersionInfo(); + await getVersionInfo(); const shouldShowUpdateScreen = await checkShouldShowUpdateScreen(); setShowUpdateScreen(shouldShowUpdateScreen); - if (serverConfig || legacyServer) { + const serverConfigured = !!(serverConfig || legacyServer); + setHasServerConfigured(serverConfigured); + + if (serverConfigured) { let authStatus = false; const jwtToken = await AsyncStorage.getItem("jwt"); @@ -113,67 +136,49 @@ export const AppProvider: React.FC = ({ children }) => { try { const { getUserInfo } = await import("./main-axios"); const meRes = await getUserInfo(); - if (meRes && meRes.username) { - if (meRes.data_unlocked === false) { - authStatus = false; - } else { - authStatus = true; - } - } else { + if (meRes && meRes.username && meRes.data_unlocked === true) { + authStatus = true; } } catch (e) { console.error("[AppContext] Auto-login failed:", e); authStatus = false; await AsyncStorage.removeItem("jwt"); } - } else { } - let serverInfo = null; + let serverInfo: Server | null = null; if (legacyServer) { serverInfo = JSON.parse(legacyServer); } else if (serverConfig) { const config = JSON.parse(serverConfig); - serverInfo = { - name: "Server", - ip: config.serverUrl, - }; + serverInfo = { name: "Server", ip: config.serverUrl }; } + setSelectedServer(serverInfo); - if (authStatus) { - setAuthenticated(true); - setShowServerManager(false); - setShowLoginForm(false); - setSelectedServer(serverInfo); - } else { - setAuthenticated(false); - setShowServerManager(false); - setShowLoginForm(true); - setSelectedServer(serverInfo); - } + setAuthenticated(authStatus); + // A configured-but-unauthenticated server lands the user on the + // empty-state shell; they re-open the auth flow themselves. } else { + // Brand-new install: guide the user, but the flow is dismissible. setAuthenticated(false); - setShowServerManager(true); - setShowLoginForm(false); + openAuthFlow("server"); } } catch (error) { setAuthenticated(false); - setShowServerManager(true); - setShowLoginForm(false); } finally { setIsLoading(false); } }; initializeApp(); - }, []); + }, [openAuthFlow]); useEffect(() => { - setAuthStateCallback(async (isAuthenticated: boolean) => { - if (!isAuthenticated) { + setAuthStateCallback((authed: boolean) => { + if (!authed) { + // Token expired / 401: drop to the empty-state shell. Tabs render + // their "no server connected" prompt; the user re-authenticates. setAuthenticated(false); - setShowLoginForm(true); - setShowServerManager(false); } }); }, []); @@ -207,8 +212,11 @@ export const AppProvider: React.FC = ({ children }) => { !userInfo.username || userInfo.data_unlocked === false ) { + setAuthenticated(false); } } catch (error) { + // Network blips shouldn't log the user out; the 401 callback handles + // genuine auth failures. } finally { validationInProgressRef.current = false; } @@ -225,13 +233,17 @@ export const AppProvider: React.FC = ({ children }) => { }; }, [isAuthenticated]); + // Keep hasServerConfigured in sync whenever the auth flow closes (the user + // may have just added or changed a server inside it). + useEffect(() => { + if (!authFlowVisible) { + setHasServerConfigured(!!getCurrentServerUrl()); + } + }, [authFlowVisible]); + return ( = ({ children }) => { setShowUpdateScreen, isLoading, setIsLoading, + hasServerConfigured, + setHasServerConfigured, + authFlowVisible, + authFlowInitialStep, + openAuthFlow, + closeAuthFlow, }} > {children} diff --git a/app/_layout.tsx b/app/_layout.tsx index 1dae430..a26d336 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,105 +4,144 @@ import { TerminalSessionsProvider } from "./contexts/TerminalSessionsContext"; import { TerminalCustomizationProvider } from "./contexts/TerminalCustomizationContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationContext"; -import ServerForm from "@/app/authentication/ServerForm"; -import LoginForm from "@/app/authentication/LoginForm"; +import { + ThemeProvider, + useTheme, + useThemeColor, +} from "./contexts/ThemeContext"; +import { AppLockProvider, useAppLock } from "./contexts/AppLockContext"; +import { LockScreen } from "@/app/components/LockScreen"; +import AuthFlow from "@/app/authentication/AuthFlow"; import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { StatusBar } from "expo-status-bar"; import { Toaster } from "sonner-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { useFonts } from "expo-font"; +import { FONT_MAP, MONO_FONT, MONO_FONT_BOLD } from "./constants/fonts"; import "../global.css"; import UpdateRequired from "@/app/authentication/UpdateRequired"; function RootLayoutContent() { const { - showServerManager, - setShowServerManager, - showLoginForm, - setShowLoginForm, - isAuthenticated, + authFlowVisible, + openAuthFlow, showUpdateScreen, isLoading, setIsLoading, } = useAppContext(); + const accent = useThemeColor()("accent-brand"); if (isLoading) { return ( - - - Initializing... + + + + Initializing… + { - setShowLoginForm(false); - setShowServerManager(true); + setIsLoading(false); + openAuthFlow("server"); }} - className="mt-6 px-6 py-3 bg-[#1a1a1a] border border-[#303032] rounded-lg" + className="mt-6 border border-border bg-card px-6 py-3" > - Cancel + + Cancel + ); } - if (showUpdateScreen) { - return ; - } + if (showUpdateScreen) return ; - if (showServerManager) { - return ; - } + // The tab shell always renders once loaded. When the user isn't connected, + // the tabs themselves show a "no server connected" empty state. The auth flow + // is layered on top as a dismissible full-screen overlay. + return ( + + + + + + + {authFlowVisible ? ( + + + + ) : null} + + ); +} - if (showLoginForm) { - return ; - } +function AppLockGate() { + const { enabled, locked } = useAppLock(); + if (!enabled || !locked) return null; + return ; +} - if (isAuthenticated) { - return ( - - - - - - - ); - } +function ThemedToaster() { + const { isDark } = useTheme(); + const card = useThemeColor()("card"); + const border = useThemeColor()("border"); + return ( + + ); +} - return ; +function ThemedStatusBar() { + const { isDark } = useTheme(); + return ; } export default function RootLayout() { + const [fontsLoaded] = useFonts(FONT_MAP); + return ( - - - - - - - - - - - - + + + {!fontsLoaded ? ( + + ) : ( + + + + + + + + + + + + + + + )} + ); diff --git a/app/authentication/AuthFlow.tsx b/app/authentication/AuthFlow.tsx new file mode 100644 index 0000000..5aca0a7 --- /dev/null +++ b/app/authentication/AuthFlow.tsx @@ -0,0 +1,1511 @@ +import { + View, + ScrollView, + KeyboardAvoidingView, + Platform, + Linking, + TouchableOpacity, + Alert, + ActivityIndicator, +} from "react-native"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import * as WebBrowser from "expo-web-browser"; +import { + Server, + ShieldAlert, + ArrowLeft, + Eye, + EyeOff, + KeyRound, + User as UserIcon, + RefreshCw, + Globe, + X, +} from "lucide-react-native"; +import { WebView, WebViewNavigation } from "react-native-webview"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Text, Input, Button, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { useAppContext } from "../AppContext"; +import { + saveServerConfig, + getCurrentServerUrl, + initializeServerConfig, + setCookie, + getUserInfo, + loginUser, + registerUser, + verifyTOTPLogin, + initiatePasswordReset, + verifyPasswordResetCode, + completePasswordReset, + getSetupRequired, + getRegistrationAllowed, + getPasswordLoginAllowed, + getOIDCConfig, + getOIDCAuthorizeUrl, + isReverseProxyAuthGate, + clearSession, + consumeFreshWebSession, + logoutUser, +} from "../main-axios"; + +type Step = "server" | "login" | "totp" | "signup" | "reset" | "oidc"; + +// TEMP (testing): force the embedded web-version login (WebView) for every +// sign-in — skip the native login form AND the system-browser OIDC popup. +// Set back to false to restore normal behavior. +const FORCE_WEBVIEW_LOGIN = false; + +/** Server capabilities probed after the server URL is set. */ +interface ServerCaps { + setupRequired: boolean; + passwordLoginAllowed: boolean; + registrationAllowed: boolean; + oidcAvailable: boolean; +} + +function errMessage(e: any, fallback: string): string { + return e?.response?.data?.error || e?.message || fallback; +} + +export default function AuthFlow() { + const { + authFlowInitialStep, + closeAuthFlow, + setAuthenticated, + setSelectedServer, + } = useAppContext(); + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const bg = color("background") ?? "#0c0d0b"; + const accent = color("accent-brand") ?? "#f59145"; + const muted = color("muted-foreground"); + + const [step, setStep] = useState(authFlowInitialStep as Step); + const [serverUrl, setServerUrl] = useState(""); + const [caps, setCaps] = useState(null); + const [busy, setBusy] = useState(false); + // Carries the TOTP temp token between the login and totp steps. + const totpTempTokenRef = useRef(""); + + // Hostname shown in the shared header. + const activeHost = (getCurrentServerUrl() ?? serverUrl).replace( + /^https?:\/\//, + "", + ); + + useEffect(() => { + const current = getCurrentServerUrl(); + if (current) setServerUrl(current); + }, []); + + const finishAuthenticated = useCallback(async () => { + try { + await initializeServerConfig(); + } catch {} + const url = getCurrentServerUrl(); + if (url) setSelectedServer({ name: "Server", ip: url }); + setAuthenticated(true); + closeAuthFlow(); + }, [closeAuthFlow, setAuthenticated, setSelectedServer]); + + // ── Step: server ──────────────────────────────────────────────────────── + + /** + * Probes the currently-configured server to decide which sign-in affordances + * to show, then advances to the appropriate step. Returns false on failure + * (server unreachable) so callers can react. + */ + const probeServer = useCallback(async (): Promise => { + if (FORCE_WEBVIEW_LOGIN) { + setStep("oidc"); + return true; + } + + // If the server sits behind a reverse-proxy auth gate (Cloudflare Access, + // Authelia, …), API endpoints return the proxy's HTML login page rather + // than JSON — a native form can't work. Send the user to the browser-based + // external sign-in instead. + if (await isReverseProxyAuthGate()) { + setCaps({ + setupRequired: false, + passwordLoginAllowed: false, + registrationAllowed: false, + oidcAvailable: false, + }); + setStep("oidc"); + return true; + } + + const [setupRes, pwRes, regRes, oidcRes] = await Promise.allSettled([ + getSetupRequired(), + getPasswordLoginAllowed(), + getRegistrationAllowed(), + getOIDCConfig(), + ]); + + // If every probe failed, the server is unreachable — surface that rather + // than rendering an empty login form. + if ( + setupRes.status === "rejected" && + pwRes.status === "rejected" && + regRes.status === "rejected" && + oidcRes.status === "rejected" + ) { + return false; + } + + const setupRequired = + setupRes.status === "fulfilled" && !!setupRes.value?.setup_required; + const passwordLoginAllowed = + pwRes.status === "fulfilled" ? pwRes.value?.allowed !== false : true; + const registrationAllowed = + regRes.status === "fulfilled" && !!regRes.value?.allowed; + const oidcAvailable = + oidcRes.status === "fulfilled" && !!oidcRes.value?.client_id; + + setCaps({ + setupRequired, + passwordLoginAllowed, + registrationAllowed, + oidcAvailable, + }); + + setStep(setupRequired ? "signup" : "login"); + return true; + }, []); + + const handleConnect = async () => { + const url = serverUrl.trim().replace(/\/$/, ""); + if (!url) { + toast.error("Please enter a server address"); + return; + } + if (!/^https?:\/\//.test(url)) { + toast.error("Server address must start with http:// or https://"); + return; + } + + setBusy(true); + try { + // Reset the session before connecting to a (possibly different) server: + // clears the JWT and the reverse-proxy cookie/session so a fresh proxy + // login is shown and a subsequent sign-in can't resolve to the old account. + await clearSession(); + + await saveServerConfig({ + serverUrl: url, + lastUpdated: new Date().toISOString(), + }); + setSelectedServer({ name: "Server", ip: url }); + + const ok = await probeServer(); + if (!ok) { + toast.error("Could not reach that server"); + } + } catch (e: any) { + toast.error(errMessage(e, "Could not reach that server")); + } finally { + setBusy(false); + } + }; + + // When the flow is opened directly to login/signup (e.g. "Sign in" after + // logout, with a server already configured), the server hasn't been probed + // yet so `caps` is null and no form would render. Probe on mount in that case. + useEffect(() => { + if ( + (authFlowInitialStep === "login" || authFlowInitialStep === "signup") && + getCurrentServerUrl() + ) { + setBusy(true); + probeServer() + .then((ok) => { + if (!ok) { + toast.error("Could not reach that server"); + setStep("server"); + } + }) + .catch(() => setStep("server")) + .finally(() => setBusy(false)); + } + // Run once on mount for the initial step. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const goToServer = () => { + // Changing server must not carry the old session/proxy login forward. + // Invalidate the server-side session FIRST (while the JWT still exists), then + // clear local state — otherwise the live Termix cookie lets OIDC resume the + // old account. + void (async () => { + try { + await logoutUser(); + } catch { + // best-effort + } + await clearSession(); + })(); + setCaps(null); + setStep("server"); + }; + + // ── Render ─────────────────────────────────────────────────────────────── + return ( + + {step === "oidc" ? ( + + // If there's a usable native login step, return to it; otherwise + // (pure reverse-proxy case) go back to the server step. + setStep( + !FORCE_WEBVIEW_LOGIN && + caps && + (caps.passwordLoginAllowed || caps.oidcAvailable) + ? "login" + : "server", + ) + } + onAuthenticated={finishAuthenticated} + /> + ) : ( + + {/* Shared header */} + + {step !== "server" ? ( + + + + {activeHost || "Server"} + + + ) : ( + + )} + + + + + + + + {/* Brand mark */} + + + + + TERMIX + + + {step === "server" + ? "CONNECT TO YOUR SERVER" + : (activeHost || "").toUpperCase()} + + + {step === "server" && ( + + )} + + {step === "login" && caps && ( + { + totpTempTokenRef.current = tempToken; + setStep("totp"); + }} + onAuthenticated={finishAuthenticated} + onForgot={() => setStep("reset")} + onSignup={() => setStep("signup")} + onOidc={() => setStep("oidc")} + /> + )} + + {/* Probe in flight (e.g. opened directly to login after logout). */} + {step === "login" && !caps && ( + + + + Connecting… + + + )} + + {step === "totp" && ( + totpTempTokenRef.current} + onAuthenticated={finishAuthenticated} + onBack={() => setStep("login")} + /> + )} + + {step === "signup" && ( + + setStep(caps?.setupRequired ? "server" : "login") + } + /> + )} + + {step === "reset" && ( + setStep("login")} + onBack={() => setStep("login")} + /> + )} + + + + )} + + ); +} + +// ── Sub-steps ─────────────────────────────────────────────────────────────── + +function ServerStep({ + serverUrl, + setServerUrl, + busy, + onConnect, + color, +}: { + serverUrl: string; + setServerUrl: (v: string) => void; + busy: boolean; + onConnect: () => void; + color: ReturnType; +}) { + return ( + <> + + + + } + onSubmitEditing={onConnect} + returnKeyType="go" + /> + + + Enter the address of your self-hosted Termix server, including http:// + or https://. + + + + + + + + Using a self-signed certificate? Install its root CA on your device + first. Local HTTP servers are supported. + + + + ); +} + +function PasswordInput({ + value, + onChangeText, + placeholder, + editable, + onSubmitEditing, + color, +}: { + value: string; + onChangeText: (v: string) => void; + placeholder: string; + editable: boolean; + onSubmitEditing?: () => void; + color: ReturnType; +}) { + const [show, setShow] = useState(false); + return ( + } + trailing={ + setShow((s) => !s)} hitSlop={8}> + {show ? ( + + ) : ( + + )} + + } + /> + ); +} + +function LoginStep({ + caps, + color, + onTotp, + onAuthenticated, + onForgot, + onSignup, + onOidc, +}: { + caps: ServerCaps; + color: ReturnType; + onTotp: (tempToken: string) => void; + onAuthenticated: () => void; + onForgot: () => void; + onSignup: () => void; + onOidc: () => void; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + + const handleLogin = async () => { + if (!username.trim() || !password) { + toast.error("Enter your username and password"); + return; + } + setBusy(true); + try { + const res = await loginUser(username.trim(), password); + if (res?.requires_totp) { + onTotp(res.temp_token || res.token || ""); + return; + } + // loginUser already persisted the JWT. + await onAuthenticated(); + } catch (e: any) { + if (e?.code === "PROXY_AUTH_GATE") { + // The server is behind a login proxy — fall back to the browser flow. + toast.error( + "This server uses a login proxy — opening external sign-in", + ); + onOidc(); + return; + } + toast.error(errMessage(e, "Login failed")); + } finally { + setBusy(false); + } + }; + + const showPasswordCard = caps.passwordLoginAllowed; + + return ( + + {showPasswordCard ? ( + + + + } + /> + + + + + + + + + + + + Forgot password? + + + {caps.registrationAllowed ? ( + + Sign up + + ) : null} + + + ) : ( + + + Password login is disabled on this server. Use external sign-in + below. + + + )} + + {caps.oidcAvailable ? ( + <> + {showPasswordCard ? ( + + + + OR + + + + ) : ( + + )} + + + ) : null} + + {!showPasswordCard && !caps.oidcAvailable ? ( + + + This server has no available sign-in methods (password login is + disabled and no SSO provider is configured). Check the server + configuration. + + + ) : null} + + ); +} + +function TotpStep({ + color, + getTempToken, + onAuthenticated, + onBack, +}: { + color: ReturnType; + getTempToken: () => string; + onAuthenticated: () => void; + onBack: () => void; +}) { + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + + const handleVerify = async () => { + const c = code.trim(); + if (!c) { + toast.error("Enter your authentication code"); + return; + } + setBusy(true); + try { + await verifyTOTPLogin(getTempToken(), c); + await onAuthenticated(); + } catch (e: any) { + toast.error(errMessage(e, "Invalid code")); + } finally { + setBusy(false); + } + }; + + return ( + + + + } + /> + + + Enter the 6-digit code from your authenticator app, or one of your + backup codes. + + + + + Back to sign in + + + + ); +} + +function SignupStep({ + color, + firstUser, + onAuthenticated, + onBack, +}: { + color: ReturnType; + firstUser: boolean; + onAuthenticated: () => void; + onBack: () => void; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + + const handleSignup = async () => { + if (!username.trim() || !password) { + toast.error("Enter a username and password"); + return; + } + if (password.length < 6) { + toast.error("Password must be at least 6 characters"); + return; + } + if (password !== confirm) { + toast.error("Passwords do not match"); + return; + } + setBusy(true); + try { + await registerUser(username.trim(), password); + // Auto sign-in after creating the account. + await loginUser(username.trim(), password); + await onAuthenticated(); + } catch (e: any) { + toast.error(errMessage(e, "Could not create account")); + } finally { + setBusy(false); + } + }; + + return ( + + + {firstUser ? ( + + This is the first account on the server and will be an administrator. + + ) : ( + + )} + + + } + /> + + + + + + + + + + + + {firstUser ? "Back" : "Already have an account? Sign in"} + + + + ); +} + +function ResetStep({ + color, + onDone, + onBack, +}: { + color: ReturnType; + onDone: () => void; + onBack: () => void; +}) { + const [phase, setPhase] = useState<"request" | "code" | "password">( + "request", + ); + const [username, setUsername] = useState(""); + const [resetCode, setResetCode] = useState(""); + const [tempToken, setTempToken] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [busy, setBusy] = useState(false); + + const requestCode = async () => { + if (!username.trim()) { + toast.error("Enter your username"); + return; + } + setBusy(true); + try { + await initiatePasswordReset(username.trim()); + toast.success("Reset code generated — check the server logs"); + setPhase("code"); + } catch (e: any) { + toast.error(errMessage(e, "Could not start password reset")); + } finally { + setBusy(false); + } + }; + + const verifyCode = async () => { + if (!resetCode.trim()) { + toast.error("Enter the reset code"); + return; + } + setBusy(true); + try { + const res = await verifyPasswordResetCode( + username.trim(), + resetCode.trim(), + ); + setTempToken(res?.tempToken || ""); + setPhase("password"); + } catch (e: any) { + toast.error(errMessage(e, "Invalid reset code")); + } finally { + setBusy(false); + } + }; + + const setNew = async () => { + if (newPassword.length < 6) { + toast.error("Password must be at least 6 characters"); + return; + } + setBusy(true); + try { + await completePasswordReset(username.trim(), tempToken, newPassword); + toast.success("Password reset — you can sign in now"); + onDone(); + } catch (e: any) { + toast.error(errMessage(e, "Could not reset password")); + } finally { + setBusy(false); + } + }; + + return ( + + + + {phase === "request" && ( + <> + + Enter your username. A reset code will be generated and printed to + the server's logs. + + } + /> + + + )} + + {phase === "code" && ( + <> + + Enter the 6-digit reset code from the server logs. + + } + /> + + + )} + + {phase === "password" && ( + <> + + Choose a new password. + + + + + )} + + + + Back to sign in + + + + ); +} + +// ── OIDC / reverse-proxy sign-in ───────────────────────────────────────────── +// OIDC opens in the system browser so provider-side captcha/passkey checks work. +// Reverse-proxy auth still falls back to the embedded web view. + +// Fixed deep link the server redirects to after OIDC. Must use the registered +// "termix-mobile" scheme (app.json) so the backend accepts it as appCallbackUrl +// and handleCallbackUrl matches it. Hardcoded because ExpoLinking.createURL is +// unreliable in dev-client builds (can yield an exp:// dev-server URL). +const OIDC_CALLBACK_URL = "termix-mobile://oidc-callback"; + +function OidcStep({ + bg, + accent, + onBack, + onAuthenticated, +}: { + bg: string; + accent: string; + onBack: () => void; + onAuthenticated: () => void; +}) { + const color = useThemeColor(); + const webViewRef = useRef(null); + const [source, setSource] = useState<{ uri: string } | null>(null); + const [url, setUrl] = useState(""); + const [authenticating, setAuthenticating] = useState(false); + // After a reset (logout/change-server), load the WebView with an ephemeral + // cookie store so the reverse-proxy login screen shows again instead of + // silently resuming the old proxy session. + const [incognito, setIncognito] = useState(false); + const [browserAuthUrl, setBrowserAuthUrl] = useState(null); + const [openingBrowser, setOpeningBrowser] = useState(false); + const [webViewKey, setWebViewKey] = useState(() => String(Date.now())); + // Synchronous guards: state updates are async, so two near-simultaneous + // callbacks (openAuthSessionAsync result + the OS deep-link listener) can both + // pass a useState check before the first re-render. Refs prevent that. + const authStartedRef = useRef(false); + const browserOpenedRef = useRef(false); + // Set when a reset (logout/change-server) asked for a fresh sign-in. Drives an + // ephemeral system-browser auth session (no shared Safari cookies) and an + // incognito embedded WebView, so the proxy/IdP login is shown instead of + // silently resuming the previous account. + const freshSessionRef = useRef(false); + + const completeNativeAuth = useCallback( + async (token: string) => { + if (authStartedRef.current) return; + authStartedRef.current = true; + setAuthenticating(true); + try { + await setCookie("jwt", token); + const saved = await AsyncStorage.getItem("jwt"); + if (!saved) { + Alert.alert("Error", "Failed to save authentication token."); + authStartedRef.current = false; + return; + } + await initializeServerConfig(); + + // Confirm the token, tolerating transient gateway hiccups (502 / brief + // network blips from the reverse proxy). A real 401 means the token is + // bad; a non-user payload (e.g. the proxy's HTML login page) means the + // native request isn't reaching Termix — surface that rather than + // pretending we're signed in. + let confirmed = false; + for (let attempt = 0; attempt < 3 && !confirmed; attempt++) { + try { + const me = await getUserInfo(); + if (me?.username) { + confirmed = true; + break; + } + // Got a response, but not a Termix user object. + await new Promise((r) => setTimeout(r, 600)); + } catch (e: any) { + if (e?.response?.status === 401) { + await AsyncStorage.removeItem("jwt"); + Alert.alert( + "Sign in failed", + "The server rejected the session token. Please try again.", + ); + authStartedRef.current = false; + return; + } + // Transient (502/HTML/network) — wait briefly and retry. + await new Promise((r) => setTimeout(r, 600)); + } + } + + if (!confirmed) { + await AsyncStorage.removeItem("jwt"); + Alert.alert( + "Sign in failed", + "Signed in, but the app couldn't reach Termix's API (a reverse proxy may be blocking the request). Please try again.", + ); + authStartedRef.current = false; + return; + } + + // Proceed even if confirmation didn't succeed: the token is saved and + // valid as far as we know; the app's startup will verify it again. + await onAuthenticated(); + } catch { + Alert.alert("Error", "Could not complete sign-in."); + authStartedRef.current = false; + } finally { + setAuthenticating(false); + } + }, + [onAuthenticated], + ); + + const handleCallbackUrl = useCallback( + async (callbackUrl: string) => { + if (!callbackUrl.startsWith("termix-mobile://oidc-callback")) return; + + // Hermes's URL implementation may not parse custom schemes reliably, + // so extract query params manually from the raw string. + const qIndex = callbackUrl.indexOf("?"); + const queryString = qIndex !== -1 ? callbackUrl.slice(qIndex + 1) : ""; + const params: Record = {}; + for (const pair of queryString.split("&")) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) continue; + const k = decodeURIComponent(pair.slice(0, eqIdx)); + const v = decodeURIComponent(pair.slice(eqIdx + 1)); + params[k] = v; + } + + const error = params["error"]; + if (error) { + Alert.alert("Sign in failed", error); + return; + } + + const token = params["token"]; + if (!token) { + Alert.alert("Sign in failed", "The server did not return a token."); + return; + } + + await completeNativeAuth(token); + }, + [completeNativeAuth], + ); + + const openBrowser = useCallback( + async (authUrl: string) => { + // Don't reopen if a session is already in flight or auth already started + // (e.g. a deep-link remount re-running init, or a double tap). + if (openingBrowser || authStartedRef.current) return; + setOpeningBrowser(true); + try { + // Use a fixed deep link rather than ExpoLinking.createURL: in dev-client + // builds createURL can return an exp:// dev-server URL, which the backend + // rejects (protocol must be "termix-mobile:") and which handleCallbackUrl + // wouldn't match. The fixed form works in both dev and production. + const callbackUrl = OIDC_CALLBACK_URL; + // openAuthSessionAsync uses ASWebAuthenticationSession on iOS and + // Chrome Custom Tabs on Android — both support WebAuthn/passkeys (RFC 8252). + // It captures the termix-mobile:// redirect itself and returns it as + // result.url, so the global Linking listener is a backup, not the primary. + const result = await WebBrowser.openAuthSessionAsync( + authUrl, + callbackUrl, + // After a reset, don't share Safari's cookies (iOS) so the IdP/proxy + // doesn't auto-resume the previous account — forces a fresh login. + freshSessionRef.current + ? { preferEphemeralSession: true } + : undefined, + ); + if (result.type === "success" && result.url) { + await handleCallbackUrl(result.url); + } + // result.type === "cancel" means user dismissed — no error needed + } catch { + Alert.alert("Error", "Could not open the authentication browser."); + } finally { + setOpeningBrowser(false); + } + }, + [handleCallbackUrl, openingBrowser], + ); + + // Pre-warm Chrome Custom Tab on Android for faster open + useEffect(() => { + if (Platform.OS === "android") void WebBrowser.warmUpAsync(); + return () => { + if (Platform.OS === "android") void WebBrowser.coolDownAsync(); + }; + }, []); + + useEffect(() => { + const subscription = Linking.addEventListener("url", (event) => { + void handleCallbackUrl(event.url); + }); + void Linking.getInitialURL().then((initialUrl) => { + if (initialUrl) void handleCallbackUrl(initialUrl); + }); + return () => subscription.remove(); + }, [handleCallbackUrl]); + + useEffect(() => { + // Run exactly once per mount. Guarding with a ref (not the effect deps) + // prevents re-entry when openBrowser is recreated, and prevents a second + // browser prompt if a deep-link return remounts/re-renders this screen. + if (browserOpenedRef.current) return; + browserOpenedRef.current = true; + + const init = async () => { + // Start every sign-in attempt from a clean slate: a leftover token would + // let confirmation pass as the previous account. + await AsyncStorage.removeItem("jwt"); + // If a reset (logout / change-server) requested it, force a fresh login so + // the proxy/IdP login is shown instead of resuming the previous account. + const fresh = await consumeFreshWebSession(); + if (fresh || FORCE_WEBVIEW_LOGIN) { + setIncognito(true); + if (fresh) freshSessionRef.current = true; + } + + // The system browser is preferred (passkeys/captcha work there), but on a + // reset it can't be wiped on Android (Custom Tabs share the device browser + // cookies; only iOS supports an ephemeral session). So on a reset+Android, + // skip the system browser and use the incognito embedded WebView, which + // reliably starts from an empty cookie jar on both platforms. + const useEmbeddedForFresh = + (fresh && Platform.OS === "android") || FORCE_WEBVIEW_LOGIN; + + if (!useEmbeddedForFresh) { + const callbackUrl = OIDC_CALLBACK_URL; + try { + const res = await getOIDCAuthorizeUrl(callbackUrl); + if (res?.auth_url) { + setBrowserAuthUrl(res.auth_url); + setUrl(res.auth_url); + await openBrowser(res.auth_url); + return; + } + } catch { + // ignore — fall back to the server root, which renders the web login + // (covers reverse-proxy login forms). + } + } + + const fallbackUrl = getCurrentServerUrl(); + if (fallbackUrl) { + setSource({ uri: fallbackUrl }); + setUrl(fallbackUrl); + } + }; + init(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleNav = (navState: WebViewNavigation) => { + if (!navState.loading) setUrl(navState.url); + }; + + const handleError = (syntheticEvent: any) => { + const { nativeEvent } = syntheticEvent; + if ( + nativeEvent.description?.includes("SSL") || + nativeEvent.description?.includes("certificate") || + nativeEvent.description?.includes("ERR_CERT") + ) { + Alert.alert( + "SSL Certificate Error", + "Unable to verify the server's SSL certificate. Install your self-signed certificate's root CA on the device (as a CA certificate) and rebuild the app.\n\nError: " + + (nativeEvent.description || "Unknown SSL error"), + [{ text: "OK" }], + ); + } + }; + + const onMessage = async (event: any) => { + if (authStartedRef.current) return; + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type !== "AUTH_SUCCESS") return; + if (!data.token || String(data.token).length < 20) { + // The web page signalled success but couldn't hand us a usable token + // (e.g. an older server build, or the JWT cookie wasn't readable). Don't + // leave the user stuck on the web "Redirecting…" screen with no feedback. + Alert.alert( + "Sign in failed", + "Signed in on the web page but the app didn't receive a session token. Make sure the server is up to date, then try again.", + ); + return; + } + await completeNativeAuth(String(data.token)); + } catch { + setAuthenticating(false); + } + }; + + const injectedJavaScript = ` + (function() { + const isCallback = window.location.href.includes('/oidc/callback') || + window.location.href.includes('?success=') || + window.location.href.includes('?error='); + + // On a fresh (non-callback) page load, drop any JS-readable leftover token + // so the web app doesn't silently resume the PREVIOUS account — otherwise + // an OIDC sign-in can hand back the old user's token. (HttpOnly cookies + // can't be cleared here, but this covers the localStorage/readable-cookie + // case the web app uses for the handoff.) + if (!isCallback) { + try { + localStorage.removeItem('jwt'); + sessionStorage.removeItem('jwt'); + document.cookie.split(';').forEach(function(c) { + const name = c.split('=')[0].trim(); + if (name === 'jwt') { + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;'; + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;domain=' + window.location.hostname; + } + }); + } catch (e) {} + } + + let hasNotified = false; + let initialCheckComplete = false; + + const notifyAuth = (token) => { + if (hasNotified || !token || token.length < 20) return; + if (!isCallback && !initialCheckComplete) return; + hasNotified = true; + try { + window.ReactNativeWebView && window.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'AUTH_SUCCESS', token: token }) + ); + } catch (e) {} + }; + + // Primary recovery path: the web app authenticates via an HttpOnly cookie, + // which JS cannot read. A same-origin credentialed fetch to /users/me/token + // sends that cookie automatically and returns the JWT in the body. + const fetchTokenFromServer = () => { + if (hasNotified) return; + // Try the origin root first (standard nginx routes the API there), then + // a path relative to the current document directory (sub-path mounts). + const dir = window.location.pathname.replace(/[^/]*$/, ''); + const candidates = ['/users/me/token', dir + 'users/me/token']; + candidates.forEach((url) => { + try { + fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (d && d.token) notifyAuth(d.token); }) + .catch(() => {}); + } catch (e) {} + }); + }; + + const checkAuth = () => { + try { + // Secondary fallbacks. Note: an HttpOnly 'jwt' cookie is invisible to + // document.cookie, so that scrape is a no-op for the standard flow and + // only catches non-HttpOnly storage some setups may use. + const ls = localStorage.getItem('jwt'); + if (ls) { notifyAuth(ls); return true; } + const ss = sessionStorage.getItem('jwt'); + if (ss) { notifyAuth(ss); return true; } + const m = document.cookie.split('; ').find(r => r.startsWith('jwt=')); + if (m) { notifyAuth(m.split('=')[1]); return true; } + } catch (e) {} + return false; + }; + + const origSet = localStorage.setItem; + localStorage.setItem = function(k, v) { + origSet.apply(this, arguments); + if (k === 'jwt' && v && !hasNotified) checkAuth(); + }; + + const id = setInterval(() => { + if (hasNotified) { clearInterval(id); return; } + if (checkAuth()) clearInterval(id); + else if (isCallback) fetchTokenFromServer(); + }, 500); + checkAuth(); + if (isCallback) fetchTokenFromServer(); + setTimeout(() => { initialCheckComplete = true; }, 1000); + setTimeout(() => clearInterval(id), 120000); + })(); + true; + `; + + return ( + + + + + + Sign in + + + + + {url.replace(/^https?:\/\//, "")} + + + { + setWebViewKey(String(Date.now())); + webViewRef.current?.reload(); + }} + hitSlop={8} + className="py-1 pl-2" + > + + + + + {browserAuthUrl ? ( + + + + Complete sign-in in your browser + + + Return to Termix after your identity provider finishes signing you + in. + + + + ) : source ? ( + ( + + + + )} + /> + ) : ( + + + + )} + + ); +} diff --git a/app/authentication/LoginForm.tsx b/app/authentication/LoginForm.tsx deleted file mode 100644 index 3537f3d..0000000 --- a/app/authentication/LoginForm.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { - View, - TouchableOpacity, - Text, - Alert, - ActivityIndicator, - Platform, -} from "react-native"; -import { useAppContext } from "../AppContext"; -import { useState, useEffect, useRef } from "react"; -import { - setCookie, - getCurrentServerUrl, - initializeServerConfig, -} from "../main-axios"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ArrowLeft, RefreshCw } from "lucide-react-native"; -import { WebView, WebViewNavigation } from "react-native-webview"; -import { WebViewSource } from "react-native-webview/lib/WebViewTypes"; -import AsyncStorage from "@react-native-async-storage/async-storage"; - -export default function LoginForm() { - const { - setAuthenticated, - setShowLoginForm, - setShowServerManager, - selectedServer, - } = useAppContext(); - const insets = useSafeAreaInsets(); - const webViewRef = useRef(null); - const [url, setUrl] = useState(""); - const [canGoBack, setCanGoBack] = useState(false); - const [loading, setLoading] = useState(true); - const [source, setSource] = useState({ uri: "" }); - const [webViewKey, setWebViewKey] = useState(() => String(Date.now())); - const [hasNavigated, setHasNavigated] = useState(false); - - useEffect(() => { - const initializeLogin = async () => { - const existingToken = await AsyncStorage.getItem("jwt"); - if (existingToken) { - try { - const { getUserInfo } = await import("../main-axios"); - const userInfo = await getUserInfo(); - - if (userInfo && userInfo.username) { - if (userInfo.data_unlocked === false) { - } else { - setAuthenticated(true); - setShowLoginForm(false); - return; - } - } - } catch (error) {} - } - - setWebViewKey(String(Date.now())); - - const serverUrl = getCurrentServerUrl(); - if (serverUrl) { - setSource({ uri: serverUrl }); - setUrl(serverUrl); - } else if (selectedServer?.ip) { - setSource({ uri: selectedServer.ip }); - setUrl(selectedServer.ip); - } - }; - - initializeLogin(); - }, [selectedServer]); - - const handleBackToServerConfig = () => { - setShowLoginForm(false); - setShowServerManager(true); - }; - - const handleRefresh = () => { - webViewRef.current?.reload(); - }; - - const handleNavigationStateChange = (navState: WebViewNavigation) => { - setCanGoBack(navState.canGoBack); - setLoading(navState.loading); - setHasNavigated(true); - if (!navState.loading) { - setUrl(navState.url); - } - }; - - const handleError = (syntheticEvent: any) => { - const { nativeEvent } = syntheticEvent; - console.error("[LoginForm] WebView error:", nativeEvent); - - if ( - nativeEvent.description?.includes("SSL") || - nativeEvent.description?.includes("certificate") || - nativeEvent.description?.includes("ERR_CERT") - ) { - Alert.alert( - "SSL Certificate Error", - "Unable to verify the server's SSL certificate. Please ensure:\n\n" + - "1. Your self-signed certificate's root CA is installed in Android Settings > Security > Encryption & Credentials > Install a certificate\n" + - "2. The certificate is installed as a 'CA certificate'\n" + - "3. You've rebuilt the app after installing the certificate\n\n" + - "Error: " + - (nativeEvent.description || "Unknown SSL error"), - [{ text: "OK" }], - ); - } - }; - - const handleHttpError = (syntheticEvent: any) => { - const { nativeEvent } = syntheticEvent; - console.warn( - "[LoginForm] HTTP error:", - nativeEvent.statusCode, - nativeEvent.url, - ); - }; - - const [isAuthenticating, setIsAuthenticating] = useState(false); - - const onMessage = async (event: any) => { - if (isAuthenticating) { - return; - } - - try { - const data = JSON.parse(event.nativeEvent.data); - - if (data.type === "AUTH_SUCCESS" && data.token) { - setIsAuthenticating(true); - - try { - const tokenParts = data.token.split("."); - if (tokenParts.length === 3) { - const payload = JSON.parse(atob(tokenParts[1])); - if (payload.exp) { - const expirationDate = new Date(payload.exp * 1000); - const now = new Date(); - const daysUntilExpiration = Math.floor( - (expirationDate.getTime() - now.getTime()) / - (1000 * 60 * 60 * 24), - ); - } - } - } catch (jwtParseError) { - console.error( - "[LoginForm] Failed to parse JWT for diagnostics:", - jwtParseError, - ); - } - - await setCookie("jwt", data.token); - - const savedToken = await AsyncStorage.getItem("jwt"); - if (!savedToken) { - setIsAuthenticating(false); - Alert.alert( - "Error", - "Failed to save authentication token. Please try again.", - ); - return; - } - - await initializeServerConfig(); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - setAuthenticated(true); - setShowLoginForm(false); - } - } catch (error) { - console.error("[LoginForm] Error processing auth token:", error); - setIsAuthenticating(false); - Alert.alert("Error", "Failed to process authentication token."); - } - }; - - const injectedJavaScript = ` - (function() { - const isOIDCCallback = window.location.href.includes('/oidc/callback') || - window.location.href.includes('?success=') || - window.location.href.includes('?error='); - - if (!isOIDCCallback) { - try { - if (typeof localStorage !== 'undefined') { - localStorage.removeItem('jwt'); - } - if (typeof sessionStorage !== 'undefined') { - sessionStorage.removeItem('jwt'); - } - - const cookies = document.cookie.split(";"); - cookies.forEach(function(c) { - const cookieName = c.split("=")[0].trim(); - if (cookieName === 'jwt') { - document.cookie = cookieName + "=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;"; - document.cookie = cookieName + "=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;domain=" + window.location.hostname; - } - }); - } catch(e) { - console.error('[LoginForm] Error clearing JWT from WebView:', e); - } - } - - const style = document.createElement('style'); - style.textContent = \` - button:has-text("Install Mobile App"), - [class*="mobile-app"], - [class*="install-app"], - [id*="mobile-app"], - [id*="install-app"], - a[href*="app-store"], - a[href*="play-store"], - a[href*="google.com/store"], - a[href*="apple.com/app"], - button[aria-label*="Install"], - button[aria-label*="Mobile App"], - button[aria-label*="Download App"], - a[aria-label*="Install"], - a[aria-label*="Mobile App"], - a[aria-label*="Download App"] { - display: none !important; - } - \`; - document.head.appendChild(style); - - const hideByText = () => { - const buttons = document.querySelectorAll('button, a'); - buttons.forEach(btn => { - const text = btn.textContent?.toLowerCase() || ''; - if (text.includes('install') && text.includes('mobile')) { - btn.style.display = 'none'; - } - if (text.includes('download') && text.includes('app')) { - btn.style.display = 'none'; - } - if (text.includes('get') && text.includes('app')) { - btn.style.display = 'none'; - } - }); - }; - - hideByText(); - setTimeout(hideByText, 500); - setTimeout(hideByText, 1000); - setTimeout(hideByText, 2000); - - const observer = new MutationObserver(hideByText); - observer.observe(document.body, { childList: true, subtree: true }); - - let hasNotified = false; - let lastCheckedToken = null; - let initialCheckComplete = false; - - const notifyAuth = (token, source) => { - if (hasNotified || !token || token === lastCheckedToken) { - return; - } - - if (isOIDCCallback) { - hasNotified = true; - lastCheckedToken = token; - } - else if (initialCheckComplete) { - hasNotified = true; - lastCheckedToken = token; - } else { - return; - } - - if (!hasNotified) return; - - try { - const message = JSON.stringify({ - type: 'AUTH_SUCCESS', - token: token, - source: source, - timestamp: Date.now() - }); - - if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { - window.ReactNativeWebView.postMessage(message); - } else { - console.error('[WebView] ReactNativeWebView.postMessage not available!'); - } - } catch (e) { - console.error('[WebView] Error sending message:', e); - } - }; - - const checkAuth = () => { - try { - const localToken = localStorage.getItem('jwt'); - if (localToken && localToken.length > 20) { - notifyAuth(localToken, 'localStorage'); - return true; - } - - const sessionToken = sessionStorage.getItem('jwt'); - if (sessionToken && sessionToken.length > 20) { - notifyAuth(sessionToken, 'sessionStorage'); - return true; - } - - const cookies = document.cookie; - if (cookies && cookies.length > 0) { - const cookieArray = cookies.split('; '); - const tokenCookie = cookieArray.find(row => row.startsWith('jwt=')); - - if (tokenCookie) { - const token = tokenCookie.split('=')[1]; - if (token && token.length > 20) { - notifyAuth(token, 'cookie'); - return true; - } - } - } - } catch (error) { - console.error('[WebView] Error in checkAuth:', error); - } - return false; - }; - - const originalSetItem = localStorage.setItem; - localStorage.setItem = function(key, value) { - originalSetItem.apply(this, arguments); - if (key === 'jwt' && value && value.length > 20 && !hasNotified) { - checkAuth(); - } - }; - - const originalSessionSetItem = sessionStorage.setItem; - sessionStorage.setItem = function(key, value) { - originalSessionSetItem.apply(this, arguments); - if (key === 'jwt' && value && value.length > 20 && !hasNotified) { - checkAuth(); - } - }; - - const intervalId = setInterval(() => { - if (hasNotified) { - clearInterval(intervalId); - return; - } - if (checkAuth()) { - clearInterval(intervalId); - } - }, 500); - - checkAuth(); - - setTimeout(() => { - initialCheckComplete = true; - }, 1000); - - window.addEventListener('message', (event) => { - try { - if (event.data && typeof event.data === 'object') { - const data = event.data; - if (data.type === 'AUTH_SUCCESS' && data.token && data.source === 'explicit') { - notifyAuth(data.token, 'explicit-message'); - } - } - } catch (e) { - console.error('[WebView] Error processing message event:', e); - } - }); - - document.addEventListener('visibilitychange', () => { - if (!document.hidden && !hasNotified) { - checkAuth(); - } - }); - - setTimeout(() => { - clearInterval(intervalId); - }, 120000); - })(); - `; - - if (!source.uri) { - return ( - - - Loading server configuration... - - ); - } - - return ( - - - - - Server - - - - {url.replace(/^https?:\/\//, "")} - - - - - - - - ( - - - - )} - /> - - ); -} diff --git a/app/authentication/ServerForm.tsx b/app/authentication/ServerForm.tsx deleted file mode 100644 index a23cbe3..0000000 --- a/app/authentication/ServerForm.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { - TextInput, - View, - TouchableOpacity, - Text, - Alert, - ScrollView, - KeyboardAvoidingView, - Platform, -} from "react-native"; -import { useAppContext } from "../AppContext"; -import { useState, useEffect } from "react"; -import { saveServerConfig, getCurrentServerUrl } from "../main-axios"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Server } from "lucide-react-native"; - -type ServerDetails = { - ip: string; -}; - -export default function ServerForm() { - const { - setShowServerManager, - setShowLoginForm, - setSelectedServer, - selectedServer, - } = useAppContext(); - const insets = useSafeAreaInsets(); - const [formData, setFormData] = useState({ ip: "" }); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const loadExistingConfig = async () => { - try { - const currentUrl = getCurrentServerUrl(); - if (currentUrl) { - setFormData({ ip: currentUrl }); - } else if (selectedServer?.ip) { - setFormData({ ip: selectedServer.ip }); - } - } catch (error) {} - }; - loadExistingConfig(); - }, [selectedServer]); - - const handleInputChange = (field: keyof ServerDetails, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; - - const handleConnect = async () => { - const serverUrl = formData.ip.trim(); - if (!serverUrl) { - Alert.alert("Error", "Please enter a server address"); - return; - } - - if (!/^https?:\/\//.test(serverUrl)) { - Alert.alert( - "Error", - "Server address must start with http:// or https://", - ); - return; - } - - setIsLoading(true); - - try { - const serverConfig = { - serverUrl, - lastUpdated: new Date().toISOString(), - }; - await saveServerConfig(serverConfig); - - const serverInfo = { - name: "Server", - ip: serverUrl, - }; - - setSelectedServer(serverInfo); - setShowServerManager(false); - setShowLoginForm(true); - } catch (error: any) { - Alert.alert( - "Error", - `Failed to save server: ${error?.message || "Unknown error"}`, - ); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - - Server Connection - - - - - - - - Server Address - - - - - - handleInputChange("ip", value)} - autoCapitalize="none" - autoCorrect={false} - autoComplete="off" - editable={!isLoading} - /> - - - Enter the address of your self-hosted Termix server. - - - - - - {isLoading ? "Saving..." : "Connect"} - - - - - - - ); -} diff --git a/app/authentication/UpdateRequired.tsx b/app/authentication/UpdateRequired.tsx index eac1f30..eb81856 100644 --- a/app/authentication/UpdateRequired.tsx +++ b/app/authentication/UpdateRequired.tsx @@ -1,25 +1,18 @@ -import { - View, - Text, - TouchableOpacity, - Alert, - ActivityIndicator, -} from "react-native"; +import { View, ActivityIndicator } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { X, AlertTriangle, Download } from "lucide-react-native"; +import { AlertTriangle, Download } from "lucide-react-native"; import { useAppContext } from "../AppContext"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { getVersionInfo, getLatestGitHubRelease } from "../main-axios"; import { useState, useEffect } from "react"; import Constants from "expo-constants"; +import { Text, Button, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; export default function UpdateRequired() { const insets = useSafeAreaInsets(); + const color = useThemeColor(); const { setShowUpdateScreen } = useAppContext(); - const [versionInfo, setVersionInfo] = useState<{ - localVersion: string; - serverVersion: string; - } | null>(null); const [latestRelease, setLatestRelease] = useState<{ version: string; tagName: string; @@ -32,18 +25,17 @@ export default function UpdateRequired() { useEffect(() => { const fetchVersionInfo = async () => { try { - const [version, release] = await Promise.all([ + const [, release] = await Promise.all([ getVersionInfo(), getLatestGitHubRelease(), ]); - setVersionInfo(version); setLatestRelease(release); - } catch (error) { + } catch { + // best-effort } finally { setIsLoading(false); } }; - fetchVersionInfo(); }, []); @@ -53,8 +45,7 @@ export default function UpdateRequired() { "dismissedUpdateVersion", latestRelease?.version || "unknown", ); - setShowUpdateScreen(false); - } catch (error) { + } finally { setShowUpdateScreen(false); } }; @@ -62,83 +53,72 @@ export default function UpdateRequired() { if (isLoading) { return ( - - - Loading version information... + + + Loading version information… ); } return ( - - - - - Update Required - + + + + + Update Available + - - - - - - Version Mismatch Detected + + + + + + New version available - - A new version of the mobile app is available. Some features may not - work properly until you update to the latest version. + + A newer version of the mobile app is available. Some features may + not work correctly until you update. - - - Version Information: - - - + + + - Current Version: - - {currentMobileAppVersion} + Installed + + v{currentMobileAppVersion} - - Latest Release: - - {latestRelease?.version || "Unknown"} + Latest + + v{latestRelease?.version || "Unknown"} - - {latestRelease?.tagName && ( + {latestRelease?.tagName ? ( - Release Tag: - + Tag + {latestRelease.tagName} - )} + ) : null} - - - - Continue Anyway - - + + ); diff --git a/app/components/ConnectEmptyState.tsx b/app/components/ConnectEmptyState.tsx new file mode 100644 index 0000000..8f8b96d --- /dev/null +++ b/app/components/ConnectEmptyState.tsx @@ -0,0 +1,43 @@ +import { View } from "react-native"; +import { ServerOff } from "lucide-react-native"; +import { Text, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { useAppContext } from "@/app/AppContext"; + +/** + * Shown inside a tab when there is no authenticated server connection. Prompts + * the user to connect, opening the auth flow. Used across Hosts / Sessions so + * the disconnected state is consistent. + */ +export function ConnectEmptyState({ + title = "No server connected", + message = "Connect to a Termix server to manage your hosts, terminals, files and more.", +}: { + title?: string; + message?: string; +}) { + const color = useThemeColor(); + const { hasServerConfigured, openAuthFlow } = useAppContext(); + + return ( + + + + + + {title} + + + {message} + + + + ); +} diff --git a/app/components/CustomTabBar.tsx b/app/components/CustomTabBar.tsx new file mode 100644 index 0000000..457c9e2 --- /dev/null +++ b/app/components/CustomTabBar.tsx @@ -0,0 +1,78 @@ +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"; +import { Server, SquareTerminal, Settings } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { useOrientation } from "@/app/utils/orientation"; + +const ICONS: Record< + string, + (props: { color: string; size: number }) => React.ReactNode +> = { + hosts: (p) => , + sessions: (p) => , + settings: (p) => , +}; + +const LABELS: Record = { + hosts: "Hosts", + sessions: "Sessions", + settings: "Settings", +}; + +/** + * Bottom tab bar matching the web MobileBottomBar: square, bg-sidebar, + * top border, accent-colored active item, lucide icons, safe-area aware. + */ +export function CustomTabBar({ state, navigation }: BottomTabBarProps) { + const insets = useSafeAreaInsets(); + const { isLandscape } = useOrientation(); + const resolveColor = useThemeColor(); + const accent = resolveColor("accent-brand") ?? "#f59145"; + const muted = resolveColor("muted-foreground") ?? "#a4a4a4"; + const iconSize = isLandscape ? 18 : 20; + + return ( + + {state.routes.map((route, index) => { + const focused = state.index === index; + const color = focused ? accent : muted; + const icon = ICONS[route.name]; + const label = LABELS[route.name] ?? route.name; + + const onPress = () => { + const event = navigation.emit({ + type: "tabPress", + target: route.key, + canPreventDefault: true, + }); + if (!focused && !event.defaultPrevented) { + navigation.navigate(route.name); + } + }; + + return ( + + {icon?.({ color, size: iconSize })} + + {label} + + + ); + })} + + ); +} diff --git a/app/components/LockScreen.tsx b/app/components/LockScreen.tsx new file mode 100644 index 0000000..d5bf663 --- /dev/null +++ b/app/components/LockScreen.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { View, Pressable } from "react-native"; +import { Lock, Fingerprint, Delete, ChevronLeft } from "lucide-react-native"; +import { useAppLock } from "@/app/contexts/AppLockContext"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +interface LockScreenProps { + title?: string; + subtitle?: string; + /** + * Verify-mode overrides. When provided, the keypad calls these instead of the + * context's unlock methods and does NOT change global lock state — used for + * re-authentication (e.g. disabling app lock in Settings). + */ + onVerifyPin?: (pin: string) => Promise; + onBiometric?: () => Promise; + /** Called after a successful PIN/biometric verification. */ + onSuccess?: () => void; + /** When set, shows a Cancel affordance and makes the screen dismissible. */ + onCancel?: () => void; +} + +/** + * Full-screen 4-digit PIN gate. Used both as the app-lock overlay (default + * mode) and as a re-auth screen in Settings (verify mode via props). Biometrics + * are opt-in: the user taps the fingerprint button — they are never prompted + * automatically, and the OS device passcode is never offered as a fallback. + */ +export function LockScreen({ + title = "Termix Locked", + subtitle = "Enter your PIN to continue", + onVerifyPin, + onBiometric, + onSuccess, + onCancel, +}: LockScreenProps = {}) { + const { hasBiometrics, unlockWithBiometrics, unlockWithPin } = useAppLock(); + const color = useThemeColor(); + const [pin, setPin] = useState(""); + const [error, setError] = useState(false); + + const verifyPin = onVerifyPin ?? unlockWithPin; + const biometric = onBiometric ?? unlockWithBiometrics; + + useEffect(() => { + if (pin.length !== 4) return; + verifyPin(pin).then((ok) => { + if (ok) { + onSuccess?.(); + } else { + setError(true); + setTimeout(() => { + setPin(""); + setError(false); + }, 600); + } + }); + }, [pin, verifyPin, onSuccess]); + + const press = (d: string) => { + if (pin.length < 4) setPin((p) => p + d); + }; + + const handleBiometric = async () => { + if (!hasBiometrics) return; + const ok = await biometric(); + if (ok) onSuccess?.(); + }; + + return ( + + {onCancel ? ( + + + Cancel + + ) : null} + + + + + + {title} + + + {subtitle} + + + {/* PIN dots */} + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + {/* Keypad */} + + {[ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + ].map((row, ri) => ( + + {row.map((d) => ( + press(d)} /> + ))} + + ))} + + + {hasBiometrics ? ( + + ) : null} + + press("0")} /> + setPin((p) => p.slice(0, -1))} + className="h-16 w-16 items-center justify-center border border-border active:bg-muted/40" + > + + + + + + ); +} + +function Key({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + {label} + + + ); +} diff --git a/app/components/Screen.tsx b/app/components/Screen.tsx new file mode 100644 index 0000000..c116875 --- /dev/null +++ b/app/components/Screen.tsx @@ -0,0 +1,47 @@ +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/app/components/ui"; + +/** + * Screen — top-level themed container with safe-area top padding and an + * optional header (title + actions). Used by Hosts / Tools / Settings. + */ +export function Screen({ + title, + subtitle, + headerRight, + children, + scrollableHeader, +}: { + title?: string; + subtitle?: string; + headerRight?: React.ReactNode; + children: React.ReactNode; + /** When true, the header is part of the scroll content (caller handles it). */ + scrollableHeader?: boolean; +}) { + const insets = useSafeAreaInsets(); + + return ( + + {title && !scrollableHeader ? ( + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {headerRight ? ( + {headerRight} + ) : null} + + ) : null} + {children} + + ); +} diff --git a/app/components/ui/Accordion.tsx b/app/components/ui/Accordion.tsx new file mode 100644 index 0000000..0290fb1 --- /dev/null +++ b/app/components/ui/Accordion.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import { ChevronDown } from "lucide-react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** + * AccordionSection — bordered card with a header that expands/collapses, + * matching the web user-profile accordion (uppercase header + chevron). + */ +export function AccordionSection({ + label, + icon, + defaultOpen = false, + open: controlledOpen, + onToggle, + children, +}: { + label: string; + icon?: React.ReactNode; + defaultOpen?: boolean; + open?: boolean; + onToggle?: () => void; + children: React.ReactNode; +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const muted = useThemeColor()("muted-foreground"); + + return ( + + { + if (isControlled) onToggle?.(); + else setInternalOpen((o) => !o); + }} + className="flex-row items-center gap-2 px-3 py-3 active:bg-muted/40" + > + {icon ? {icon} : null} + + {label} + + + + + + {open ? ( + {children} + ) : null} + + ); +} diff --git a/app/components/ui/Badge.tsx b/app/components/ui/Badge.tsx new file mode 100644 index 0000000..c45a769 --- /dev/null +++ b/app/components/ui/Badge.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native"; +import { Text } from "./Text"; + +export type BadgeVariant = "accent" | "muted" | "destructive" | "success"; + +const VARIANTS: Record = { + accent: { + box: "bg-accent-brand/10 border-accent-brand/40", + text: "text-accent-brand", + }, + muted: { box: "bg-muted border-border", text: "text-muted-foreground" }, + destructive: { + box: "bg-destructive/10 border-destructive/40", + text: "text-destructive", + }, + success: { box: "bg-chart-3/15 border-chart-3/40", text: "text-chart-3" }, +}; + +export function Badge({ + children, + variant = "muted", + className, +}: { + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; +}) { + const v = VARIANTS[variant]; + return ( + + + {children} + + + ); +} diff --git a/app/components/ui/BottomSheet.tsx b/app/components/ui/BottomSheet.tsx new file mode 100644 index 0000000..48ad2c1 --- /dev/null +++ b/app/components/ui/BottomSheet.tsx @@ -0,0 +1,86 @@ +import { Modal, Pressable, View, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "./Text"; + +/** + * BottomSheet — slide-up themed panel. Used for host action sheets, file ops, + * pickers, and menus. Square top corners, themed surface, scrim backdrop. + */ +export function BottomSheet({ + visible, + onClose, + title, + children, + scroll = false, +}: { + visible: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + scroll?: boolean; +}) { + const insets = useSafeAreaInsets(); + const Container: any = scroll ? ScrollView : View; + + return ( + + + + + + + {title ? ( + + + {title} + + + ) : null} + {children} + + + ); +} + +/** A tappable row inside a BottomSheet (icon + label, optional destructive). */ +export function SheetRow({ + icon, + label, + onPress, + destructive, + trailing, +}: { + icon?: React.ReactNode; + label: string; + onPress: () => void; + destructive?: boolean; + trailing?: React.ReactNode; +}) { + return ( + + {icon ? {icon} : null} + + {label} + + {trailing ?? null} + + ); +} diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx new file mode 100644 index 0000000..7ea66fa --- /dev/null +++ b/app/components/ui/Button.tsx @@ -0,0 +1,95 @@ +import { + Pressable, + type PressableProps, + View, + ActivityIndicator, +} from "react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +export type ButtonVariant = + | "default" // solid primary + | "accent" // accent-brand outline (primary action in the web design) + | "outline" + | "ghost" + | "destructive"; + +export type ButtonSize = "sm" | "default" | "lg" | "icon"; + +interface ButtonProps extends Omit { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + className?: string; + textClassName?: string; + children?: React.ReactNode; + /** Leading icon element (already colored, or it inherits via color prop). */ + icon?: React.ReactNode; +} + +const SIZES: Record = { + sm: "h-8 px-2.5", + default: "h-10 px-3", + lg: "h-12 px-4", + icon: "h-10 w-10", +}; + +const VARIANT_CONTAINER: Record = { + default: "bg-primary border border-primary", + accent: "bg-accent-brand/10 border border-accent-brand/40", + outline: "bg-transparent border border-border", + ghost: "bg-transparent border border-transparent", + destructive: "bg-transparent border border-destructive/40", +}; + +const VARIANT_TEXT: Record = { + default: "text-primary-foreground", + accent: "text-accent-brand", + outline: "text-foreground", + ghost: "text-foreground", + destructive: "text-destructive", +}; + +export function Button({ + variant = "outline", + size = "default", + loading = false, + disabled, + className, + textClassName, + children, + icon, + ...props +}: ButtonProps) { + const accent = useThemeColor()("accent-brand"); + const isDisabled = disabled || loading; + + return ( + + {loading ? ( + + ) : ( + <> + {icon ? {icon} : null} + {typeof children === "string" ? ( + + {children} + + ) : ( + children + )} + + )} + + ); +} diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx new file mode 100644 index 0000000..7212555 --- /dev/null +++ b/app/components/ui/Card.tsx @@ -0,0 +1,32 @@ +import { View, type ViewProps } from "react-native"; +import { Text } from "./Text"; + +export function Card({ + className, + ...props +}: ViewProps & { className?: string }) { + return ( + + ); +} + +/** Small uppercase tracking-widest muted label — the web design's section/field label. */ +export function Label({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx new file mode 100644 index 0000000..4fcd5f3 --- /dev/null +++ b/app/components/ui/Dialog.tsx @@ -0,0 +1,86 @@ +import { KeyboardAvoidingView, Modal, Platform, Pressable, View } from "react-native"; +import { X } from "lucide-react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** + * Dialog — centered modal card. Square corners, themed surface, optional + * title/description and close button. Mirrors the web shadcn dialog. + */ +export function Dialog({ + visible, + onClose, + title, + description, + icon, + children, + footer, +}: { + visible: boolean; + onClose: () => void; + title?: string; + description?: string; + icon?: React.ReactNode; + children?: React.ReactNode; + footer?: React.ReactNode; +}) { + const muted = useThemeColor()("muted-foreground"); + + return ( + + + + e.stopPropagation()} + > + {title ? ( + + {icon ? ( + + {icon} + + ) : null} + + + {title} + + {description ? ( + + {description} + + ) : null} + + + + + + ) : null} + {children ? {children} : null} + {footer ? ( + + {footer} + + ) : null} + + + + + ); +} diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx new file mode 100644 index 0000000..1901f94 --- /dev/null +++ b/app/components/ui/Input.tsx @@ -0,0 +1,56 @@ +import { forwardRef } from "react"; +import { TextInput, type TextInputProps, View } from "react-native"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +interface InputProps extends TextInputProps { + className?: string; + /** Optional leading element (e.g. a search icon). */ + leading?: React.ReactNode; + /** Optional trailing element (e.g. a clear button). */ + trailing?: React.ReactNode; + containerClassName?: string; +} + +export const Input = forwardRef(function Input( + { + className, + leading, + trailing, + containerClassName, + style, + multiline, + ...props + }, + ref, +) { + const placeholderColor = useThemeColor()("muted-foreground", 0.7); + + // Multiline grows with content, so the container can't be a fixed-height + // centered row — it must allow height and top-align its children. Single-line + // keeps the original fixed 40px row. + const layout = multiline + ? "flex-row items-start gap-2 min-h-10 py-2 px-2.5" + : "flex-row items-center gap-2 h-10 px-2.5"; + + return ( + + {leading ? {leading} : null} + + {trailing ? {trailing} : null} + + ); +}); diff --git a/app/components/ui/SegmentedControl.tsx b/app/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000..a51ef5e --- /dev/null +++ b/app/components/ui/SegmentedControl.tsx @@ -0,0 +1,40 @@ +import { Pressable, View } from "react-native"; +import { Text } from "./Text"; + +/** + * SegmentedControl — a row of equal-width options where the selected one is + * highlighted with the accent (matches the web font-size / theme pickers). + */ +export function SegmentedControl({ + options, + value, + onChange, + className, +}: { + options: { id: T; label: string }[]; + value: T; + onChange: (id: T) => void; + className?: string; +}) { + return ( + + {options.map((opt) => { + const active = opt.id === value; + return ( + onChange(opt.id)} + className={`flex-1 items-center border py-2 ${active ? "border-accent-brand/40 bg-accent-brand/10" : "border-border active:bg-muted/40"}`} + > + + {opt.label} + + + ); + })} + + ); +} diff --git a/app/components/ui/SettingRow.tsx b/app/components/ui/SettingRow.tsx new file mode 100644 index 0000000..361420c --- /dev/null +++ b/app/components/ui/SettingRow.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import { Text } from "./Text"; + +/** + * SettingRow — label + optional description on the left, a control on the + * right. Mirrors the web app's SettingRow used throughout user preferences. + */ +export function SettingRow({ + label, + description, + badge, + children, + last, +}: { + label: string; + description?: string; + badge?: string; + children: React.ReactNode; + last?: boolean; +}) { + return ( + + + + + {label} + + {badge ? ( + + + {badge} + + + ) : null} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} diff --git a/app/components/ui/Switch.tsx b/app/components/ui/Switch.tsx new file mode 100644 index 0000000..ad512b8 --- /dev/null +++ b/app/components/ui/Switch.tsx @@ -0,0 +1,61 @@ +import { Pressable, View } from "react-native"; +import Animated, { + useAnimatedStyle, + withTiming, +} from "react-native-reanimated"; + +/** + * FakeSwitch — pill toggle matching the web design (bg-accent-brand when on, + * bg-muted when off). One of the few intentionally rounded elements. + */ +export function FakeSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + const thumbStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: withTiming(checked ? 16 : 2, { duration: 150 }) }, + ], + })); + + return ( + onChange(!checked)} + hitSlop={8} + className={`h-5 w-9 justify-center rounded-full ${checked ? "bg-accent-brand" : "bg-muted"} ${disabled ? "opacity-50" : ""}`} + > + + + ); +} + +/** Square checkbox with 4px rounding (matches web). */ +export function Checkbox({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + onChange(!checked)} + hitSlop={8} + className={`h-4 w-4 items-center justify-center rounded-check border ${checked ? "border-accent-brand bg-accent-brand" : "border-input bg-transparent"} ${disabled ? "opacity-50" : ""}`} + > + {checked ? : null} + + ); +} diff --git a/app/components/ui/Text.tsx b/app/components/ui/Text.tsx new file mode 100644 index 0000000..daf4340 --- /dev/null +++ b/app/components/ui/Text.tsx @@ -0,0 +1,40 @@ +import { Text as RNText, type TextProps as RNTextProps } from "react-native"; +import { + MONO_FONT, + MONO_FONT_BOLD, + MONO_FONT_MEDIUM, +} from "@/app/constants/fonts"; + +export type TextWeight = "regular" | "medium" | "bold"; + +export interface ThemedTextProps extends RNTextProps { + weight?: TextWeight; + /** Tailwind classes (color, size, etc.). Defaults to foreground. */ + className?: string; +} + +const FONT_BY_WEIGHT: Record = { + regular: MONO_FONT, + medium: MONO_FONT_MEDIUM, + bold: MONO_FONT_BOLD, +}; + +/** + * App-wide text. Always monospace (JetBrains Mono), defaults to the theme + * foreground color. Use `weight` for medium/bold since RN cannot synthesize + * weights for custom fonts. + */ +export function Text({ + weight = "regular", + className, + style, + ...props +}: ThemedTextProps) { + return ( + + ); +} diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts new file mode 100644 index 0000000..51f3366 --- /dev/null +++ b/app/components/ui/index.ts @@ -0,0 +1,14 @@ +export { Text } from "./Text"; +export type { ThemedTextProps, TextWeight } from "./Text"; +export { Button } from "./Button"; +export type { ButtonVariant, ButtonSize } from "./Button"; +export { Input } from "./Input"; +export { Card, Label } from "./Card"; +export { FakeSwitch, Checkbox } from "./Switch"; +export { SettingRow } from "./SettingRow"; +export { AccordionSection } from "./Accordion"; +export { BottomSheet, SheetRow } from "./BottomSheet"; +export { Dialog } from "./Dialog"; +export { Badge } from "./Badge"; +export type { BadgeVariant } from "./Badge"; +export { SegmentedControl } from "./SegmentedControl"; diff --git a/app/constants/designTokens.ts b/app/constants/designTokens.ts index 4e18735..dcfd4b3 100644 --- a/app/constants/designTokens.ts +++ b/app/constants/designTokens.ts @@ -1,40 +1,49 @@ /** - * Includes a default value for all margins, borders, design elements, etc. - * These can be used across all components as a default inside the style tag in a component - * Any styling not included as a default here, can be set inside a className using NativeWind + * Static design tokens for the dark session surfaces (terminal, stats, file + * manager, tunnels, tab bar). These mirror the redesigned web app's dark + * theme. They are intentionally static (not theme-context driven) because the + * terminal/console area stays dark in every app theme — matching the web, + * where the terminal canvas is always dark. For themable chrome elsewhere in + * the app, use Tailwind tokens + useTheme() instead. */ export const BORDERS = { - MAJOR: 2, + MAJOR: 1, STANDARD: 1, SEPARATOR: 1, } as const; +// Brand accent (orange) — replaces the old terminal green. +export const ACCENT = "#f59145"; + export const BORDER_COLORS = { - PRIMARY: "#303032", - SECONDARY: "#373739", - SEPARATOR: "#404040", - BUTTON: "#303032", - ACTIVE: "#22C55E", + PRIMARY: "#323232", + SECONDARY: "#383838", + SEPARATOR: "#2a2a2a", + BUTTON: "#323232", + PANEL: "#262626", + ACTIVE: ACCENT, } as const; export const BACKGROUNDS = { - DARKEST: "#09090b", - DARKER: "#0e0e10", - HEADER: "#131316", - DARK: "#18181b", - CARD: "#1a1a1a", - BUTTON: "#2a2a2a", - BUTTON_ALT: "#23232a", - ACTIVE: "#4a4a4a", - HOVER: "#2d2d30", + DARKEST: "#0c0d0b", + DARKER: "#141513", + HEADER: "#141513", + DARK: "#0c0d0b", + CARD: "#181917", + PANEL: "#181917", + BUTTON: "#232323", + BUTTON_ALT: "#232323", + ACTIVE: "#2a2a2a", + HOVER: "#232323", } as const; export const RADIUS = { - BUTTON: 6, - CARD: 12, - SMALL: 4, - LARGE: 16, + // Square corners everywhere — matches the web redesign. + BUTTON: 0, + CARD: 0, + SMALL: 0, + LARGE: 0, } as const; export const SPACING = { @@ -47,11 +56,11 @@ export const SPACING = { } as const; export const TEXT_COLORS = { - PRIMARY: "#ffffff", - SECONDARY: "#9CA3AF", - TERTIARY: "#6B7280", - DISABLED: "#4B5563", - ACCENT: "#22C55E", + PRIMARY: "#fafafa", + SECONDARY: "#a4a4a4", + TERTIARY: "#737373", + DISABLED: "#525252", + ACCENT, } as const; export const ICON_SIZES = { diff --git a/app/constants/fonts.ts b/app/constants/fonts.ts new file mode 100644 index 0000000..98d8178 --- /dev/null +++ b/app/constants/fonts.ts @@ -0,0 +1,21 @@ +import { + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + JetBrainsMono_700Bold, +} from "@expo-google-fonts/jetbrains-mono"; + +/** + * Font families registered with expo-font. We register weight-specific + * families because React Native cannot synthesize weights for custom fonts. + * Tailwind `font-mono` resolves to "JetBrainsMono" (see tailwind.config.js); + * for bold/medium text, set fontFamily explicitly via these names. + */ +export const FONT_MAP = { + JetBrainsMono: JetBrainsMono_400Regular, + "JetBrainsMono-Medium": JetBrainsMono_500Medium, + "JetBrainsMono-Bold": JetBrainsMono_700Bold, +}; + +export const MONO_FONT = "JetBrainsMono"; +export const MONO_FONT_MEDIUM = "JetBrainsMono-Medium"; +export const MONO_FONT_BOLD = "JetBrainsMono-Bold"; diff --git a/app/constants/theme.ts b/app/constants/theme.ts new file mode 100644 index 0000000..f2c438b --- /dev/null +++ b/app/constants/theme.ts @@ -0,0 +1,450 @@ +/** + * Theme constants — ported from the Termix web app + * (../Termix/Termix/src/ui/lib/theme.ts + index.css). + * + * Colors are space-separated RGB triplets ("R G B") so they can be fed to + * NativeWind's vars() helper and resolved by Tailwind tokens that use + * rgb(var(--token) / ). + */ + +export type ThemeId = + | "system" + | "light" + | "dark" + | "dracula" + | "catppuccin" + | "nord" + | "solarized" + | "tokyo-night" + | "one-dark" + | "gruvbox"; + +/** Full variable set keyed by token name (without the leading --). */ +export type ThemeVars = Record; + +const LIGHT: ThemeVars = { + background: "255 255 255", + foreground: "17 18 16", + card: "255 255 255", + "card-foreground": "17 18 16", + popover: "255 255 255", + "popover-foreground": "17 18 16", + primary: "24 25 23", + "primary-foreground": "250 250 250", + secondary: "245 245 245", + "secondary-foreground": "24 25 23", + muted: "245 245 245", + "muted-foreground": "115 115 115", + accent: "245 245 245", + "accent-foreground": "24 25 23", + destructive: "231 0 11", + border: "229 229 229", + input: "229 229 229", + ring: "161 161 161", + surface: "245 245 245", + "surface-dim": "235 235 235", + "chart-1": "212 212 212", + "chart-2": "115 115 115", + "chart-3": "82 82 82", + "chart-4": "64 64 64", + "chart-5": "38 38 38", + sidebar: "250 250 250", + "sidebar-foreground": "17 18 16", + "sidebar-primary": "24 25 23", + "sidebar-primary-foreground": "250 250 250", + "sidebar-accent": "245 245 245", + "sidebar-accent-foreground": "24 25 23", + "sidebar-border": "229 229 229", + "sidebar-ring": "161 161 161", +}; + +const DARK: ThemeVars = { + background: "12 13 11", + foreground: "250 250 250", + card: "24 25 23", + "card-foreground": "250 250 250", + popover: "24 25 23", + "popover-foreground": "250 250 250", + primary: "229 229 229", + "primary-foreground": "24 25 23", + secondary: "38 38 38", + "secondary-foreground": "250 250 250", + muted: "35 35 35", + "muted-foreground": "164 164 164", + accent: "35 35 35", + "accent-foreground": "250 250 250", + destructive: "255 100 103", + border: "50 50 50", + input: "56 56 56", + ring: "115 115 115", + surface: "18 19 17", + "surface-dim": "14 15 13", + "chart-1": "212 212 212", + "chart-2": "115 115 115", + "chart-3": "82 82 82", + "chart-4": "64 64 64", + "chart-5": "38 38 38", + sidebar: "20 21 19", + "sidebar-foreground": "250 250 250", + "sidebar-primary": "20 71 230", + "sidebar-primary-foreground": "250 250 250", + "sidebar-accent": "35 35 35", + "sidebar-accent-foreground": "250 250 250", + "sidebar-border": "50 50 50", + "sidebar-ring": "115 115 115", +}; + +const DRACULA: ThemeVars = { + background: "40 42 54", + foreground: "248 248 242", + card: "33 34 44", + "card-foreground": "248 248 242", + popover: "33 34 44", + "popover-foreground": "248 248 242", + primary: "248 248 242", + "primary-foreground": "40 42 54", + secondary: "68 71 90", + "secondary-foreground": "248 248 242", + muted: "68 71 90", + "muted-foreground": "98 114 164", + accent: "68 71 90", + "accent-foreground": "248 248 242", + destructive: "255 85 85", + border: "98 114 164", + input: "98 114 164", + ring: "98 114 164", + surface: "33 34 44", + "surface-dim": "25 26 33", + "chart-1": "255 121 198", + "chart-2": "139 233 253", + "chart-3": "80 250 123", + "chart-4": "255 184 108", + "chart-5": "189 147 249", + sidebar: "33 34 44", + "sidebar-foreground": "248 248 242", + "sidebar-primary": "189 147 249", + "sidebar-primary-foreground": "40 42 54", + "sidebar-accent": "68 71 90", + "sidebar-accent-foreground": "248 248 242", + "sidebar-border": "98 114 164", + "sidebar-ring": "98 114 164", +}; + +const CATPPUCCIN: ThemeVars = { + background: "30 30 46", + foreground: "205 214 244", + card: "24 24 37", + "card-foreground": "205 214 244", + popover: "24 24 37", + "popover-foreground": "205 214 244", + primary: "205 214 244", + "primary-foreground": "30 30 46", + secondary: "49 50 68", + "secondary-foreground": "205 214 244", + muted: "49 50 68", + "muted-foreground": "108 112 134", + accent: "49 50 68", + "accent-foreground": "205 214 244", + destructive: "243 139 168", + border: "108 112 134", + input: "108 112 134", + ring: "108 112 134", + surface: "24 24 37", + "surface-dim": "17 17 27", + "chart-1": "243 139 168", + "chart-2": "137 220 235", + "chart-3": "166 227 161", + "chart-4": "250 179 135", + "chart-5": "203 166 247", + sidebar: "24 24 37", + "sidebar-foreground": "205 214 244", + "sidebar-primary": "203 166 247", + "sidebar-primary-foreground": "30 30 46", + "sidebar-accent": "49 50 68", + "sidebar-accent-foreground": "205 214 244", + "sidebar-border": "108 112 134", + "sidebar-ring": "108 112 134", +}; + +const NORD: ThemeVars = { + background: "46 52 64", + foreground: "236 239 244", + card: "39 44 54", + "card-foreground": "236 239 244", + popover: "39 44 54", + "popover-foreground": "236 239 244", + primary: "236 239 244", + "primary-foreground": "46 52 64", + secondary: "59 66 82", + "secondary-foreground": "236 239 244", + muted: "59 66 82", + "muted-foreground": "76 86 106", + accent: "59 66 82", + "accent-foreground": "236 239 244", + destructive: "191 97 106", + border: "76 86 106", + input: "76 86 106", + ring: "76 86 106", + surface: "39 44 54", + "surface-dim": "34 38 46", + "chart-1": "191 97 106", + "chart-2": "136 192 208", + "chart-3": "163 190 140", + "chart-4": "235 203 139", + "chart-5": "180 142 173", + sidebar: "39 44 54", + "sidebar-foreground": "236 239 244", + "sidebar-primary": "136 192 208", + "sidebar-primary-foreground": "46 52 64", + "sidebar-accent": "59 66 82", + "sidebar-accent-foreground": "236 239 244", + "sidebar-border": "76 86 106", + "sidebar-ring": "76 86 106", +}; + +const SOLARIZED: ThemeVars = { + background: "0 43 54", + foreground: "131 148 150", + card: "7 54 66", + "card-foreground": "147 161 161", + popover: "7 54 66", + "popover-foreground": "147 161 161", + primary: "147 161 161", + "primary-foreground": "0 43 54", + secondary: "7 54 66", + "secondary-foreground": "131 148 150", + muted: "7 54 66", + "muted-foreground": "88 110 117", + accent: "7 54 66", + "accent-foreground": "147 161 161", + destructive: "220 50 47", + border: "88 110 117", + input: "88 110 117", + ring: "88 110 117", + surface: "7 54 66", + "surface-dim": "0 33 43", + "chart-1": "220 50 47", + "chart-2": "42 161 152", + "chart-3": "133 153 0", + "chart-4": "181 137 0", + "chart-5": "108 113 196", + sidebar: "7 54 66", + "sidebar-foreground": "147 161 161", + "sidebar-primary": "38 139 210", + "sidebar-primary-foreground": "0 43 54", + "sidebar-accent": "7 54 66", + "sidebar-accent-foreground": "147 161 161", + "sidebar-border": "88 110 117", + "sidebar-ring": "88 110 117", +}; + +const TOKYO_NIGHT: ThemeVars = { + background: "26 27 38", + foreground: "169 177 214", + card: "22 22 30", + "card-foreground": "169 177 214", + popover: "22 22 30", + "popover-foreground": "169 177 214", + primary: "169 177 214", + "primary-foreground": "26 27 38", + secondary: "36 40 59", + "secondary-foreground": "169 177 214", + muted: "36 40 59", + "muted-foreground": "86 95 137", + accent: "36 40 59", + "accent-foreground": "169 177 214", + destructive: "247 118 142", + border: "86 95 137", + input: "86 95 137", + ring: "86 95 137", + surface: "22 22 30", + "surface-dim": "19 19 26", + "chart-1": "247 118 142", + "chart-2": "125 207 255", + "chart-3": "158 206 106", + "chart-4": "224 175 104", + "chart-5": "187 154 247", + sidebar: "22 22 30", + "sidebar-foreground": "169 177 214", + "sidebar-primary": "122 162 247", + "sidebar-primary-foreground": "26 27 38", + "sidebar-accent": "36 40 59", + "sidebar-accent-foreground": "169 177 214", + "sidebar-border": "86 95 137", + "sidebar-ring": "86 95 137", +}; + +const ONE_DARK: ThemeVars = { + background: "40 44 52", + foreground: "171 178 191", + card: "33 37 43", + "card-foreground": "171 178 191", + popover: "33 37 43", + "popover-foreground": "171 178 191", + primary: "171 178 191", + "primary-foreground": "40 44 52", + secondary: "44 49 58", + "secondary-foreground": "171 178 191", + muted: "44 49 58", + "muted-foreground": "92 99 112", + accent: "44 49 58", + "accent-foreground": "171 178 191", + destructive: "224 108 117", + border: "92 99 112", + input: "92 99 112", + ring: "92 99 112", + surface: "33 37 43", + "surface-dim": "27 31 35", + "chart-1": "224 108 117", + "chart-2": "86 182 194", + "chart-3": "152 195 121", + "chart-4": "229 192 123", + "chart-5": "198 120 221", + sidebar: "33 37 43", + "sidebar-foreground": "171 178 191", + "sidebar-primary": "97 175 239", + "sidebar-primary-foreground": "40 44 52", + "sidebar-accent": "44 49 58", + "sidebar-accent-foreground": "171 178 191", + "sidebar-border": "92 99 112", + "sidebar-ring": "92 99 112", +}; + +const GRUVBOX: ThemeVars = { + background: "40 40 40", + foreground: "235 219 178", + card: "29 32 33", + "card-foreground": "235 219 178", + popover: "29 32 33", + "popover-foreground": "235 219 178", + primary: "235 219 178", + "primary-foreground": "40 40 40", + secondary: "60 56 54", + "secondary-foreground": "235 219 178", + muted: "60 56 54", + "muted-foreground": "146 131 116", + accent: "60 56 54", + "accent-foreground": "235 219 178", + destructive: "204 36 29", + border: "146 131 116", + input: "146 131 116", + ring: "146 131 116", + surface: "29 32 33", + "surface-dim": "24 26 26", + "chart-1": "204 36 29", + "chart-2": "104 157 106", + "chart-3": "152 151 26", + "chart-4": "215 153 33", + "chart-5": "177 98 134", + sidebar: "29 32 33", + "sidebar-foreground": "235 219 178", + "sidebar-primary": "250 189 47", + "sidebar-primary-foreground": "40 40 40", + "sidebar-accent": "60 56 54", + "sidebar-accent-foreground": "235 219 178", + "sidebar-border": "146 131 116", + "sidebar-ring": "146 131 116", +}; + +/** Resolved variable map per concrete theme (excludes "system"). */ +export const THEME_VARS: Record, ThemeVars> = { + light: LIGHT, + dark: DARK, + dracula: DRACULA, + catppuccin: CATPPUCCIN, + nord: NORD, + solarized: SOLARIZED, + "tokyo-night": TOKYO_NIGHT, + "one-dark": ONE_DARK, + gruvbox: GRUVBOX, +}; + +/** Whether a concrete theme is dark (drives status bar + xterm defaults). */ +export const THEME_IS_DARK: Record, boolean> = { + light: false, + dark: true, + dracula: true, + catppuccin: true, + nord: true, + solarized: true, + "tokyo-night": true, + "one-dark": true, + gruvbox: true, +}; + +/** Theme picker entries (preview is a swatch hex). Mirrors web UserProfilePanel. */ +export const THEMES: { id: ThemeId; preview: string }[] = [ + { id: "system", preview: "auto" }, + { id: "light", preview: "#ffffff" }, + { id: "dark", preview: "#0c0d0b" }, + { id: "dracula", preview: "#282a36" }, + { id: "catppuccin", preview: "#1e1e2e" }, + { id: "nord", preview: "#2e3440" }, + { id: "solarized", preview: "#002b36" }, + { id: "tokyo-night", preview: "#1a1b26" }, + { id: "one-dark", preview: "#282c34" }, + { id: "gruvbox", preview: "#282828" }, +]; + +export const THEME_LABELS: Record = { + system: "System", + light: "Light", + dark: "Dark", + dracula: "Dracula", + catppuccin: "Catppuccin", + nord: "Nord", + solarized: "Solarized", + "tokyo-night": "Tokyo Night", + "one-dark": "One Dark", + gruvbox: "Gruvbox", +}; + +/** 12 accent presets — identical set + order to the web app. */ +export const ACCENT_PRESET_COLORS = [ + { label: "Orange", value: "#f59145" }, + { label: "Blue", value: "#3b82f6" }, + { label: "Green", value: "#22c55e" }, + { label: "Purple", value: "#a855f7" }, + { label: "Pink", value: "#ec4899" }, + { label: "Cyan", value: "#06b6d4" }, + { label: "Red", value: "#ef4444" }, + { label: "Yellow", value: "#eab308" }, + { label: "Teal", value: "#14b8a6" }, + { label: "Indigo", value: "#6366f1" }, + { label: "Rose", value: "#f43f5e" }, + { label: "Lime", value: "#84cc16" }, +]; + +export const DEFAULT_ACCENT = "#f59145"; + +export const FOLDER_COLORS = [ + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#3b82f6", + "#a855f7", + "#ec4899", + "#6b7280", +]; + +/** "#f59145" -> "245 145 69" (RGB triplet for vars()). Returns null if invalid. */ +export function hexToRgbTriplet(hex: string): string | null { + let h = hex.trim().replace("#", ""); + if (h.length === 3) + h = h + .split("") + .map((c) => c + c) + .join(""); + if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `${r} ${g} ${b}`; +} + +/** AsyncStorage keys (mirror the web localStorage keys conceptually). */ +export const STORAGE_KEYS = { + theme: "termix-theme", + accent: "termix-accent", +} as const; diff --git a/app/contexts/AppLockContext.tsx b/app/contexts/AppLockContext.tsx new file mode 100644 index 0000000..3aab67a --- /dev/null +++ b/app/contexts/AppLockContext.tsx @@ -0,0 +1,166 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as LocalAuthentication from "expo-local-authentication"; +import * as SecureStore from "expo-secure-store"; + +/** + * App Lock — optional biometric / PIN gate when the app is opened or returns + * from background. Addresses Support issues #496 / #338 (account security on + * mobile). The PIN is stored in the OS secure store; the enabled flag in + * AsyncStorage. + */ + +const ENABLED_KEY = "appLockEnabled"; +const PIN_KEY = "appLockPin"; // SecureStore +const LOCK_DELAY_MS = 15_000; // re-lock if backgrounded longer than this + +interface AppLockContextValue { + enabled: boolean; + locked: boolean; + hasBiometrics: boolean; + /** Enable app lock with a numeric PIN (biometrics used when available). */ + enable: (pin: string) => Promise; + /** + * Tear down app lock. Performs no verification — callers MUST authenticate + * the user (via `verifyPin` / `authenticateBiometrics`) first. + */ + disable: () => Promise; + /** Compare a PIN against the stored one. Does NOT change lock state. */ + verifyPin: (pin: string) => Promise; + /** + * Prompt for biometrics (no device-passcode fallback). Does NOT change lock + * state — used for re-authentication in Settings. + */ + authenticateBiometrics: () => Promise; + /** Attempt unlock via biometrics; unlocks on success. Returns success. */ + unlockWithBiometrics: () => Promise; + /** Attempt unlock via PIN; unlocks on success. Returns success. */ + unlockWithPin: (pin: string) => Promise; +} + +const AppLockContext = createContext(null); + +export function AppLockProvider({ children }: { children: React.ReactNode }) { + const [enabled, setEnabled] = useState(false); + const [locked, setLocked] = useState(false); + const [hasBiometrics, setHasBiometrics] = useState(false); + const backgroundedAt = useRef(null); + const appState = useRef(AppState.currentState); + + useEffect(() => { + (async () => { + const [isEnabled, hw, enrolled] = await Promise.all([ + AsyncStorage.getItem(ENABLED_KEY), + LocalAuthentication.hasHardwareAsync().catch(() => false), + LocalAuthentication.isEnrolledAsync().catch(() => false), + ]); + const on = isEnabled === "true"; + setEnabled(on); + // Only treat biometrics as available when the device has a sensor AND a + // biometric is actually enrolled — otherwise the fingerprint button would + // appear but do nothing. + setHasBiometrics(!!hw && !!enrolled); + if (on) setLocked(true); // lock on cold start + })(); + }, []); + + // Re-lock when returning from background after the delay. + useEffect(() => { + const sub = AppState.addEventListener("change", (next: AppStateStatus) => { + const prev = appState.current; + appState.current = next; + if (!enabled) return; + if (next.match(/inactive|background/)) { + backgroundedAt.current = Date.now(); + } else if (prev.match(/inactive|background/) && next === "active") { + const elapsed = Date.now() - (backgroundedAt.current ?? 0); + if (elapsed >= LOCK_DELAY_MS) setLocked(true); + } + }); + return () => sub.remove(); + }, [enabled]); + + const enable = useCallback(async (pin: string) => { + await SecureStore.setItemAsync(PIN_KEY, pin); + await AsyncStorage.setItem(ENABLED_KEY, "true"); + setEnabled(true); + setLocked(false); + }, []); + + const disable = useCallback(async () => { + await SecureStore.deleteItemAsync(PIN_KEY).catch(() => {}); + await AsyncStorage.setItem(ENABLED_KEY, "false"); + setEnabled(false); + setLocked(false); + }, []); + + // Prompt biometrics without changing lock state. Device-passcode fallback is + // disabled so iOS never shows the phone passcode prompt — the in-app PIN is + // the only fallback. + const authenticateBiometrics = useCallback(async () => { + try { + const enrolled = await LocalAuthentication.isEnrolledAsync(); + if (!enrolled) return false; + const res = await LocalAuthentication.authenticateAsync({ + promptMessage: "Unlock Termix", + disableDeviceFallback: true, + cancelLabel: "Use PIN", + }); + return res.success; + } catch { + return false; + } + }, []); + + const unlockWithBiometrics = useCallback(async () => { + const ok = await authenticateBiometrics(); + if (ok) setLocked(false); + return ok; + }, [authenticateBiometrics]); + + const verifyPin = useCallback(async (pin: string) => { + const stored = await SecureStore.getItemAsync(PIN_KEY).catch(() => null); + return !!stored && stored === pin; + }, []); + + const unlockWithPin = useCallback( + async (pin: string) => { + const ok = await verifyPin(pin); + if (ok) setLocked(false); + return ok; + }, + [verifyPin], + ); + + return ( + + {children} + + ); +} + +export function useAppLock(): AppLockContextValue { + const ctx = useContext(AppLockContext); + if (!ctx) throw new Error("useAppLock must be used within AppLockProvider"); + return ctx; +} diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx index f3398d3..2e5d89b 100644 --- a/app/contexts/KeyboardCustomizationContext.tsx +++ b/app/contexts/KeyboardCustomizationContext.tsx @@ -13,10 +13,7 @@ import { PresetType, KeyboardSettings, } from "@/types/keyboard"; -import { - PRESET_DEFINITIONS, - getPresetById, -} from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; +import { getPresetById } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; const STORAGE_KEY = "keyboardCustomization"; const DEFAULT_PRESET_ID: PresetType = "default"; diff --git a/app/contexts/TerminalCustomizationContext.tsx b/app/contexts/TerminalCustomizationContext.tsx index 2b2c17e..965af1b 100644 --- a/app/contexts/TerminalCustomizationContext.tsx +++ b/app/contexts/TerminalCustomizationContext.tsx @@ -22,6 +22,9 @@ interface TerminalCustomizationContextType { resetConfig: () => Promise; updateFontSize: (fontSize: number) => Promise; + updateFontFamily: (fontFamily: string) => Promise; + updateLetterSpacing: (letterSpacing: number) => Promise; + updateLineHeight: (lineHeight: number) => Promise; resetToDefault: () => Promise; } @@ -42,7 +45,10 @@ export const TerminalCustomizationProvider: React.FC<{ const stored = await AsyncStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; - setConfig(parsed); + setConfig({ + ...getDefaultConfig(), + ...parsed, + }); } } catch (error) { console.error("Failed to load terminal configuration:", error); @@ -85,6 +91,27 @@ export const TerminalCustomizationProvider: React.FC<{ [updateConfig], ); + const updateFontFamily = useCallback( + async (fontFamily: string) => { + await updateConfig({ fontFamily }); + }, + [updateConfig], + ); + + const updateLetterSpacing = useCallback( + async (letterSpacing: number) => { + await updateConfig({ letterSpacing }); + }, + [updateConfig], + ); + + const updateLineHeight = useCallback( + async (lineHeight: number) => { + await updateConfig({ lineHeight }); + }, + [updateConfig], + ); + const resetToDefault = useCallback(async () => { await resetConfig(); }, [resetConfig]); @@ -95,6 +122,9 @@ export const TerminalCustomizationProvider: React.FC<{ updateConfig, resetConfig, updateFontSize, + updateFontFamily, + updateLetterSpacing, + updateLineHeight, resetToDefault, }; diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 70445c4..28c48f7 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -10,6 +10,21 @@ import React, { import { SSHHost } from "@/types"; import { router } from "expo-router"; import { useAppContext } from "@/app/AppContext"; +import { + addOpenTab, + deleteOpenTab, + getOpenTabs, + patchOpenTab, + type OpenTabRecord, +} from "@/app/main-axios"; + +export type SessionType = + | "terminal" + | "stats" + | "filemanager" + | "tunnel" + | "docker" + | "remoteDesktop"; export interface TerminalSession { id: string; @@ -17,23 +32,64 @@ export interface TerminalSession { title: string; isActive: boolean; createdAt: Date; - type: "terminal" | "stats" | "filemanager" | "tunnel"; + type: SessionType; + /** Stable per-tab instance id used for cross-device tab sync (open-tabs API). */ + instanceId: string; + /** Backend SSH session id this tab is attached to, when resumed/created. */ + backendSessionId?: string | null; + /** When set, the terminal should attach to this backend session on connect. */ + restoredSessionId?: string | null; +} + +const TYPE_LABELS: Record = { + terminal: "", + stats: "Stats", + filemanager: "Files", + tunnel: "Tunnels", + docker: "Docker", + remoteDesktop: "Remote", +}; + +/** open-tabs tabType is shared with the web app; map our session types to it. */ +function toTabType(type: SessionType): string { + switch (type) { + case "filemanager": + return "files"; + case "remoteDesktop": + return "rdp"; + default: + return type; + } +} + +function uuid(): string { + // RN-safe UUID v4 (crypto.randomUUID is not always available). + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); } interface TerminalSessionsContextType { sessions: TerminalSession[]; activeSessionId: string | null; + /** Background tab records that exist server-side but aren't open here. */ + backgroundTabRecords: OpenTabRecord[]; + refreshBackgroundTabs: () => Promise; addSession: ( host: SSHHost, - type?: "terminal" | "stats" | "filemanager" | "tunnel", + type?: SessionType, + opts?: { instanceId?: string; restoredSessionId?: string | null }, ) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; - navigateToSessions: ( - host?: SSHHost, - type?: "terminal" | "stats" | "filemanager" | "tunnel", - ) => void; + /** Persist the backend session id created/attached for a tab (cross-device). */ + setBackendSessionId: (sessionId: string, backendId: string | null) => void; + /** Forget a server-side background tab record. */ + forgetBackgroundTab: (recordId: string) => void; + navigateToSessions: (host?: SSHHost, type?: SessionType) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; lastKeyboardHeight: number; @@ -66,29 +122,34 @@ export const TerminalSessionsProvider: React.FC< const { isAuthenticated } = useAppContext(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); + const [backgroundTabRecords, setBackgroundTabRecords] = useState< + OpenTabRecord[] + >([]); const [isCustomKeyboardVisible, setIsCustomKeyboardVisible] = useState(false); const [lastKeyboardHeight, setLastKeyboardHeight] = useState(300); const keyboardIntentionallyHiddenRef = useRef(false); const [, forceUpdate] = useState({}); + const refreshBackgroundTabs = useCallback(async () => { + const records = await getOpenTabs(); + setBackgroundTabRecords(records); + }, []); + const addSession = useCallback( ( host: SSHHost, - type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", + type: SessionType = "terminal", + opts?: { instanceId?: string; restoredSessionId?: string | null }, ): string => { + const instanceId = opts?.instanceId ?? uuid(); + const sessionId = `${host.id}-${type}-${Date.now()}`; + setSessions((prev) => { const existingSessions = prev.filter( (session) => session.host.id === host.id && session.type === type, ); - const typeLabel = - type === "stats" - ? "Stats" - : type === "filemanager" - ? "Files" - : type === "tunnel" - ? "Tunnels" - : ""; + const typeLabel = TYPE_LABELS[type]; let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { title = typeLabel @@ -96,7 +157,6 @@ export const TerminalSessionsProvider: React.FC< : `${host.name} (${existingSessions.length + 1})`; } - const sessionId = `${host.id}-${type}-${Date.now()}`; const newSession: TerminalSession = { id: sessionId, host, @@ -104,89 +164,95 @@ export const TerminalSessionsProvider: React.FC< isActive: true, createdAt: new Date(), type, + instanceId, + backendSessionId: opts?.restoredSessionId ?? null, + restoredSessionId: opts?.restoredSessionId ?? null, }; + // Persist this tab server-side for cross-device awareness. + addOpenTab({ + id: instanceId, + tabType: toTabType(type), + hostId: host.id, + label: title, + tabOrder: prev.length, + backendSessionId: opts?.restoredSessionId ?? null, + }); + const updatedSessions = prev.map((session) => ({ ...session, isActive: false, })); - - setActiveSessionId(sessionId); return [...updatedSessions, newSession]; }); - return ""; + setActiveSessionId(sessionId); + return sessionId; }, [], ); - const removeSession = useCallback( - (sessionId: string) => { - setSessions((prev) => { - const sessionToRemove = prev.find( - (session) => session.id === sessionId, - ); - if (!sessionToRemove) return prev; + const removeSession = useCallback((sessionId: string) => { + // All reads of activeSessionId happen inside the setSessions updater so we + // always see the latest state regardless of how quickly sessions are removed. + setSessions((prev) => { + const sessionToRemove = prev.find((session) => session.id === sessionId); + if (!sessionToRemove) return prev; - const updatedSessions = prev.filter( - (session) => session.id !== sessionId, - ); + // Forget the server-side record for this tab. + deleteOpenTab(sessionToRemove.instanceId); - const hostId = sessionToRemove.host.id; - const sessionType = sessionToRemove.type; - const sameHostSessions = updatedSessions.filter( - (session) => - session.host.id === hostId && session.type === sessionType, - ); + let updatedSessions = prev.filter((session) => session.id !== sessionId); - if (sameHostSessions.length > 0) { - sameHostSessions.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - ); + // Re-number sibling sessions of the same host/type. + const hostId = sessionToRemove.host.id; + const sessionType = sessionToRemove.type; + const sameHostSessions = updatedSessions.filter( + (session) => + session.host.id === hostId && session.type === sessionType, + ); - sameHostSessions.forEach((session, index) => { - const sessionIndex = updatedSessions.findIndex( - (s) => s.id === session.id, - ); - if (sessionIndex !== -1) { - const typeLabel = - session.type === "stats" - ? "Stats" - : session.type === "filemanager" - ? "Files" - : session.type === "tunnel" - ? "Tunnels" - : ""; - const baseName = typeLabel - ? `${session.host.name} - ${typeLabel}` - : session.host.name; - updatedSessions[sessionIndex] = { - ...session, - title: index === 0 ? baseName : `${baseName} (${index + 1})`, - }; - } - }); - } - - if (activeSessionId === sessionId) { - if (updatedSessions.length > 0) { - const newActiveSession = - updatedSessions[updatedSessions.length - 1]; - setActiveSessionId(newActiveSession.id); - updatedSessions[updatedSessions.length - 1] = { - ...newActiveSession, - isActive: true, + if (sameHostSessions.length > 0) { + sameHostSessions.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + sameHostSessions.forEach((session, index) => { + const sessionIndex = updatedSessions.findIndex( + (s) => s.id === session.id, + ); + if (sessionIndex !== -1) { + const typeLabel = TYPE_LABELS[session.type]; + const baseName = typeLabel + ? `${session.host.name} - ${typeLabel}` + : session.host.name; + updatedSessions[sessionIndex] = { + ...session, + title: index === 0 ? baseName : `${baseName} (${index + 1})`, }; - } else { - setActiveSessionId(null); } + }); + } + + // Check whether the removed session was the active one — and if so pick a + // new active session. Using setActiveSessionId inside a setSessions updater + // is safe: React batches these in the same flush. + const removedWasActive = !updatedSessions.some((s) => s.isActive); + if (removedWasActive) { + if (updatedSessions.length > 0) { + const newActive = updatedSessions[updatedSessions.length - 1]; + setActiveSessionId(newActive.id); + updatedSessions = updatedSessions.map((s) => ({ + ...s, + isActive: s.id === newActive.id, + })); + } else { + setActiveSessionId(null); } + } - return updatedSessions; - }); - }, - [activeSessionId], - ); + return updatedSessions; + }); + }, []); const setActiveSession = useCallback( (sessionId: string) => { @@ -207,11 +273,28 @@ export const TerminalSessionsProvider: React.FC< [isCustomKeyboardVisible], ); + const setBackendSessionId = useCallback( + (sessionId: string, backendId: string | null) => { + setSessions((prev) => { + const target = prev.find((s) => s.id === sessionId); + if (!target) return prev; + patchOpenTab(target.instanceId, { backendSessionId: backendId }); + return prev.map((s) => + s.id === sessionId + ? { ...s, backendSessionId: backendId, restoredSessionId: null } + : s, + ); + }); + }, + [], + ); + + const forgetBackgroundTab = useCallback((recordId: string) => { + setBackgroundTabRecords((prev) => prev.filter((r) => r.id !== recordId)); + }, []); + const navigateToSessions = useCallback( - ( - host?: SSHHost, - type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", - ) => { + (host?: SSHHost, type: SessionType = "terminal") => { if (host) { addSession(host, type); } @@ -232,6 +315,7 @@ export const TerminalSessionsProvider: React.FC< const clearAllSessions = useCallback(() => { setSessions([]); setActiveSessionId(null); + setBackgroundTabRecords([]); setIsCustomKeyboardVisible(false); keyboardIntentionallyHiddenRef.current = false; }, []); @@ -239,18 +323,25 @@ export const TerminalSessionsProvider: React.FC< useEffect(() => { if (!isAuthenticated) { clearAllSessions(); + } else { + // Hydrate background tab records (e.g. tabs opened on another device). + refreshBackgroundTabs(); } - }, [isAuthenticated, clearAllSessions]); + }, [isAuthenticated, clearAllSessions, refreshBackgroundTabs]); return ( ; + isDark: boolean; + accent: string; // hex, e.g. "#f59145" + accentTriplet: string; // "245 145 69" + ready: boolean; + setTheme: (t: ThemeId) => void; + setAccent: (hex: string) => void; +} + +const ThemeContext = createContext(null); + +const DEFAULT_ACCENT_TRIPLET = hexToRgbTriplet(DEFAULT_ACCENT)!; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useRNColorScheme(); + const [theme, setThemeState] = useState("dark"); + const [accent, setAccentState] = useState(DEFAULT_ACCENT); + const [ready, setReady] = useState(false); + + // Load persisted preferences once on mount. + useEffect(() => { + (async () => { + try { + const [savedTheme, savedAccent] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.theme), + AsyncStorage.getItem(STORAGE_KEYS.accent), + ]); + if (savedTheme) setThemeState(savedTheme as ThemeId); + if (savedAccent && hexToRgbTriplet(savedAccent)) + setAccentState(savedAccent); + } catch { + // best-effort; fall back to defaults + } finally { + setReady(true); + } + })(); + }, []); + + const setTheme = useCallback((t: ThemeId) => { + setThemeState(t); + AsyncStorage.setItem(STORAGE_KEYS.theme, t).catch(() => {}); + }, []); + + const setAccent = useCallback((hex: string) => { + if (!hexToRgbTriplet(hex)) return; + setAccentState(hex); + AsyncStorage.setItem(STORAGE_KEYS.accent, hex).catch(() => {}); + }, []); + + const resolvedTheme: Exclude = + theme === "system" ? (systemScheme === "light" ? "light" : "dark") : theme; + + const isDark = THEME_IS_DARK[resolvedTheme]; + const accentTriplet = hexToRgbTriplet(accent) ?? DEFAULT_ACCENT_TRIPLET; + + // Build the CSS-variable style for the active theme + accent. + const themeStyle = useMemo(() => { + const tokens = THEME_VARS[resolvedTheme]; + const cssVars: Record = { "--accent-brand": accentTriplet }; + for (const [name, value] of Object.entries(tokens)) { + cssVars[`--${name}`] = value; + } + return vars(cssVars); + }, [resolvedTheme, accentTriplet]); + + const value = useMemo( + () => ({ + theme, + resolvedTheme, + isDark, + accent, + accentTriplet, + ready, + setTheme, + setAccent, + }), + [ + theme, + resolvedTheme, + isDark, + accent, + accentTriplet, + ready, + setTheme, + setAccent, + ], + ); + + // The `dark` class enables Tailwind dark: variants; the vars() style on the + // same root view supplies every theme token (and overrides the accent). + return ( + + + {children} + + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} + +/** Resolve a token name (e.g. "accent-brand", "muted-foreground") to an rgb() + * string, optionally with alpha. Useful for non-className props (icon colors, + * WebView injection, chart fills). */ +export function useThemeColor() { + const { resolvedTheme, accentTriplet } = useTheme(); + return useCallback( + (token: string, alpha = 1) => { + const triplet = + token === "accent-brand" + ? accentTriplet + : THEME_VARS[resolvedTheme][token]; + if (!triplet) return undefined; + return alpha >= 1 + ? `rgb(${triplet.split(" ").join(",")})` + : `rgba(${triplet.split(" ").join(",")},${alpha})`; + }, + [resolvedTheme, accentTriplet], + ); +} diff --git a/app/main-axios.ts b/app/main-axios.ts index e86056c..d633a55 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -4,14 +4,11 @@ import type { SSHHostData, TunnelConfig, TunnelStatus, - Credential, - CredentialData, - HostInfo, - ApiResponse, FileManagerFile, FileManagerShortcut, ServerStatus, ServerMetrics, + LoginStatsMetrics, AuthResponse, UserInfo, UserCount, @@ -20,6 +17,10 @@ import type { ServerConfig, UptimeInfo, RecentActivityItem, + DockerContainer, + DockerContainerStats, + DockerContainerAction as DockerActionType, + SessionAuthOverrides, } from "../types/index"; import { apiLogger, @@ -64,7 +65,11 @@ export async function setCookie( ): Promise { try { await AsyncStorage.setItem(name, value); - } catch (error) {} + } catch (error) { + systemLogger.error(`[setCookie] Failed to persist ${name} to AsyncStorage`, error, { + operation: "set_cookie", + }); + } } export async function getCookie(name: string): Promise { @@ -72,10 +77,9 @@ export async function getCookie(name: string): Promise { const token = await AsyncStorage.getItem(name); return token || undefined; } catch (error) { - console.error( - `[getCookie] Error reading ${name} from AsyncStorage:`, - error, - ); + systemLogger.error(`[getCookie] Failed to read ${name} from AsyncStorage`, error, { + operation: "get_cookie", + }); return undefined; } } @@ -221,6 +225,13 @@ function createApiInstance( } else { logger.networkError(method, fullUrl, message, context); } + } else if ( + status === 404 && + serviceName === "STATS" && + url.includes("/metrics/") + ) { + // 404 on metrics means data isn't ready yet — suppress as debug noise. + logger.debug(`Metrics not yet available: ${method} ${url}`, context); } else { logger.requestError( method, @@ -276,17 +287,53 @@ export async function initializeServerConfig(): Promise { configuredServerUrl = config.serverUrl; updateApiInstances(); await detectAndUpdateApiInstances(); - } else { } - } else { } - } catch (error) {} + } catch (error) { + systemLogger.error("[initializeServerConfig] Failed to load server config", error, { + operation: "initialize_server_config", + }); + } } export function getCurrentServerUrl(): string | null { return configuredServerUrl; } +/** + * WebSocket URL for the Docker exec console (backend WS server on port 30009). + * Token is passed as a query param (the WS server accepts cookie / Bearer / + * `?token=`). The console speaks JSON messages: connect/input/resize/disconnect. + */ +export function getDockerConsoleWebSocketUrl(token: string): string { + const base = getRootBase(30009).replace(/\/$/, ""); + const websocketBase = base.replace(/^http/i, (scheme) => + scheme.toLowerCase() === "https" ? "wss" : "ws", + ); + const params = new URLSearchParams({ token }); + // When a real server URL is configured, nginx routes /docker/console/ → port 30009. + // In local dev (no configuredServerUrl), getRootBase already includes :30009. + const path = configuredServerUrl ? "/docker/console/" : "/"; + return `${websocketBase}${path}?${params.toString()}`; +} + +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"); @@ -299,7 +346,11 @@ export async function isAuthenticated(): Promise { export async function clearAuth(): Promise { try { await AsyncStorage.removeItem("jwt"); - } catch (error) {} + } catch (error) { + systemLogger.error("[clearAuth] Failed to remove jwt from AsyncStorage", error, { + operation: "clear_auth", + }); + } } export async function clearServerConfig(): Promise { @@ -317,6 +368,40 @@ export async function clearServerConfig(): Promise { } } +// Behind a reverse-proxy auth gate (e.g. Pangolin) the sign-in WebView keeps the +// proxy's session cookie, so the next sign-in would skip the proxy login. We +// reset it with pure JS (no native cookie module / no rebuild): flag that the +// next sign-in WebView must use an ephemeral (incognito) cookie store, which +// starts with no proxy cookie and therefore shows the proxy login again. +const FRESH_WEBVIEW_SESSION_KEY = "freshWebViewSession"; + +export async function requestFreshWebSession(): Promise { + try { + await AsyncStorage.setItem(FRESH_WEBVIEW_SESSION_KEY, "1"); + } catch { + // Non-fatal: worst case the WebView reuses the previous proxy session. + } +} + +export async function consumeFreshWebSession(): Promise { + try { + const v = await AsyncStorage.getItem(FRESH_WEBVIEW_SESSION_KEY); + if (v) { + await AsyncStorage.removeItem(FRESH_WEBVIEW_SESSION_KEY); + return true; + } + } catch { + // ignore + } + return false; +} + +/** Full session reset: JWT + force a fresh (incognito) proxy login next time. */ +export async function clearSession(): Promise { + await AsyncStorage.removeItem("jwt"); + await requestFreshWebSession(); +} + function getApiUrl(path: string, defaultPort: number): string { if (configuredServerUrl) { const baseUrl = configuredServerUrl.replace(/\/$/, ""); @@ -388,7 +473,11 @@ async function detectAndUpdateApiInstances(): Promise { (async () => { try { const base = getRootBase(8085).replace(/\/$/, ""); - const testInstance = axios.create({ baseURL: base, timeout: 5000, headers: authHeaders }); + const testInstance = axios.create({ + baseURL: base, + timeout: 5000, + headers: authHeaders, + }); await testInstance.head("/status"); return true; } catch { @@ -398,7 +487,11 @@ async function detectAndUpdateApiInstances(): Promise { (async () => { try { const base = getSshBase(8085).replace(/\/$/, ""); - const testInstance = axios.create({ baseURL: base, timeout: 5000, headers: authHeaders }); + const testInstance = axios.create({ + baseURL: base, + timeout: 5000, + headers: authHeaders, + }); await testInstance.head("/status"); return true; } catch { @@ -408,7 +501,11 @@ async function detectAndUpdateApiInstances(): Promise { (async () => { try { const base = getRootBase(8081).replace(/\/$/, ""); - const testInstance = axios.create({ baseURL: base, timeout: 5000, headers: authHeaders }); + const testInstance = axios.create({ + baseURL: base, + timeout: 5000, + headers: authHeaders, + }); await testInstance.head("/users/registration-allowed"); return true; } catch { @@ -418,7 +515,11 @@ async function detectAndUpdateApiInstances(): Promise { (async () => { try { const base = getSshBase(8081).replace(/\/$/, ""); - const testInstance = axios.create({ baseURL: base, timeout: 5000, headers: authHeaders }); + const testInstance = axios.create({ + baseURL: base, + timeout: 5000, + headers: authHeaders, + }); await testInstance.head("/users/registration-allowed"); return true; } catch { @@ -482,6 +583,22 @@ class ApiError extends Error { } } +/** + * Pulls a `connectionLogs` array off a connect response (or an axios error's + * response body). Session connect endpoints return these on both success and + * failure so the UI can surface a connection log. Returns [] when absent. + */ +export function extractConnectionLogs( + source: unknown, +): { type: string; stage?: string; message: string }[] { + const data = + axios.isAxiosError(source) + ? (source.response?.data as any) + : (source as any); + const logs = data?.connectionLogs; + return Array.isArray(logs) ? logs : []; +} + function handleApiError(error: unknown, operation: string): never { const context: LogContext = { operation: "error_handling", @@ -642,10 +759,41 @@ function handleApiError(error: unknown, operation: string): never { // SSH HOST MANAGEMENT // ============================================================================ +function normalizeJumpHosts(value: unknown): { hostId: number }[] { + const raw = + typeof value === "string" + ? (() => { + try { + return JSON.parse(value); + } catch { + return []; + } + })() + : value; + + if (!Array.isArray(raw)) return []; + + return raw + .map((item) => { + const hostId = Number((item as { hostId?: unknown })?.hostId); + return Number.isFinite(hostId) ? { hostId } : null; + }) + .filter((item): item is { hostId: number } => item !== null); +} + +function normalizeSSHHost(host: SSHHost): SSHHost { + return { + ...host, + jumpHosts: normalizeJumpHosts((host as { jumpHosts?: unknown }).jumpHosts), + }; +} + export async function getSSHHosts(): Promise { try { const response = await sshHostApi.get("/db/host"); - return response.data; + return Array.isArray(response.data) + ? response.data.map(normalizeSSHHost) + : response.data; } catch (error) { handleApiError(error, "fetch SSH hosts"); } @@ -683,6 +831,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + ...buildProtocolFields(hostData), }; if (!submitData.enableTunnel) { @@ -714,6 +863,39 @@ export async function createSSHHost(hostData: SSHHostData): Promise { } } +/** + * Modern multi-protocol fields shared by create/update. Only included when the + * caller provides them so legacy SSH-only payloads are unchanged. SSH defaults + * to enabled when no protocol flags are specified. + */ +function buildProtocolFields(hostData: SSHHostData): Record { + const anyProtocol = + hostData.enableSsh !== undefined || + hostData.enableRdp || + hostData.enableVnc || + hostData.enableTelnet; + return { + enableSsh: anyProtocol ? Boolean(hostData.enableSsh) : true, + enableRdp: Boolean(hostData.enableRdp), + enableVnc: Boolean(hostData.enableVnc), + enableTelnet: Boolean(hostData.enableTelnet), + enableDocker: Boolean(hostData.enableDocker), + notes: hostData.notes ?? "", + rdpUser: hostData.enableRdp ? (hostData.rdpUser ?? null) : null, + rdpPassword: hostData.enableRdp ? (hostData.rdpPassword ?? null) : null, + rdpDomain: hostData.enableRdp ? (hostData.rdpDomain ?? null) : null, + rdpPort: hostData.enableRdp ? (hostData.rdpPort ?? null) : null, + vncUser: hostData.enableVnc ? (hostData.vncUser ?? null) : null, + vncPassword: hostData.enableVnc ? (hostData.vncPassword ?? null) : null, + vncPort: hostData.enableVnc ? (hostData.vncPort ?? null) : null, + telnetUser: hostData.enableTelnet ? (hostData.telnetUser ?? null) : null, + telnetPassword: hostData.enableTelnet + ? (hostData.telnetPassword ?? null) + : null, + telnetPort: hostData.enableTelnet ? (hostData.telnetPort ?? null) : null, + }; +} + export async function updateSSHHost( hostId: number, hostData: SSHHostData, @@ -749,6 +931,7 @@ export async function updateSSHHost( : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + ...buildProtocolFields(hostData), }; if (!submitData.enableTunnel) { @@ -822,6 +1005,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 // ============================================================================ @@ -1063,6 +1257,23 @@ export async function connectSSH( } } +export async function verifySSHWarpgate( + sessionId: string, + warpgateUrl: string, + securityKey?: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/connect-warpgate", { + sessionId, + warpgateUrl, + securityKey, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify SSH Warpgate"); + } +} + export async function disconnectSSH(sessionId: string): Promise { try { const response = await fileManagerApi.post("/ssh/disconnect", { @@ -1518,6 +1729,108 @@ export async function compressSSHFiles( } } +export async function resolveSSHPath( + sessionId: string, + path: string, +): Promise<{ resolved: string }> { + try { + const response = await fileManagerApi.get("/ssh/resolvePath", { + params: { sessionId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "resolve SSH path"); + } +} + +export async function executeSSHFile( + sessionId: string, + path: string, + hostId?: number, + userId?: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/executeFile", { + sessionId, + path, + hostId, + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "execute SSH file"); + } +} + +/** + * Store a sudo password for a session so the backend can satisfy sudo prompts + * during file operations (mirrors the web file-manager sudo flow). + */ +export async function setSSHSudoPassword( + sessionId: string, + password: string, +): Promise { + try { + const response = await fileManagerApi.post("/sudo-password", { + sessionId, + password, + }); + return response.data; + } catch (error) { + handleApiError(error, "set sudo password"); + } +} + +// ============================================================================ +// TERMINAL COMMAND HISTORY +// ============================================================================ + +/** Per-host shell command history (deduped, newest first, max 500). */ +export async function getCommandHistory(hostId: number): Promise { + try { + const response = await authApi.get(`/terminal/command_history/${hostId}`); + const data = response.data; + if (Array.isArray(data)) { + return data + .map((row: any) => (typeof row === "string" ? row : row?.command)) + .filter((c: unknown): c is string => typeof c === "string"); + } + return []; + } catch { + return []; + } +} + +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise { + try { + await authApi.post("/terminal/command_history", { hostId, command }); + } catch { + // History is best-effort; never block the terminal on it. + } +} + +export async function deleteCommandFromHistory( + hostId: number, + command: string, +): Promise { + try { + await authApi.post("/terminal/command_history/delete", { hostId, command }); + } catch { + // Best-effort. + } +} + +export async function clearCommandHistory(hostId: number): Promise { + try { + await authApi.delete(`/terminal/command_history/${hostId}`); + } catch { + // Best-effort. + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ @@ -1705,32 +2018,138 @@ export async function getServerStatusById(id: number): Promise { } } -export async function getServerMetricsById(id: number): Promise { +function normalizeMetrics(raw: any): ServerMetrics { + // Processes: coerce cpu/mem strings → numbers (backend sends "2.5" not 2.5) + let processes = raw.processes; + if (processes?.top) { + processes = { + ...processes, + top: processes.top.map((p: any) => ({ + ...p, + cpu: Number(p.cpu) || 0, + mem: Number(p.mem) || 0, + })), + }; + } + + // Network: map rxBytes/txBytes → rx/tx + let network = raw.network; + if (network?.interfaces) { + network = { + ...network, + interfaces: network.interfaces.map((iface: any) => ({ + ...iface, + rx: iface.rx ?? iface.rxBytes ?? null, + tx: iface.tx ?? iface.txBytes ?? null, + })), + }; + } + + // Login stats: snake_case backend key → camelCase, map to correct shape + const rawLogin = raw.login_stats ?? raw.loginStats; + const loginStats: LoginStatsMetrics | undefined = rawLogin + ? { + recentLogins: Array.isArray(rawLogin.recentLogins) ? rawLogin.recentLogins : [], + failedLogins: Array.isArray(rawLogin.failedLogins) ? rawLogin.failedLogins : [], + totalLogins: rawLogin.totalLogins ?? 0, + uniqueIPs: rawLogin.uniqueIPs ?? 0, + } + : undefined; + + return { ...raw, processes, network, loginStats } as ServerMetrics; +} + +export async function getServerMetricsById(id: number): Promise { try { const response = await statsApi.get(`/metrics/${id}`); - return response.data; + return response.data ? normalizeMetrics(response.data) : null; } catch (error: any) { if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get(`/metrics/${id}`); - return response.data; - } catch (e) { - handleApiError(e, "fetch server metrics"); - } + // Metrics not ready yet — backend is still starting collection. + return null; } handleApiError(error, "fetch server metrics"); } } +/** + * Start metrics collection for a host. Returns a viewerSessionId that must be + * passed to heartbeat/stop/unregister calls. May return requiresTOTP for 2FA hosts. + */ +export async function startMetricsPolling(id: number): Promise<{ + success?: boolean; + requiresTOTP?: boolean; + sessionId?: string; + viewerSessionId?: string; +}> { + try { + const response = await statsApi.post(`/metrics/start/${id}`); + return response.data || {}; + } catch (error) { + handleApiError(error, "start metrics polling"); + } +} + +export async function stopMetricsPolling(id: number, viewerSessionId?: string): Promise { + try { + await statsApi.post(`/metrics/stop/${id}`, viewerSessionId ? { viewerSessionId } : undefined); + } catch { + // Best-effort on teardown. + } +} + +export async function submitMetricsTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await statsApi.post("/metrics/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "submit metrics TOTP"); + } +} + +/** Register this viewer so the backend keeps polling while the screen is open. Returns a viewerSessionId. */ +export async function registerMetricsViewer(id: number): Promise<{ + success?: boolean; + viewerSessionId?: string; + skipped?: boolean; +}> { + try { + const response = await statsApi.post("/metrics/register-viewer", { hostId: id }); + return response.data || {}; + } catch { + // Best-effort. + return {}; + } +} + +export async function unregisterMetricsViewer(id: number, viewerSessionId: string): Promise { + try { + await statsApi.post("/metrics/unregister-viewer", { hostId: id, viewerSessionId }); + } catch { + // Best-effort on teardown. + } +} + +/** Heartbeat so the backend knows a viewer is still watching this host. */ +export async function sendMetricsHeartbeat(viewerSessionId: string): Promise { + try { + await statsApi.post("/metrics/heartbeat", { viewerSessionId }); + } catch { + // Best-effort. + } +} + export async function refreshServerPolling(): Promise { try { await statsApi.post("/refresh"); } catch (error) { - console.warn("Failed to refresh server polling:", error); + statsLogger.warn("Failed to refresh server polling", { operation: "refresh_polling" }); } } @@ -1740,7 +2159,7 @@ export async function notifyHostCreatedOrUpdated( try { await statsApi.post("/host-updated", { hostId }); } catch (error) { - console.warn("Failed to notify stats server of host update:", error); + statsLogger.warn("Failed to notify stats server of host update", { operation: "notify_host_updated" }); } } @@ -1791,6 +2210,62 @@ function extractJwtFromSetCookie(headers: any): string | null { return null; } +/** + * Detects whether the server URL sits behind a reverse-proxy authentication + * gate (Cloudflare Access, Authelia, etc.) that intercepts requests and serves + * its own HTML login page instead of forwarding them to Termix. In that case a + * native login form cannot work — the user must authenticate to the proxy in a + * browser context (the WebView/SSO flow). + * + * Returns true when a known JSON endpoint responds with HTML (or otherwise + * non-JSON) content, which is the tell-tale sign of an interposing auth proxy. + */ +export async function isReverseProxyAuthGate(): Promise { + const probe = async (base: string): Promise => { + try { + const url = `${base.replace(/\/$/, "")}/users/registration-allowed`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + let res: Response; + try { + res = await fetch(url, { + method: "GET", + signal: controller.signal, + headers: { + Accept: "application/json", + "User-Agent": `Termix-Mobile/${Platform.OS === "android" ? "Android" : "iOS"}`, + }, + }); + } finally { + clearTimeout(timeoutId); + } + const contentType = (res.headers.get("content-type") || "").toLowerCase(); + const body = await res.text(); + // A genuine Termix API endpoint returns JSON. An auth proxy returns its + // login HTML (often with a 200 or a redirect-resolved 200). + if (contentType.includes("application/json")) return false; + const looksHtml = + contentType.includes("text/html") || + /^\s*<(?:!doctype|html)/i.test(body); + if (looksHtml) return true; + // Unknown content-type but valid JSON body → treat as real API. + try { + JSON.parse(body); + return false; + } catch { + return looksHtml ? true : null; + } + } catch { + return null; + } + }; + + const rootResult = await probe(getRootBase(8081)); + if (rootResult !== null) return rootResult; + const sshResult = await probe(getSshBase(8081)); + return sshResult === true; +} + async function loginWithFetch( baseUrl: string, username: string, @@ -1803,14 +2278,39 @@ async function loginWithFetch( body: JSON.stringify({ username, password }), }); + const contentType = ( + fetchResponse.headers.get("content-type") || "" + ).toLowerCase(); + const rawBody = await fetchResponse.text(); + + // A reverse-proxy auth gate intercepts the request and returns its HTML login + // page instead of Termix JSON. Surface a clear, actionable error rather than + // crashing on JSON.parse of "…". + const looksHtml = + contentType.includes("text/html") || + /^\s*<(?:!doctype|html)/i.test(rawBody); + if (looksHtml) { + const err: any = new Error( + "This server is behind a login proxy. Use the external sign-in option instead.", + ); + err.code = "PROXY_AUTH_GATE"; + err.response = { status: fetchResponse.status, data: {} }; + throw err; + } + if (!fetchResponse.ok) { - const errData = await fetchResponse.json().catch(() => ({})); + let errData: any = {}; + try { + errData = rawBody ? JSON.parse(rawBody) : {}; + } catch { + errData = {}; + } const err: any = new Error(errData?.error || "Login failed"); err.response = { status: fetchResponse.status, data: errData }; throw err; } - const data = await fetchResponse.json(); + const data = JSON.parse(rawBody); let token: string | null = data.token || null; const setCookie = fetchResponse.headers.get("set-cookie"); @@ -1834,12 +2334,17 @@ export async function loginUser( return { ...data, token: data.temp_token || "" }; } - let finalToken = token; if (!finalToken) { try { - const axiosResponse = await authApi.post("/users/login", { username, password }); - finalToken = extractJwtFromSetCookie(axiosResponse.headers) || axiosResponse.data.token || null; + const axiosResponse = await authApi.post("/users/login", { + username, + password, + }); + finalToken = + extractJwtFromSetCookie(axiosResponse.headers) || + axiosResponse.data.token || + null; } catch { // ignore, we already have data } @@ -1851,10 +2356,17 @@ export async function loginUser( return { ...data, token: finalToken || "" }; } catch (error: any) { + if (error?.code === "PROXY_AUTH_GATE") { + throw new ApiError(error.message, 0, "PROXY_AUTH_GATE"); + } if (error?.response?.status === 404) { try { const altBase = getSshBase(8081); - const { data, token } = await loginWithFetch(altBase, username, password); + const { data, token } = await loginWithFetch( + altBase, + username, + password, + ); if (data.requires_totp) { return { ...data, token: data.temp_token || "" }; @@ -1865,7 +2377,10 @@ export async function loginUser( } return { ...data, token: token || "" }; - } catch (e) { + } catch (e: any) { + if (e?.code === "PROXY_AUTH_GATE") { + throw new ApiError(e.message, 0, "PROXY_AUTH_GATE"); + } handleApiError(e, "login user"); } } @@ -1952,10 +2467,10 @@ export async function getOIDCConfig(): Promise { const response = await authApi.get("/users/oidc-config"); return response.data; } catch (error: any) { - console.warn( - "Failed to fetch OIDC config:", - error.response?.data?.error || error.message, - ); + authLogger.warn("Failed to fetch OIDC config", { + operation: "get_oidc_config", + error: error.response?.data?.error || error.message, + }); return null; } } @@ -2028,25 +2543,14 @@ export async function completePasswordReset( } } -export async function changePassword( - oldPassword: string, - newPassword: string, -): Promise { +export async function getOIDCAuthorizeUrl( + appCallbackUrl?: string, +): Promise { try { - const response = await authApi.post("/users/change-password", { - oldPassword, - newPassword, + const response = await authApi.get("/users/oidc/authorize", { + params: appCallbackUrl ? { appCallbackUrl } : undefined, }); return response.data; - } catch (error) { - handleApiError(error, "change password"); - } -} - -export async function getOIDCAuthorizeUrl(): Promise { - try { - const response = await authApi.get("/users/oidc/authorize"); - return response.data; } catch (error) { handleApiError(error, "get OIDC authorize URL"); } @@ -2497,7 +3001,7 @@ export async function getSSHHostWithCredentials(hostId: number): Promise { const response = await sshHostApi.get( `/db/host/${hostId}/with-credentials`, ); - return response.data; + return response.data ? normalizeSSHHost(response.data) : response.data; } catch (error) { handleApiError(error, "fetch SSH host with credentials"); } @@ -2589,9 +3093,15 @@ export function connectToTerminalHost( hostConfig, }, }; - ws.send(JSON.stringify(connectMessage)); } else { + sshLogger.warn( + "[connectToTerminalHost] WebSocket is not open — connect message dropped", + { + operation: "connect_to_host", + readyState: ws.readyState, + }, + ); } } @@ -3102,3 +3612,348 @@ export async function unlinkOIDCFromPasswordAccount( throw error; } } + +// ============================================================================ +// OPEN TABS / CROSS-DEVICE SESSIONS +// Persists open tabs per user so connections can be revived and switched +// between devices (open on desktop, continue on mobile). Mirrors the web app. +// ============================================================================ + +export interface OpenTabRecord { + id: string; + userId: string; + tabType: string; + hostId: number | null; + label: string; + tabOrder: number; + backendSessionId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OpenTabUpsertPayload { + id: string; + tabType: string; + hostId?: number | null; + label: string; + tabOrder: number; + backendSessionId?: string | null; +} + +export interface ActiveSessionInfo { + sessionId: string; + hostId: number; + hostName: string; + tabInstanceId: string | null; + isConnected: boolean; + createdAt: number; +} + +export async function getOpenTabs(): Promise { + try { + const response = await authApi.get("/open-tabs"); + return Array.isArray(response.data) ? response.data : []; + } catch { + return []; + } +} + +export async function addOpenTab(tab: OpenTabUpsertPayload): Promise { + try { + await authApi.post("/open-tabs", tab); + } catch { + // best-effort; cross-device sync is non-critical to local use + } +} + +export async function patchOpenTab( + instanceId: string, + updates: Partial< + Pick + >, +): Promise { + try { + await authApi.patch(`/open-tabs/${instanceId}`, updates); + } catch { + // best-effort + } +} + +export async function deleteOpenTab(instanceId: string): Promise { + try { + await authApi.delete(`/open-tabs/${instanceId}`); + } catch { + // best-effort + } +} + +export async function getActiveSessions(): Promise { + try { + const response = await authApi.get("/open-tabs/active-sessions"); + return Array.isArray(response.data) ? response.data : []; + } catch { + return []; + } +} + +// ============================================================================ +// DOCKER — session-based container management over SSH. +// +// IMPORTANT: Docker uses the SAME session-based REST contract as the file +// manager (connect → sessionId → keepalive/status/disconnect → operations), +// served by the SSH/file-manager backend service (`fileManagerApi` base, paths +// under `/docker/...`). The previous mobile implementation called +// `sshHostApi /:hostId/docker/...` endpoints that DO NOT EXIST on the backend, +// so Docker never actually worked — this is the corrected wiring. +// +// Guacamole helpers (getGuacamoleWebSocketUrl/getGuacamoleTokenFromHost) live +// earlier in this file. +// ============================================================================ + +export type { + DockerContainer, + DockerContainerStats, +} from "../types/index"; + +/** + * Docker REST API base. nginx routes /docker/* → port 30007 from the server + * root (no /ssh prefix). In local dev without a configured server URL, + * getRootBase falls back to localhost:30007. + */ +function getDockerBase(): string { + return getRootBase(30007).replace(/\/$/, ""); +} + +function dockerApi(): AxiosInstance { + return createApiInstance(getDockerBase(), "DOCKER"); +} + +/** Establish (or reuse) an SSH session for Docker operations on a host. */ +export async function dockerConnect( + sessionId: string, + hostId: number, + overrides?: SessionAuthOverrides, +): Promise { + try { + const response = await dockerApi().post("/docker/ssh/connect", { + sessionId, + hostId, + userProvidedPassword: overrides?.userProvidedPassword, + userProvidedSshKey: overrides?.userProvidedSshKey, + userProvidedKeyPassword: overrides?.userProvidedKeyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "connect Docker session"); + } +} + +export async function dockerConnectTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await dockerApi().post("/docker/ssh/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "submit Docker TOTP"); + } +} + +export async function dockerKeepAlive(sessionId: string): Promise { + try { + await dockerApi().post("/docker/ssh/keepalive", { sessionId }); + } catch { + // Best-effort heartbeat. + } +} + +export async function dockerDisconnect(sessionId: string): Promise { + try { + await dockerApi().post("/docker/ssh/disconnect", { sessionId }); + } catch { + // Best-effort on teardown. + } +} + +export async function dockerStatus( + sessionId: string, +): Promise<{ connected: boolean }> { + try { + const response = await dockerApi().get("/docker/ssh/status", { + params: { sessionId }, + }); + return response.data || { connected: false }; + } catch { + return { connected: false }; + } +} + +/** Whether Docker is actually available on the connected host. */ +export async function dockerValidate( + sessionId: string, +): Promise<{ available: boolean; version?: string; error?: string }> { + try { + const response = await dockerApi().get(`/docker/validate/${sessionId}`); + return response.data; + } catch (error) { + handleApiError(error, "validate Docker"); + } +} + +export async function getDockerContainers( + sessionId: string, + all = true, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}`, + { params: { all } }, + ); + const data = response.data; + return Array.isArray(data) ? data : (data?.containers ?? []); + } catch (error) { + handleApiError(error, "list Docker containers"); + } +} + +export async function getDockerContainerDetail( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "inspect Docker container"); + } +} + +export async function getDockerContainerStats( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}/stats`, + ); + return response.data; + } catch (error) { + handleApiError(error, "fetch Docker stats"); + } +} + +export async function dockerContainerAction( + sessionId: string, + containerId: string, + action: DockerActionType, +): Promise { + try { + if (action === "remove") { + await dockerApi().delete( + `/docker/containers/${sessionId}/${containerId}`, + ); + } else { + await dockerApi().post( + `/docker/containers/${sessionId}/${containerId}/${action}`, + ); + } + } catch (error) { + handleApiError(error, `docker ${action}`); + } +} + +export async function getDockerContainerLogs( + sessionId: string, + containerId: string, + tail = 200, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}/logs`, + { params: { tail } }, + ); + const data = response.data; + if (typeof data === "string") return data; + return data?.logs ?? ""; + } catch (error) { + handleApiError(error, "fetch Docker logs"); + } +} + +// ============================================================================ +// WAKE-ON-LAN +// ============================================================================ + +export async function wakeHost(hostId: number): Promise<{ success: boolean; message: string }> { + try { + const response = await sshHostApi.post(`/db/host/${hostId}/wake`); + return response.data; + } catch (error) { + handleApiError(error, "wake host"); + } +} + +// ============================================================================ +// API KEY MANAGEMENT +// ============================================================================ + +export interface ApiKey { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; +} + +export async function getApiKeys(): Promise<{ apiKeys: ApiKey[] }> { + try { + const response = await authApi.get("/users/api-keys"); + return response.data; + } catch (error) { + handleApiError(error, "fetch API keys"); + } +} + +export async function createApiKey( + name: string, + userId: string, + expiresAt?: string, +): Promise<{ apiKey: ApiKey & { key: string; token: string } }> { + try { + const response = await authApi.post("/users/api-keys", { + name, + userId, + expiresAt: expiresAt ?? null, + }); + return response.data; + } catch (error) { + handleApiError(error, "create API key"); + } +} + +export async function deleteApiKey(keyId: string): Promise { + try { + await authApi.delete(`/users/api-keys/${keyId}`); + } catch (error) { + handleApiError(error, "delete API key"); + } +} + +// ============================================================================ +// TOTP BACKUP CODES +// ============================================================================ + +export async function getTOTPBackupCodes(): Promise<{ backup_codes: string[] }> { + try { + const response = await authApi.get("/users/totp/backup-codes"); + return response.data; + } catch (error) { + handleApiError(error, "fetch TOTP backup codes"); + } +} diff --git a/app/tabs/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx index 21b6ba8..0ab7a0a 100644 --- a/app/tabs/dialogs/HostKeyVerificationDialog.tsx +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -1,25 +1,10 @@ -import React, { useState, useCallback } from "react"; -import { - View, - Text, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, -} from "react-native"; +import { useState, useCallback } from "react"; +import { View, Platform, Modal, Pressable, ScrollView } 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"; +import { Shield, AlertTriangle, Copy, Check } from "lucide-react-native"; +import { Button, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import type { HostKeyData } from "@/app/tabs/sessions/terminal/NativeWebSocketManager"; interface HostKeyVerificationDialogProps { visible: boolean; @@ -29,14 +14,20 @@ interface HostKeyVerificationDialogProps { onReject: () => void; } -const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp; +const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") ?? fp; -const FingerprintRow: React.FC<{ +function FingerprintRow({ + label, + algorithm, + fingerprint, + keyType, +}: { label: string; algorithm: string; fingerprint: string; keyType: string; -}> = ({ label, algorithm, fingerprint, keyType }) => { +}) { + const color = useThemeColor(); const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -44,53 +35,22 @@ const FingerprintRow: React.FC<{ await Clipboard.setStringAsync(formatFingerprint(fingerprint)); setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (_) {} + } catch {} }, [fingerprint]); return ( - - + + {label} - - + + {algorithm.toUpperCase()} ({keyType}) - + {formatFingerprint(fingerprint)} - + ) : ( + + ) + } > - - - {copied ? "Copied!" : "Copy"} - - + {copied ? "Copied" : "Copy"} + ); -}; - -const HostKeyVerificationDialogComponent: React.FC< - HostKeyVerificationDialogProps -> = ({ visible, scenario, data, onAccept, onReject }) => { - const { isLandscape } = useOrientation(); - const insets = useSafeAreaInsets(); - const padding = getResponsivePadding(isLandscape); +} +export function HostKeyVerificationDialog({ + visible, + scenario, + data, + onAccept, + onReject, +}: HostKeyVerificationDialogProps) { + const color = useThemeColor(); const isChanged = scenario === "changed"; - const accentColor = isChanged ? "#ef4444" : "#22c55e"; - const accentBorder = isChanged ? "#dc2626" : "#16a34a"; - - const hostLabel = data ? `${data.hostname || data.ip}:${data.port}` : ""; + const hostLabel = data ? `${data.hostname ?? data.ip}:${data.port}` : ""; return ( - - e.stopPropagation()} > - + {/* Card with scenario-aware border */} - {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 ? ( + + ) : ( + + )} + + + + {isChanged ? "Host Key Changed!" : "Verify Host Key"} + + + {hostLabel} + + + - - + {/* Status banner */} + + + {isChanged + ? "The host's SSH key has changed since your last connection. This could indicate a security risk." + : "You are connecting to this host for the first time. Verify the fingerprint before trusting."} + + + + {/* Fingerprints */} + {data && isChanged && data.oldFingerprint ? ( + <> + + + + ) : data ? ( + + ) : null} + + + {/* Footer */} + + + + - - - + + + ); -}; - -export const HostKeyVerificationDialog = React.memo( - HostKeyVerificationDialogComponent, -); +} diff --git a/app/tabs/dialogs/PassphraseDialog.tsx b/app/tabs/dialogs/PassphraseDialog.tsx new file mode 100644 index 0000000..2895b72 --- /dev/null +++ b/app/tabs/dialogs/PassphraseDialog.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect, useCallback } from "react"; +import { View, Platform } from "react-native"; +import { KeyRound } from "lucide-react-native"; +import { Dialog, Input, Button, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +interface PassphraseDialogProps { + visible: boolean; + onSubmit: (passphrase: string) => void; + onCancel: () => void; + hostInfo: { + name?: string; + ip: string; + port: number; + username: string; + }; +} + +export function PassphraseDialog({ + visible, + onSubmit, + onCancel, + hostInfo, +}: PassphraseDialogProps) { + const color = useThemeColor(); + const [passphrase, setPassphrase] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!visible) { + setPassphrase(""); + setBusy(false); + } + }, [visible]); + + const handleSubmit = useCallback(() => { + if (!passphrase.trim()) return; + setBusy(true); + try { + onSubmit(passphrase); + } finally { + setBusy(false); + } + }, [passphrase, onSubmit]); + + const hostLabel = hostInfo.name + ? `${hostInfo.name} · ${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}` + : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`; + + return ( + } + title="Passphrase Required" + description="Enter the passphrase for your SSH key." + footer={ + + + + + } + > + + {hostLabel} + + + + ); +} diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx index 1b55a08..9d714a2 100644 --- a/app/tabs/dialogs/SSHAuthDialog.tsx +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -1,30 +1,8 @@ -import React, { - useState, - useEffect, - useCallback, - useMemo, - useRef, -} from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, - TouchableWithoutFeedback, -} from "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 { useState, useEffect, useCallback } from "react"; +import { View, Platform } from "react-native"; +import { Lock } from "lucide-react-native"; +import { Dialog, Input, Button, Text, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface SSHAuthDialogProps { visible: boolean; @@ -43,22 +21,18 @@ interface SSHAuthDialogProps { reason: "no_keyboard" | "auth_failed" | "timeout"; } -const SSHAuthDialogComponent: React.FC = ({ +export function SSHAuthDialog({ visible, onSubmit, onCancel, hostInfo, reason, -}) => { +}: SSHAuthDialogProps) { + const color = useThemeColor(); const [authMethod, setAuthMethod] = useState<"password" | "key">("password"); const [password, setPassword] = useState(""); const [sshKey, setSshKey] = useState(""); const [keyPassword, setKeyPassword] = useState(""); - const { isLandscape } = useOrientation(); - const insets = useSafeAreaInsets(); - const padding = getResponsivePadding(isLandscape); - const passwordInputRef = useRef(null); - const sshKeyInputRef = useRef(null); useEffect(() => { if (!visible) { @@ -69,349 +43,116 @@ const SSHAuthDialogComponent: React.FC = ({ } }, [visible]); - useEffect(() => { - if (visible) { - const timer = setTimeout(() => { - if (authMethod === "password") { - passwordInputRef.current?.focus(); - } else { - sshKeyInputRef.current?.focus(); - } - }, 300); - return () => clearTimeout(timer); - } - }, [visible, authMethod]); - - const getReasonMessage = useCallback(() => { - switch (reason) { - case "no_keyboard": - return "Keyboard-interactive authentication is not supported on mobile. Please provide credentials directly."; - case "auth_failed": - return "Authentication failed. Please re-enter your credentials."; - case "timeout": - return "Connection timed out. Please try again with your credentials."; - default: - return "Please provide your credentials to connect."; - } - }, [reason]); - const handleSubmit = useCallback(() => { if (authMethod === "password" && password.trim()) { onSubmit({ password }); - setPassword(""); } else if (authMethod === "key" && sshKey.trim()) { - onSubmit({ - sshKey, - keyPassword: keyPassword.trim() || undefined, - }); - setSshKey(""); - setKeyPassword(""); + onSubmit({ sshKey, keyPassword: keyPassword.trim() || undefined }); } }, [authMethod, password, sshKey, keyPassword, onSubmit]); - const handleCancel = useCallback(() => { - setPassword(""); - setSshKey(""); - setKeyPassword(""); - onCancel(); - }, [onCancel]); - - const handleSetAuthMethod = useCallback((method: "password" | "key") => { - setAuthMethod(method); - }, []); - - const isValid = useMemo( - () => - authMethod === "password" - ? password.trim().length > 0 - : sshKey.trim().length > 0, - [authMethod, password, sshKey], - ); + const isValid = + authMethod === "password" ? !!password.trim() : !!sshKey.trim(); + + const hostLabel = hostInfo.name + ? `${hostInfo.name} · ${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}` + : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`; + + const reasonText = + reason === "no_keyboard" + ? "Keyboard-interactive auth is not available. Enter credentials directly." + : reason === "auth_failed" + ? "Authentication failed. Please re-enter your credentials." + : "Connection timed out. Please try again with your credentials."; + + const bannerBg = + reason === "auth_failed" || reason === "timeout" + ? "bg-destructive/10 border-destructive/40" + : "bg-yellow-500/10 border-yellow-500/40"; + const bannerText = + reason === "auth_failed" || reason === "timeout" + ? "text-destructive" + : "text-yellow-400"; return ( - } + title="SSH Authentication Required" + description={hostLabel} + footer={ + + + + + } > - - - + {reasonText} + + + + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + value={authMethod} + onChange={setAuthMethod} + className="mb-3" + /> + + {authMethod === "password" ? ( + + ) : ( + + - - SSH Authentication Required - - - - - {getReasonMessage()} - - - - - handleSetAuthMethod("password")} - style={{ - flex: 1, - paddingVertical: 12, - backgroundColor: - authMethod === "password" ? "#16a34a" : "#1a1a1a", - borderWidth: BORDERS.STANDARD, - borderColor: - authMethod === "password" - ? "#16a34a" - : BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} - > - - Password - - - handleSetAuthMethod("key")} - style={{ - flex: 1, - paddingVertical: 12, - backgroundColor: authMethod === "key" ? "#16a34a" : "#1a1a1a", - borderWidth: BORDERS.STANDARD, - borderColor: - authMethod === "key" ? "#16a34a" : BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} - > - - SSH Key - - - - - {authMethod === "password" && ( - - - Password - - - - )} - - {authMethod === "key" && ( - <> - - - Private SSH Key - - - - - - Key Password (optional) - - - - - )} - - - - - Cancel - - - - - Connect - - - - - - - + /> + + + )} + ); -}; - -export const SSHAuthDialog = React.memo(SSHAuthDialogComponent); +} diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx index 0165c72..6f0512a 100644 --- a/app/tabs/dialogs/TOTPDialog.tsx +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -1,31 +1,9 @@ -import React, { - useState, - useEffect, - useCallback, - useMemo, - useRef, -} from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, -} from "react-native"; +import { useState, useEffect, useCallback } from "react"; +import { View } from "react-native"; import * as Clipboard from "expo-clipboard"; -import { Clipboard as ClipboardIcon } 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 { ShieldCheck, Clipboard as ClipboardIcon } from "lucide-react-native"; +import { Dialog, Input, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface TOTPDialogProps { visible: boolean; @@ -35,222 +13,97 @@ interface TOTPDialogProps { isPasswordPrompt?: boolean; } -const TOTPDialogComponent: React.FC = ({ +export function TOTPDialog({ visible, onSubmit, onCancel, - prompt = "Two-Factor Authentication", + prompt, isPasswordPrompt = false, -}) => { +}: TOTPDialogProps) { + const color = useThemeColor(); const [code, setCode] = useState(""); - const { isLandscape } = useOrientation(); - const insets = useSafeAreaInsets(); - const padding = getResponsivePadding(isLandscape); - const inputRef = useRef(null); + const [busy, setBusy] = useState(false); useEffect(() => { if (!visible) { setCode(""); + setBusy(false); } }, [visible]); - useEffect(() => { - if (visible) { - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 300); - return () => clearTimeout(timer); - } - }, [visible]); - - const handleSubmit = useCallback(() => { - if (code.trim()) { - onSubmit(code); - setCode(""); + const handleSubmit = useCallback(async () => { + if (!code.trim()) return; + setBusy(true); + try { + onSubmit(code.trim()); + } finally { + setBusy(false); } }, [code, onSubmit]); - const handleCancel = useCallback(() => { - setCode(""); - onCancel(); - }, [onCancel]); - const handlePaste = useCallback(async () => { try { - const clipboardContent = await Clipboard.getString(); - if (clipboardContent) { - const pastedCode = isPasswordPrompt - ? clipboardContent - : clipboardContent.replace(/\D/g, "").slice(0, 6); - setCode(pastedCode); + const text = await Clipboard.getStringAsync(); + if (text) { + setCode(isPasswordPrompt ? text : text.replace(/\D/g, "").slice(0, 6)); } - } catch (error) { - console.error("Failed to paste from clipboard:", error); - } + } catch {} }, [isPasswordPrompt]); - const isCodeValid = useMemo(() => code.trim().length > 0, [code]); + const title = prompt || (isPasswordPrompt ? "Password Required" : "Two-Factor Authentication"); + const description = isPasswordPrompt + ? "Enter your password to continue." + : "Enter the 6-digit code from your authenticator app."; return ( - } + title={title} + description={description} + footer={ + + + + + } > - - - + setCode(isPasswordPrompt ? t : t.replace(/[^0-9]/g, "").slice(0, 6)) + } + placeholder={isPasswordPrompt ? "Password" : "000000"} + keyboardType={isPasswordPrompt ? "default" : "number-pad"} + secureTextEntry={isPasswordPrompt} + autoFocus + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + maxLength={isPasswordPrompt ? undefined : 6} + onSubmitEditing={handleSubmit} + trailing={ + + } + /> + ); -}; - -export const TOTPDialog = React.memo(TOTPDialogComponent); +} diff --git a/app/tabs/dialogs/WarpgateDialog.tsx b/app/tabs/dialogs/WarpgateDialog.tsx new file mode 100644 index 0000000..0181370 --- /dev/null +++ b/app/tabs/dialogs/WarpgateDialog.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect, useCallback } from "react"; +import { View, Linking } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { Shield, Copy, Check, ExternalLink } from "lucide-react-native"; +import { Dialog, Button, Text, Input } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { showToast } from "@/app/utils/toast"; + +interface WarpgateDialogProps { + visible: boolean; + url: string; + securityKey: string; + onContinue: () => void; + onCancel: () => void; +} + +export function WarpgateDialog({ + visible, + url, + securityKey, + onContinue, + onCancel, +}: WarpgateDialogProps) { + const color = useThemeColor(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!visible) setCopied(false); + }, [visible]); + + const handleCopy = useCallback(async () => { + try { + await Clipboard.setStringAsync(url); + setCopied(true); + showToast("URL copied", "success"); + setTimeout(() => setCopied(false), 2000); + } catch { + showToast("Failed to copy", "error"); + } + }, [url]); + + const handleOpenBrowser = useCallback(() => { + if (url) Linking.openURL(url).catch(() => showToast("Could not open browser", "error")); + }, [url]); + + return ( + } + title="Warpgate Authentication" + description="Authenticate via the Warpgate portal, then tap Continue." + footer={ + + + + + + } + > + {/* Security key — displayed prominently */} + + + Security Key + + + + {securityKey} + + + + + {/* Auth URL */} + + + Auth URL + + + {copied ? ( + + ) : ( + + )} + + } + style={{ fontSize: 11 }} + /> + + + ); +} diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts index c395ab7..520d30f 100644 --- a/app/tabs/dialogs/index.ts +++ b/app/tabs/dialogs/index.ts @@ -1,3 +1,5 @@ export { TOTPDialog } from "./TOTPDialog"; export { SSHAuthDialog } from "./SSHAuthDialog"; export { HostKeyVerificationDialog } from "./HostKeyVerificationDialog"; +export { PassphraseDialog } from "./PassphraseDialog"; +export { WarpgateDialog } from "./WarpgateDialog"; diff --git a/app/tabs/hosts/CredentialForm.tsx b/app/tabs/hosts/CredentialForm.tsx new file mode 100644 index 0000000..413cd8a --- /dev/null +++ b/app/tabs/hosts/CredentialForm.tsx @@ -0,0 +1,618 @@ +import { useEffect, useState } from "react"; +import { Modal, View, ScrollView, Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X, Upload, Copy } from "lucide-react-native"; +import * as DocumentPicker from "expo-document-picker"; +import * as Clipboard from "expo-clipboard"; +import { Credential, CredentialData } from "@/types"; +import { + getCredentialDetails, + createCredential, + updateCredential, + generateKeyPair, + generatePublicKeyFromPrivate, +} from "@/app/main-axios"; +import { + Text, + Input, + Button, + Label, + SegmentedControl, + AccordionSection, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; + +type TabId = "general" | "auth"; +type GeneratingKey = "ed25519" | "ecdsa" | "rsa" | null; + +interface CredentialFormState { + name: string; + description: string; + folder: string; + tags: string; + authType: "password" | "key"; + username: string; + password: string; + key: string; + publicKey: string; + keyPassword: string; + certPublicKey: string; +} + +const EMPTY: CredentialFormState = { + name: "", + description: "", + folder: "", + tags: "", + authType: "password", + username: "", + password: "", + key: "", + publicKey: "", + keyPassword: "", + certPublicKey: "", +}; + +const TABS: { id: TabId; label: string }[] = [ + { id: "general", label: "General" }, + { id: "auth", label: "Auth" }, +]; + +export default function CredentialForm({ + visible, + credential, + onClose, + onSaved, +}: { + visible: boolean; + credential: Credential | null; + onClose: () => void; + onSaved: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("general"); + const [keyExistsOnServer, setKeyExistsOnServer] = useState(false); + const [generatingKey, setGeneratingKey] = useState(null); + const [generatingPublicKey, setGeneratingPublicKey] = useState(false); + const isEdit = !!credential; + + const set = ( + k: K, + v: CredentialFormState[K], + ) => setForm((f) => ({ ...f, [k]: v })); + + useEffect(() => { + if (!visible) return; + setActiveTab("general"); + setKeyExistsOnServer(false); + if (!credential) { + setForm(EMPTY); + return; + } + setForm({ + ...EMPTY, + name: credential.name ?? "", + description: credential.description ?? "", + folder: credential.folder ?? "", + tags: (credential.tags ?? []).join(", "), + authType: credential.authType ?? "password", + username: credential.username ?? "", + publicKey: credential.publicKey ?? "", + }); + getCredentialDetails(credential.id) + .then((details: any) => { + if (!details) return; + setForm((f) => ({ + ...f, + username: details.username ?? f.username, + password: details.password ?? "", + publicKey: details.publicKey ?? details.public_key ?? f.publicKey, + keyPassword: details.keyPassword ?? details.key_password ?? "", + certPublicKey: details.certPublicKey ?? "", + })); + const hasKey = !!(details.key || details.private_key || details.hasKey); + setKeyExistsOnServer(hasKey); + }) + .catch(() => {}); + }, [visible, credential]); + + const pickPrivateKeyFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets?.[0]) return; + const text = await fetch(result.assets[0].uri).then((r) => r.text()); + set("key", text.trim()); + setKeyExistsOnServer(false); + toast.success(`Loaded ${result.assets[0].name}`); + } catch { + toast.error("Could not read key file"); + } + }; + + const pickCertFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets?.[0]) return; + const text = await fetch(result.assets[0].uri).then((r) => r.text()); + set("certPublicKey", text.trim()); + toast.success(`Loaded ${result.assets[0].name}`); + } catch { + toast.error("Could not read certificate file"); + } + }; + + const handleGenerateKeyPair = async ( + keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", + keySize?: number, + label?: GeneratingKey, + ) => { + const id = label ?? "ed25519"; + setGeneratingKey(id); + try { + const result = await generateKeyPair( + keyType, + keySize, + form.keyPassword || undefined, + ); + const priv = result.privateKey ?? result.private_key ?? ""; + const pub = result.publicKey ?? result.public_key ?? ""; + setForm((f) => ({ ...f, key: priv, publicKey: pub })); + setKeyExistsOnServer(false); + toast.success("Key pair generated"); + } catch (e: any) { + toast.error(e?.message ?? "Key generation failed"); + } finally { + setGeneratingKey(null); + } + }; + + const handleGeneratePublicKey = async () => { + if (!form.key && !keyExistsOnServer) { + toast.error("Enter a private key first"); + return; + } + if (!form.key && keyExistsOnServer) { + toast.error("Save the credential first, then regenerate"); + return; + } + setGeneratingPublicKey(true); + try { + const result = await generatePublicKeyFromPrivate( + form.key, + form.keyPassword || undefined, + ); + set("publicKey", result.publicKey ?? result.public_key ?? ""); + toast.success("Public key generated"); + } catch (e: any) { + toast.error(e?.message ?? "Failed to generate public key"); + } finally { + setGeneratingPublicKey(false); + } + }; + + const handleSave = async () => { + if (!form.name.trim()) { + toast.error("Name is required"); + return; + } + + const payload: CredentialData = { + name: form.name.trim(), + description: form.description.trim() || undefined, + folder: form.folder.trim() || undefined, + tags: form.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + authType: form.authType, + username: form.username.trim(), + }; + + if (form.authType === "password") { + if (form.password) payload.password = form.password; + } else { + if (form.key) payload.key = form.key; + if (form.publicKey) payload.publicKey = form.publicKey; + if (form.keyPassword) payload.keyPassword = form.keyPassword; + if (form.certPublicKey) + (payload as any).certPublicKey = form.certPublicKey; + } + + setSaving(true); + try { + if (isEdit && credential) { + await updateCredential(credential.id, payload); + toast.success("Credential updated"); + } else { + await createCredential(payload); + toast.success("Credential created"); + } + onSaved(); + } catch (e: any) { + toast.error(e?.message || "Failed to save credential"); + } finally { + setSaving(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + {isEdit ? "Edit Credential" : "New Credential"} + + + + + {/* Tab strip */} + + + {TABS.map((tab) => { + const active = tab.id === activeTab; + return ( + setActiveTab(tab.id)} + className={`border px-3.5 py-1.5 ${ + active + ? "border-accent-brand/40 bg-accent-brand/10" + : "border-border active:bg-muted/40" + }`} + > + + {tab.label} + + + ); + })} + + + + + {/* General Tab */} + {activeTab === "general" ? ( +
+ + set("name", v)} + placeholder="e.g. Production SSH Key" + /> + + + set("description", v)} + placeholder="Optional details…" + /> + + + set("folder", v)} + placeholder="e.g. Server Keys" + /> + + + set("tags", v)} + placeholder="production, linux" + autoCapitalize="none" + /> + +
+ ) : null} + + {/* Auth Tab */} + {activeTab === "auth" ? ( + <> +
+ + value={form.authType} + onChange={(v) => set("authType", v)} + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + /> +
+ + {/* Password auth */} + {form.authType === "password" ? ( +
+ + set("username", v)} + placeholder="e.g. root or deploy" + autoCapitalize="none" + autoCorrect={false} + /> + + + set("password", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + +
+ ) : null} + + {/* SSH Key auth */} + {form.authType === "key" ? ( + <> +
+ + set("username", v)} + placeholder="e.g. root or deploy" + autoCapitalize="none" + autoCorrect={false} + /> + +
+ +
+ + + + Generate new key pair + + + Replaces current private and public key + + + + + + + + +
+ +
+ + + + + set("key", v)} + multiline + placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 100 }} + /> + {isEdit && keyExistsOnServer && form.key === "" ? ( + + Key saved — paste or upload to replace + + ) : null} +
+ +
+ + + + + + + + set("publicKey", v)} + multiline + placeholder="ssh-ed25519 AAAA…" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 60 }} + /> +
+ +
+ + set("keyPassword", v)} + secureTextEntry + placeholder="Passphrase (optional)" + autoCapitalize="none" + /> + +
+ + + + + + Certificate signed by a CA for certificate-based auth + + + + set("certPublicKey", v)} + multiline + placeholder="ssh-ed25519-cert-v01@openssh.com AAAA…" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 60 }} + /> + + + + ) : null} + + ) : null} +
+
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} diff --git a/app/tabs/hosts/CredentialListModal.tsx b/app/tabs/hosts/CredentialListModal.tsx new file mode 100644 index 0000000..c5c680e --- /dev/null +++ b/app/tabs/hosts/CredentialListModal.tsx @@ -0,0 +1,340 @@ +import { useEffect, useState, useMemo } from "react"; +import { + Modal, + View, + ScrollView, + Pressable, + ActivityIndicator, + RefreshControl, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + X, + Plus, + Search, + KeyRound, + Lock, + Pencil, + Trash2, + ChevronRight, +} from "lucide-react-native"; +import { Credential } from "@/types"; +import { getCredentials, deleteCredential } from "@/app/main-axios"; +import { + Text, + Input, + Button, + BottomSheet, + SheetRow, + Dialog, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import CredentialForm from "@/app/tabs/hosts/CredentialForm"; + +export default function CredentialListModal({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sheetCredential, setSheetCredential] = useState( + null, + ); + const [formCredential, setFormCredential] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = async (isRefresh = false) => { + if (isRefresh) setRefreshing(true); + else setLoading(true); + try { + const res = await getCredentials(); + const list: Credential[] = Array.isArray(res) + ? res + : (res?.credentials ?? []); + setCredentials(list); + } catch (e: any) { + toast.error(e?.message || "Failed to load credentials"); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + if (!visible) return; + setSearchQuery(""); + load(); + }, [visible]); + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return credentials; + return credentials.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.username?.toLowerCase().includes(q) || + c.folder?.toLowerCase().includes(q) || + c.tags?.some((t) => t.toLowerCase().includes(q)), + ); + }, [credentials, searchQuery]); + + const openCreate = () => { + setFormCredential(null); + setFormOpen(true); + }; + + const openEdit = (c: Credential) => { + setFormCredential(c); + setFormOpen(true); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await deleteCredential(deleteTarget.id); + toast.success(`Deleted "${deleteTarget.name}"`); + setDeleteTarget(null); + load(true); + } catch (e: any) { + toast.error(e?.message || "Failed to delete credential"); + } finally { + setDeleting(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + Credentials + + + + + {/* Search bar */} + + } + trailing={ + searchQuery ? ( + setSearchQuery("")} hitSlop={8}> + + + ) : undefined + } + /> + + + {/* List */} + {loading ? ( + + + + Loading credentials… + + + ) : ( + load(true)} + tintColor={color("accent-brand")} + /> + } + keyboardShouldPersistTaps="handled" + > + {filtered.length === 0 ? ( + + + {searchQuery.trim() + ? "No credentials match your search" + : "No credentials yet"} + + {!searchQuery.trim() ? ( + + ) : null} + + ) : ( + filtered.map((c) => ( + setSheetCredential(c)} + color={color} + /> + )) + )} + + )} + + + {/* Action bottom sheet */} + setSheetCredential(null)} + > + + + {sheetCredential?.name} + + + {sheetCredential?.authType === "key" ? "SSH Key" : "Password"} + {sheetCredential?.username ? ` · ${sheetCredential.username}` : ""} + + + } + label="Edit" + onPress={() => { + const c = sheetCredential!; + setSheetCredential(null); + openEdit(c); + }} + /> + } + label="Delete" + destructive + onPress={() => { + setDeleteTarget(sheetCredential); + setSheetCredential(null); + }} + /> + + + {/* Delete confirmation dialog */} + setDeleteTarget(null)} + title="Delete Credential" + description={`"${deleteTarget?.name}" will be permanently deleted. Hosts using it will lose their saved credentials.`} + icon={} + footer={ + <> + + + + } + /> + + {/* Create / edit form stacked on top */} + setFormOpen(false)} + onSaved={() => { + setFormOpen(false); + load(true); + }} + /> + + ); +} + +function CredentialRow({ + credential: c, + onPress, + color, +}: { + credential: Credential; + onPress: () => void; + color: (key: string) => string | undefined; +}) { + const subtitle = [c.username, c.folder].filter(Boolean).join(" · "); + + return ( + + + {c.authType === "key" ? ( + + ) : ( + + )} + + + + {c.name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {c.usageCount > 0 ? ( + + {c.usageCount} host{c.usageCount !== 1 ? "s" : ""} + + ) : null} + + + ); +} diff --git a/app/tabs/hosts/HostActionSheet.tsx b/app/tabs/hosts/HostActionSheet.tsx new file mode 100644 index 0000000..4b2f090 --- /dev/null +++ b/app/tabs/hosts/HostActionSheet.tsx @@ -0,0 +1,245 @@ +import { View, ScrollView } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { + Terminal, + FolderSearch, + Server, + Network, + Box, + Monitor, + MousePointerClick, + MessagesSquare, + Pencil, + Copy, + CopyPlus, + Trash2, + Zap, +} from "lucide-react-native"; +import { SSHHost } from "@/types"; +import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; +import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import type { SessionType } from "@/app/contexts/TerminalSessionsContext"; +import { BottomSheet, SheetRow, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; +import { wakeHost } from "@/app/main-axios"; + +function parseStatsConfig(host: SSHHost): StatsConfig { + try { + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } +} + +/** + * Whether the host speaks SSH. The redesigned multi-protocol backend sets + * `enableSsh`; legacy SSH-only hosts omit it, so undefined means SSH-enabled + * (matches the web's getSshActions gating). + */ +function isSshHost(host: SSHHost): boolean { + if (host.enableSsh != null) return host.enableSsh; + return !host.enableRdp && !host.enableVnc && !host.enableTelnet; +} + +export function HostActionSheet({ + host, + status, + metrics, + visible, + onClose, + onEdit, + onClone, + onDelete, +}: { + host: SSHHost | null; + status: "online" | "offline" | "unknown"; + metrics?: HostMetrics; + visible: boolean; + onClose: () => void; + onEdit: (host: SSHHost) => void; + onClone: (host: SSHHost) => void; + onDelete: (host: SSHHost) => void; +}) { + const { navigateToSessions } = useTerminalSessions(); + const color = useThemeColor(); + const iconColor = color("foreground") ?? "#fafafa"; + + if (!host) return null; + + const open = (type: SessionType) => { + navigateToSessions(host, type); + onClose(); + }; + + const copyAddress = async () => { + await Clipboard.setStringAsync( + `${host.username ? `${host.username}@` : ""}${host.ip}`, + ); + toast.success("Address copied"); + onClose(); + }; + + const handleWake = async () => { + onClose(); + try { + await wakeHost(host.id); + toast.success(`Wake-on-LAN sent to ${host.name}`); + } catch { + toast.error("Failed to send Wake-on-LAN packet"); + } + }; + + const ssh = isSshHost(host); + const metricsEnabled = ssh && parseStatsConfig(host).metricsEnabled !== false; + + // SSH-gated connection actions (mirrors the web's getSshActions). + const sshActions = [ + ssh && host.enableTerminal !== false + ? { type: "terminal" as SessionType, icon: Terminal, label: "Terminal" } + : null, + ssh && host.enableFileManager + ? { + type: "filemanager" as SessionType, + icon: FolderSearch, + label: "File Manager", + } + : null, + ssh && host.enableDocker + ? { type: "docker" as SessionType, icon: Box, label: "Docker" } + : null, + ssh && + host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 + ? { type: "tunnel" as SessionType, icon: Network, label: "Tunnels" } + : null, + metricsEnabled + ? { type: "stats" as SessionType, icon: Server, label: "Server Stats" } + : null, + ].filter(Boolean) as { + type: SessionType; + icon: typeof Terminal; + label: string; + }[]; + + // Separate protocol actions (RDP / VNC / Telnet), each with its own icon — + // matches the web rather than lumping them into one "Remote Desktop" row. + const protocolActions = [ + host.enableRdp ? { icon: Monitor, label: "RDP" } : null, + host.enableVnc ? { icon: MousePointerClick, label: "VNC" } : null, + host.enableTelnet ? { icon: MessagesSquare, label: "Telnet" } : null, + ].filter(Boolean) as { icon: typeof Monitor; label: string }[]; + + const dotColor = + status === "online" + ? "#22c55e" + : status === "offline" + ? "#ef4444" + : "#9ca3af"; + + return ( + + {/* Header */} + + + + + {host.name} + + + {host.username ? `${host.username}@` : ""} + {host.ip} + {host.port ? `:${host.port}` : ""} + + + {status === "online" && + metrics && + (metrics.cpu != null || metrics.ram != null) ? ( + + {metrics.cpu != null ? ( + + CPU {Math.round(metrics.cpu)}% + + ) : null} + {metrics.ram != null ? ( + + RAM {Math.round(metrics.ram)}% + + ) : null} + + ) : null} + + + + {/* Connection actions (SSH features + protocols) */} + {sshActions.map(({ type, icon: Icon, label }) => ( + } + label={label} + onPress={() => open(type)} + /> + ))} + {protocolActions.map(({ icon: Icon, label }) => ( + } + label={label} + onPress={() => open("remoteDesktop")} + /> + ))} + + {/* Management actions (no pin — matches the web). Rows sit flush with + the connection group; each row's own bottom border separates them. */} + {host.macAddress ? ( + } + label="Wake on LAN" + onPress={handleWake} + /> + ) : null} + } + label="Edit Host" + onPress={() => { + onEdit(host); + onClose(); + }} + /> + } + label="Copy Address" + onPress={copyAddress} + /> + } + label="Clone Host" + onPress={() => { + onClone(host); + onClose(); + }} + /> + } + label="Delete Host" + destructive + onPress={() => { + onDelete(host); + onClose(); + }} + /> + + + ); +} diff --git a/app/tabs/hosts/HostForm.tsx b/app/tabs/hosts/HostForm.tsx new file mode 100644 index 0000000..8f14cbd --- /dev/null +++ b/app/tabs/hosts/HostForm.tsx @@ -0,0 +1,811 @@ +import { useEffect, useMemo, useState } from "react"; +import { Modal, View, ScrollView, Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X, Upload } from "lucide-react-native"; +import * as DocumentPicker from "expo-document-picker"; +import { SSHHost, SSHHostData, Credential } from "@/types"; +import { + createSSHHost, + updateSSHHost, + getSSHHostWithCredentials, + getCredentials, +} from "@/app/main-axios"; +import { + Text, + Input, + Button, + Label, + FakeSwitch, + SettingRow, + SegmentedControl, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; + +type AuthType = "password" | "key" | "credential" | "none"; +type TabId = "general" | "ssh" | "rdp" | "vnc" | "telnet"; + +interface FormState { + name: string; + ip: string; + port: string; + // Protocols + enableSsh: boolean; + enableRdp: boolean; + enableVnc: boolean; + enableTelnet: boolean; + // SSH + username: string; + authType: AuthType; + password: string; + key: string; + keyPassword: string; + credentialId?: number; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + enableDocker: boolean; + defaultPath: string; + // RDP + rdpUser: string; + rdpPassword: string; + rdpDomain: string; + rdpPort: string; + // VNC + vncUser: string; + vncPassword: string; + vncPort: string; + // Telnet + telnetUser: string; + telnetPassword: string; + telnetPort: string; + // Organization + folder: string; + tags: string; + pin: boolean; + notes: string; +} + +const EMPTY: FormState = { + name: "", + ip: "", + port: "22", + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + username: "", + authType: "password", + password: "", + key: "", + keyPassword: "", + credentialId: undefined, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + enableDocker: false, + defaultPath: "/", + rdpUser: "Administrator", + rdpPassword: "", + rdpDomain: "", + rdpPort: "3389", + vncUser: "", + vncPassword: "", + vncPort: "5900", + telnetUser: "", + telnetPassword: "", + telnetPort: "23", + folder: "", + tags: "", + pin: false, + notes: "", +}; + +export default function HostForm({ + visible, + host, + onClose, + onSaved, +}: { + visible: boolean; + host: SSHHost | null; + onClose: () => void; + onSaved: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const [form, setForm] = useState(EMPTY); + const [credentials, setCredentials] = useState([]); + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("general"); + const isEdit = !!host; + + const set = (k: K, v: FormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + // Load credentials list for the credential picker. + useEffect(() => { + if (!visible) return; + getCredentials() + .then((res) => { + const list = Array.isArray(res) ? res : (res?.credentials ?? []); + setCredentials(list); + }) + .catch(() => {}); + }, [visible]); + + // Populate the form when opening (prefill secrets on edit) and reset the tab. + useEffect(() => { + if (!visible) return; + setActiveTab("general"); + if (!host) { + setForm(EMPTY); + return; + } + // Start from the known fields, then enrich with resolved secrets. + setForm({ + ...EMPTY, + name: host.name ?? "", + ip: host.ip ?? "", + port: String(host.port ?? host.sshPort ?? 22), + enableSsh: host.enableSsh !== false, + enableRdp: !!host.enableRdp, + enableVnc: !!host.enableVnc, + enableTelnet: !!host.enableTelnet, + username: host.username ?? "", + authType: host.authType ?? "password", + credentialId: host.credentialId, + enableTerminal: host.enableTerminal !== false, + enableTunnel: !!host.enableTunnel, + enableFileManager: !!host.enableFileManager, + enableDocker: !!host.enableDocker, + defaultPath: host.defaultPath || "/", + rdpUser: host.rdpUser ?? "Administrator", + rdpDomain: host.rdpDomain ?? "", + rdpPort: String(host.rdpPort ?? 3389), + vncUser: host.vncUser ?? "", + vncPort: String(host.vncPort ?? 5900), + telnetUser: host.telnetUser ?? "", + telnetPort: String(host.telnetPort ?? 23), + folder: host.folder ?? "", + tags: (host.tags ?? []).join(", "), + pin: !!host.pin, + notes: host.notes ?? "", + }); + getSSHHostWithCredentials(host.id) + .then((full) => { + if (!full) return; + setForm((f) => ({ + ...f, + password: full.password ?? "", + key: full.key ?? "", + keyPassword: full.keyPassword ?? "", + rdpPassword: full.rdpPassword ?? "", + vncPassword: full.vncPassword ?? "", + telnetPassword: full.telnetPassword ?? "", + })); + }) + .catch(() => {}); + }, [visible, host]); + + // The tab strip: General is always present; protocol tabs follow their toggle. + const tabs = useMemo<{ id: TabId; label: string }[]>(() => { + const list: { id: TabId; label: string }[] = [ + { id: "general", label: "General" }, + ]; + if (form.enableSsh) list.push({ id: "ssh", label: "SSH" }); + if (form.enableRdp) list.push({ id: "rdp", label: "RDP" }); + if (form.enableVnc) list.push({ id: "vnc", label: "VNC" }); + if (form.enableTelnet) list.push({ id: "telnet", label: "Telnet" }); + return list; + }, [form.enableSsh, form.enableRdp, form.enableVnc, form.enableTelnet]); + + // If the active tab's protocol gets disabled, fall back to General. + useEffect(() => { + if (!tabs.some((t) => t.id === activeTab)) setActiveTab("general"); + }, [tabs, activeTab]); + + const pickKeyFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + const text = await fetch(asset.uri).then((r) => r.text()); + set("key", text); + toast.success(`Loaded ${asset.name}`); + } catch { + toast.error("Could not read key file"); + } + }; + + const handleSave = async () => { + if (!form.ip.trim()) { + toast.error("Host address is required"); + return; + } + if ( + !form.enableSsh && + !form.enableRdp && + !form.enableVnc && + !form.enableTelnet + ) { + toast.error("Enable at least one protocol"); + return; + } + if (form.enableSsh && form.authType !== "none" && !form.username.trim()) { + toast.error("SSH username is required"); + return; + } + + const payload: SSHHostData = { + name: form.name.trim() || form.ip.trim(), + ip: form.ip.trim(), + port: parseInt(form.port, 10) || 22, + username: form.username.trim(), + folder: form.folder.trim(), + tags: form.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + pin: form.pin, + authType: form.authType, + password: form.authType === "password" ? form.password : undefined, + key: form.authType === "key" ? (form.key as any) : undefined, + keyPassword: form.authType === "key" ? form.keyPassword : undefined, + credentialId: + form.authType === "credential" ? form.credentialId : undefined, + enableTerminal: form.enableTerminal, + enableTunnel: form.enableTunnel, + enableFileManager: form.enableFileManager, + enableDocker: form.enableDocker, + defaultPath: form.defaultPath, + jumpHosts: host?.jumpHosts ?? [], + notes: form.notes, + enableSsh: form.enableSsh, + enableRdp: form.enableRdp, + enableVnc: form.enableVnc, + enableTelnet: form.enableTelnet, + // Protocol fields — the backend null-guards these by their enable flag. + rdpUser: form.rdpUser, + rdpPassword: form.rdpPassword, + rdpDomain: form.rdpDomain, + rdpPort: parseInt(form.rdpPort, 10) || 3389, + vncUser: form.vncUser, + vncPassword: form.vncPassword, + vncPort: parseInt(form.vncPort, 10) || 5900, + telnetUser: form.telnetUser, + telnetPassword: form.telnetPassword, + telnetPort: parseInt(form.telnetPort, 10) || 23, + }; + + setSaving(true); + try { + if (isEdit && host) { + await updateSSHHost(host.id, payload); + toast.success("Host updated"); + } else { + await createSSHHost(payload); + toast.success("Host created"); + } + onSaved(); + } catch (e: any) { + toast.error(e?.message || "Failed to save host"); + } finally { + setSaving(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + {isEdit ? "Edit Host" : "New Host"} + + + + + {/* Tab strip */} + + + {tabs.map((tab) => { + const active = tab.id === activeTab; + return ( + setActiveTab(tab.id)} + className={`border px-3.5 py-1.5 ${ + active + ? "border-accent-brand/40 bg-accent-brand/10" + : "border-border active:bg-muted/40" + }`} + > + + {tab.label} + + + ); + })} + + + + + {activeTab === "general" ? ( + <> + {/* Connection */} +
+ + set("name", v)} + placeholder="My Server" + /> + + + + + set("ip", v)} + placeholder="192.168.1.10" + autoCapitalize="none" + autoCorrect={false} + /> + + + + + set("port", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="22" + /> + + + +
+ + {/* Protocols */} +
+ + + set("enableSsh", v)} + /> + + + set("enableRdp", v)} + /> + + + set("enableVnc", v)} + /> + + + set("enableTelnet", v)} + /> + + + + Enable a protocol to configure it in its own tab above. + +
+ + {/* Organization */} +
+ + set("folder", v)} + placeholder="Production" + /> + + + set("tags", v)} + placeholder="web, nginx" + autoCapitalize="none" + /> + + + set("pin", v)} + /> + +
+ + {/* Notes */} +
+ set("notes", v)} + multiline + placeholder="Notes about this host…" + style={{ minHeight: 70 }} + /> +
+ + ) : null} + + {activeTab === "ssh" ? ( + <> +
+ + set("username", v)} + placeholder="root" + autoCapitalize="none" + autoCorrect={false} + /> + + + value={form.authType} + onChange={(v) => set("authType", v)} + options={[ + { id: "password", label: "Pass" }, + { id: "key", label: "Key" }, + { id: "credential", label: "Cred" }, + { id: "none", label: "None" }, + ]} + /> + + {form.authType === "password" ? ( + + set("password", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + ) : null} + + {form.authType === "key" ? ( + <> + + + + + set("key", v)} + multiline + placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 100 }} + /> + + set("keyPassword", v)} + secureTextEntry + placeholder="Passphrase" + autoCapitalize="none" + /> + + + ) : null} + + {form.authType === "credential" ? ( + + {credentials.length === 0 ? ( + + No saved credentials. Tap the key icon in Hosts to add + one. + + ) : ( + + {credentials.map((c) => { + const selected = form.credentialId === c.id; + return ( + set("credentialId", c.id)} + className={`border px-3 py-2.5 ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} + > + + {c.name} + + {c.username ? ( + + {c.username} + + ) : null} + + ); + })} + + )} + + ) : null} +
+ +
+ + + set("enableTerminal", v)} + /> + + + set("enableFileManager", v)} + /> + + + set("enableTunnel", v)} + /> + + + set("enableDocker", v)} + /> + + + {form.enableFileManager ? ( + + set("defaultPath", v)} + placeholder="/" + autoCapitalize="none" + /> + + ) : null} +
+ + ) : null} + + {activeTab === "rdp" ? ( +
+ + set("rdpUser", v)} + placeholder="Administrator" + autoCapitalize="none" + autoCorrect={false} + /> + + + set("rdpPassword", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + + + + set("rdpDomain", v)} + placeholder="WORKGROUP" + autoCapitalize="none" + /> + + + + + set("rdpPort", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="3389" + /> + + + + +
+ ) : null} + + {activeTab === "vnc" ? ( +
+ + set("vncUser", v)} + placeholder="vnc" + autoCapitalize="none" + autoCorrect={false} + /> + + + + + set("vncPassword", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + + + + set("vncPort", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="5900" + /> + + + + +
+ ) : null} + + {activeTab === "telnet" ? ( +
+ + set("telnetUser", v)} + placeholder="admin" + autoCapitalize="none" + autoCorrect={false} + /> + + + + + set("telnetPassword", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + + + + + set("telnetPort", v.replace(/\D/g, "")) + } + keyboardType="number-pad" + placeholder="23" + /> + + + + +
+ ) : null} +
+
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +function AdvancedNote() { + return ( + + Advanced display, audio, and clipboard settings are available in the + Termix web app. + + ); +} diff --git a/app/tabs/hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx index c5ea5d4..bc23c40 100644 --- a/app/tabs/hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -1,161 +1,273 @@ import { ScrollView, - Text, - TextInput, View, ActivityIndicator, - Alert, - TouchableOpacity, RefreshControl, + Pressable, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import { useFocusEffect } from "@react-navigation/native"; -import { RefreshCw } from "lucide-react-native"; -import Folder from "@/app/tabs/hosts/navigation/Folder"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + RefreshCw, + Search, + X, + ArrowUpDown, + Filter, + Check, + Zap, + Plus, + KeyRound, +} from "lucide-react-native"; +import HostTree from "@/app/tabs/hosts/navigation/Folder"; +import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; +import { HostActionSheet } from "@/app/tabs/hosts/HostActionSheet"; +import HostForm from "@/app/tabs/hosts/HostForm"; +import { QuickConnect } from "@/app/tabs/hosts/QuickConnect"; +import CredentialListModal from "@/app/tabs/hosts/CredentialListModal"; import { getSSHHosts, getFoldersWithStats, getAllServerStatuses, + getServerMetricsById, initializeServerConfig, getCurrentServerUrl, + deleteSSHHost, + createSSHHost, } from "@/app/main-axios"; import { SSHHost, ServerStatus } from "@/types"; -import { useOrientation } from "@/app/utils/orientation"; -import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; - -interface FolderData { - name: string; - hosts: SSHHost[]; - stats?: { - totalHosts: number; - hostsByType: { - type: string; - count: number; - }[]; - }; -} +import { Screen } from "@/app/components/Screen"; +import { + Text, + Input, + Button, + BottomSheet, + SheetRow, + Checkbox, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { + SortKey, + FilterState, + DEFAULT_FILTERS, + HostStatus, + buildHostTree, + sortHostTree, + applyFilters, + filterTreeByQuery, + collectFolderPaths, + collectMatchingFolderPaths, + collectAllTags, + filtersActive, +} from "@/app/tabs/hosts/navigation/hostTree"; + +const SORT_OPTIONS: { id: SortKey; label: string }[] = [ + { id: "default", label: "Default" }, + { id: "name-asc", label: "Name (A–Z)" }, + { id: "name-desc", label: "Name (Z–A)" }, + { id: "ip-asc", label: "IP (ascending)" }, + { id: "ip-desc", label: "IP (descending)" }, + { id: "status-online", label: "Online first" }, + { id: "status-offline", label: "Offline first" }, + { id: "pinned", label: "Pinned first" }, +]; + +const FILTER_GROUPS: { + group: keyof FilterState; + title: string; + options: { value: string; label: string }[]; +}[] = [ + { + group: "status", + title: "Status", + options: [ + { value: "online", label: "Online" }, + { value: "offline", label: "Offline" }, + { value: "pinned", label: "Pinned" }, + ], + }, + { + group: "protocol", + title: "Protocol", + options: [ + { value: "ssh", label: "SSH" }, + { value: "rdp", label: "RDP" }, + { value: "vnc", label: "VNC" }, + { value: "telnet", label: "Telnet" }, + ], + }, + { + group: "features", + title: "Features", + options: [ + { value: "terminal", label: "Terminal" }, + { value: "fileManager", label: "File Manager" }, + { value: "tunnel", label: "Tunnel" }, + { value: "docker", label: "Docker" }, + ], + }, +]; + +const STORAGE_SORT = "hostSortKey"; +const STORAGE_FILTER = "hostFilterState"; +const STORAGE_EXPANDED = "hostExpandedFolders"; export default function Hosts() { - const insets = useSafeAreaInsets(); - const { width, isLandscape } = useOrientation(); - const [folders, setFolders] = useState([]); + const color = useThemeColor(); + const [hosts, setHosts] = useState([]); + const [folderColors, setFolderColors] = useState< + Record + >({}); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [serverStatuses, setServerStatuses] = useState< Record >({}); - const isRefreshingRef = useRef(false); - - const padding = getResponsivePadding(isLandscape); - const columnCount = getColumnCount(width, isLandscape, 400); + const [metrics, setMetrics] = useState>({}); + const [sortKey, setSortKey] = useState("default"); + const [filterState, setFilterState] = useState(DEFAULT_FILTERS); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [prefsLoaded, setPrefsLoaded] = useState(false); + const [showSort, setShowSort] = useState(false); + const [showFilter, setShowFilter] = useState(false); - const fetchData = useCallback(async (isRefresh = false) => { - if (isRefreshingRef.current) return; + // Action sheet + form state + const [sheetHost, setSheetHost] = useState(null); + const [formHost, setFormHost] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [quickConnectOpen, setQuickConnectOpen] = useState(false); + const [credentialListOpen, setCredentialListOpen] = useState(false); - try { - isRefreshingRef.current = true; + const isRefreshingRef = useRef(false); + // Tracks whether the user has manually toggled folders this session, so a data + // refresh doesn't blow away their expansion choices. + const expansionInitializedRef = useRef(false); - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); + // --- Load persisted preferences once on mount. + useEffect(() => { + (async () => { + try { + const [savedSort, savedFilter, savedExpanded] = await Promise.all([ + AsyncStorage.getItem(STORAGE_SORT), + AsyncStorage.getItem(STORAGE_FILTER), + AsyncStorage.getItem(STORAGE_EXPANDED), + ]); + if (savedSort) setSortKey(savedSort as SortKey); + if (savedFilter) setFilterState(JSON.parse(savedFilter) as FilterState); + if (savedExpanded) { + setExpandedPaths(new Set(JSON.parse(savedExpanded) as string[])); + expansionInitializedRef.current = true; + } + } catch { + // best-effort + } finally { + setPrefsLoaded(true); } + })(); + }, []); - await initializeServerConfig(); - - const currentServerUrl = getCurrentServerUrl(); + const getHostStatus = useCallback( + (hostId: number): HostStatus => serverStatuses[hostId]?.status ?? "unknown", + [serverStatuses], + ); - if (!currentServerUrl) { - Alert.alert( - "No Server Configured", - "Please configure a server first in the settings.", - ); + // Fetch CPU/RAM for online hosts. Never throws; failures are ignored so the + // list always renders. + const fetchMetrics = useCallback( + async (hostList: SSHHost[], statuses: Record) => { + const onlineIds = hostList + .filter((h) => statuses[h.id]?.status === "online") + .map((h) => h.id); + if (onlineIds.length === 0) { + setMetrics({}); return; } + const results = await Promise.allSettled( + onlineIds.map((id) => getServerMetricsById(id)), + ); + const next: Record = {}; + results.forEach((res, i) => { + if (res.status === "fulfilled" && res.value) { + next[onlineIds[i]] = { + cpu: res.value.cpu?.percent ?? null, + ram: res.value.memory?.percent ?? null, + }; + } + }); + setMetrics(next); + }, + [], + ); - const [hostsResult, statusesResult] = await Promise.allSettled([ - getSSHHosts(), - getAllServerStatuses(), - ]); - - if (hostsResult.status !== "fulfilled") { - throw hostsResult.reason; - } + const fetchData = useCallback( + async (isRefresh = false) => { + if (isRefreshingRef.current) return; + try { + isRefreshingRef.current = true; + if (isRefresh) setRefreshing(true); + else setLoading(true); - const hostsRaw = hostsResult.value; - const hosts: SSHHost[] = Array.isArray(hostsRaw) - ? hostsRaw - : Array.isArray((hostsRaw as any)?.hosts) - ? (hostsRaw as any).hosts - : []; - const statuses = - statusesResult.status === "fulfilled" ? statusesResult.value : {}; + await initializeServerConfig(); + if (!getCurrentServerUrl()) { + toast.error("No server configured. Set one up in Settings."); + return; + } - let foldersData = null; - try { - foldersData = await getFoldersWithStats(); - } catch (error) {} + const [hostsResult, statusesResult] = await Promise.allSettled([ + getSSHHosts(), + getAllServerStatuses(), + ]); - const folderMap = new Map(); + if (hostsResult.status !== "fulfilled") throw hostsResult.reason; - if (foldersData && Array.isArray(foldersData)) { - foldersData.forEach((folder: any) => { - folderMap.set(folder.name, { - name: folder.name, - hosts: [], - stats: folder.stats, - }); - }); - } + const raw = hostsResult.value as any; + const hostList: SSHHost[] = Array.isArray(raw) + ? raw + : Array.isArray(raw?.hosts) + ? raw.hosts + : []; + const statuses = + statusesResult.status === "fulfilled" ? statusesResult.value : {}; - hosts.filter((host: SSHHost) => !host.connectionType || host.connectionType === "ssh").forEach((host: SSHHost) => { - const folderName = host.folder || "No Folder"; - if (!folderMap.has(folderName)) { - folderMap.set(folderName, { - name: folderName, - hosts: [], - stats: { totalHosts: 0, hostsByType: [] }, + let foldersData: any = null; + try { + foldersData = await getFoldersWithStats(); + } catch { + // folders are optional + } + const colors: Record = {}; + if (Array.isArray(foldersData)) { + foldersData.forEach((f: any) => { + if (f?.name) colors[f.name] = f.color; }); } - folderMap.get(folderName)!.hosts.push(host); - }); - const foldersArray = Array.from(folderMap.values()).sort((a, b) => { - if (a.name === "No Folder") return 1; - if (b.name === "No Folder") return -1; - return a.name.localeCompare(b.name); - }); + setHosts(hostList); + setFolderColors(colors); + setServerStatuses(statuses); - setFolders(foldersArray); - setServerStatuses(statuses); - } catch (error: any) { - console.error("[Hosts] Error loading hosts:", error); - - const isAuthError = - error?.response?.status === 401 || - error?.status === 401 || - error?.message?.includes("Authentication required"); - - if (isAuthError) { - } else { - const errorMessage = - error?.message || - "Failed to load hosts. Please check your connection and try again."; - Alert.alert("Error Loading Hosts", errorMessage); + // Best-effort live metrics for online hosts only. + void fetchMetrics(hostList, statuses); + } catch (error: any) { + const isAuth = + error?.response?.status === 401 || + error?.message?.includes("Authentication required"); + if (!isAuth) { + toast.error(error?.message || "Failed to load hosts."); + } + } finally { + setLoading(false); + setRefreshing(false); + isRefreshingRef.current = false; } - } finally { - setLoading(false); - setRefreshing(false); - isRefreshingRef.current = false; - } - }, []); + }, + [fetchMetrics], + ); const handleRefresh = useCallback(() => { - if (!isRefreshingRef.current) { - fetchData(true); - } + if (!isRefreshingRef.current) fetchData(true); }, [fetchData]); useFocusEffect( @@ -164,108 +276,436 @@ export default function Hosts() { }, [fetchData]), ); - const filteredFolders = folders - .map((folder) => ({ - ...folder, - hosts: folder.hosts.filter( - (host) => - host.name.toLowerCase().includes(searchQuery.toLowerCase()) || - host.ip.toLowerCase().includes(searchQuery.toLowerCase()) || - host.username.toLowerCase().includes(searchQuery.toLowerCase()), - ), - })) - .filter((folder) => folder.hosts.length > 0 || searchQuery === ""); - - const getHostStatus = (hostId: number): "online" | "offline" | "unknown" => { - const status = serverStatuses[hostId]; - if (!status) return "unknown"; - return status.status; - }; + // --- Build, sort, and filter the tree. + const tree = useMemo( + () => buildHostTree(hosts, folderColors), + [hosts, folderColors], + ); - if (loading) { - return ( - - - Loading hosts... - + const q = searchQuery.trim().toLowerCase(); + + const visibleTree = useMemo(() => { + let nodes = applyFilters(tree, filterState, getHostStatus); + nodes = filterTreeByQuery(nodes, q); + nodes = sortHostTree(nodes, sortKey, getHostStatus); + return nodes; + }, [tree, filterState, q, sortKey, getHostStatus]); + + // --- Default expansion: expand all folders the first time hosts load. + useEffect(() => { + if (!prefsLoaded || expansionInitializedRef.current) return; + if (tree.length === 0) return; + setExpandedPaths(new Set(collectFolderPaths(tree))); + expansionInitializedRef.current = true; + }, [prefsLoaded, tree]); + + // When searching, force-expand matching folders without touching the + // persisted set. + const effectiveExpanded = useMemo(() => { + if (!q) return expandedPaths; + const next = new Set(expandedPaths); + collectMatchingFolderPaths(tree, q).forEach((p) => next.add(p)); + return next; + }, [q, expandedPaths, tree]); + + const toggleFolder = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + AsyncStorage.setItem(STORAGE_EXPANDED, JSON.stringify([...next])).catch( + () => {}, + ); + return next; + }); + }, []); + + const handleSortChange = useCallback((key: SortKey) => { + setSortKey(key); + AsyncStorage.setItem(STORAGE_SORT, key).catch(() => {}); + setShowSort(false); + }, []); + + const toggleFilter = useCallback( + (group: keyof FilterState, value: string) => { + setFilterState((prev) => { + const arr = prev[group] as string[]; + const nextArr = arr.includes(value) + ? arr.filter((v) => v !== value) + : [...arr, value]; + const updated = { ...prev, [group]: nextArr } as FilterState; + AsyncStorage.setItem(STORAGE_FILTER, JSON.stringify(updated)).catch( + () => {}, + ); + return updated; + }); + }, + [], + ); + + const clearFilters = useCallback(() => { + setFilterState(DEFAULT_FILTERS); + AsyncStorage.setItem(STORAGE_FILTER, JSON.stringify(DEFAULT_FILTERS)).catch( + () => {}, ); - } + }, []); + + const getHostMetrics = useCallback( + (hostId: number): HostMetrics | undefined => metrics[hostId], + [metrics], + ); + + const allTags = useMemo(() => collectAllTags(hosts), [hosts]); + const isFilterActive = filtersActive(filterState); + + const handleDelete = async (host: SSHHost) => { + try { + await deleteSSHHost(host.id); + toast.success(`Deleted ${host.name}`); + fetchData(true); + } catch (e: any) { + toast.error(e?.message || "Failed to delete host"); + } + }; + + const handleClone = async (host: SSHHost) => { + try { + await createSSHHost({ + name: `${host.name} (copy)`, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: host.tags ?? [], + pin: false, + authType: host.authType, + password: host.password, + keyPassword: host.keyPassword, + keyType: host.keyType, + credentialId: host.credentialId ?? null, + overrideCredentialUsername: host.overrideCredentialUsername, + enableSsh: host.enableSsh, + enableRdp: host.enableRdp, + enableVnc: host.enableVnc, + enableTelnet: host.enableTelnet, + enableTerminal: host.enableTerminal, + enableTunnel: host.enableTunnel, + enableFileManager: host.enableFileManager, + enableDocker: host.enableDocker, + defaultPath: host.defaultPath ?? "/", + jumpHosts: host.jumpHosts ?? [], + forceKeyboardInteractive: host.forceKeyboardInteractive, + tunnelConnections: host.tunnelConnections ?? [], + notes: host.notes, + rdpUser: host.rdpUser, + rdpPassword: host.rdpPassword, + rdpDomain: host.rdpDomain, + rdpPort: host.rdpPort, + vncUser: host.vncUser, + vncPassword: host.vncPassword, + vncPort: host.vncPort, + telnetUser: host.telnetUser, + telnetPassword: host.telnetPassword, + telnetPort: host.telnetPort, + statsConfig: host.statsConfig, + terminalConfig: host.terminalConfig, + } as any); + toast.success(`Cloned ${host.name}`); + fetchData(true); + } catch (e: any) { + toast.error(e?.message || "Failed to clone host"); + } + }; + + const openEdit = (host: SSHHost) => { + setFormHost(host); + setFormOpen(true); + }; + + const openCreate = () => { + setFormHost(null); + setFormOpen(true); + }; + + const isEmpty = visibleTree.length === 0; return ( - - - - - Hosts - - + + ) : null} ) : ( - filteredFolders.map((folder, index) => ( - - )) + )}
-
-
+ )} + + {/* Action sheet on host tap */} + setSheetHost(null)} + onEdit={openEdit} + onClone={handleClone} + onDelete={handleDelete} + /> + + {/* Sort menu */} + setShowSort(false)} + title="Sort hosts" + scroll + > + {SORT_OPTIONS.map((opt) => ( + handleSortChange(opt.id)} + trailing={ + sortKey === opt.id ? ( + + ) : undefined + } + /> + ))} + + + {/* Filter menu */} + setShowFilter(false)} + title="Filter hosts" + scroll + > + {isFilterActive ? ( + } + label="Clear all filters" + onPress={clearFilters} + /> + ) : null} + {FILTER_GROUPS.map((grp) => ( + + + + {grp.title} + + + {grp.options.map((opt) => { + const checked = (filterState[grp.group] as string[]).includes( + opt.value, + ); + return ( + toggleFilter(grp.group, opt.value)} + className="flex-row items-center gap-3 border-b border-border/60 px-4 py-3 active:bg-muted/40" + > + toggleFilter(grp.group, opt.value)} + /> + + {opt.label} + + + ); + })} + + ))} + {allTags.length > 0 ? ( + + + + Tags + + + {allTags.map((tag) => { + const checked = filterState.tags.includes(tag); + return ( + toggleFilter("tags", tag)} + className="flex-row items-center gap-3 border-b border-border/60 px-4 py-3 active:bg-muted/40" + > + toggleFilter("tags", tag)} + /> + + {tag} + + + ); + })} + + ) : null} + + + {/* Create / edit form */} + setFormOpen(false)} + onSaved={() => { + setFormOpen(false); + fetchData(true); + }} + /> + + {/* Quick connect (ad-hoc, unsaved) */} + setQuickConnectOpen(false)} + /> + + {/* Credential manager */} + setCredentialListOpen(false)} + /> + ); } diff --git a/app/tabs/hosts/QuickConnect.tsx b/app/tabs/hosts/QuickConnect.tsx new file mode 100644 index 0000000..8fb4fc5 --- /dev/null +++ b/app/tabs/hosts/QuickConnect.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { View, ScrollView, KeyboardAvoidingView, Platform } from "react-native"; +import { Zap } from "lucide-react-native"; +import { SSHHost } from "@/types"; +import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import { + BottomSheet, + Input, + Button, + Label, + SegmentedControl, + Text, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; + +type AuthType = "password" | "key"; + +/** + * Quick Connect — open an ad-hoc SSH terminal without saving the host + * (Support issue #448). Builds a transient SSHHost (negative id) and starts a + * terminal session directly. + */ +export function QuickConnect({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const color = useThemeColor(); + const { navigateToSessions } = useTerminalSessions(); + const [ip, setIp] = useState(""); + const [port, setPort] = useState("22"); + const [username, setUsername] = useState(""); + const [authType, setAuthType] = useState("password"); + const [password, setPassword] = useState(""); + const [key, setKey] = useState(""); + + const reset = () => { + setIp(""); + setPort("22"); + setUsername(""); + setAuthType("password"); + setPassword(""); + setKey(""); + }; + + const connect = () => { + if (!ip.trim() || !username.trim()) { + toast.error("Host and username are required"); + return; + } + const transient: SSHHost = { + id: -Date.now(), // negative = unsaved/transient + name: `${username.trim()}@${ip.trim()}`, + ip: ip.trim(), + port: parseInt(port, 10) || 22, + username: username.trim(), + folder: "", + tags: [], + pin: false, + authType, + password: authType === "password" ? password : undefined, + key: authType === "key" ? key : undefined, + enableTerminal: true, + enableTunnel: false, + enableFileManager: false, + defaultPath: "/", + tunnelConnections: [], + createdAt: "", + updatedAt: "", + }; + onClose(); + reset(); + navigateToSessions(transient, "terminal"); + }; + + return ( + + + {/* Header */} + + + + + + + Quick Connect + + + Connect to a host without saving it. + + + + + + + + + + + + + setPort(v.replace(/\D/g, ""))} + keyboardType="number-pad" + /> + + + + + + + + + + value={authType} + onChange={setAuthType} + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "Key" }, + ]} + /> + + {authType === "password" ? ( + + + + + ) : ( + + + + + )} + + {/* Actions */} + + + + + + + + ); +} diff --git a/app/tabs/hosts/navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx index f47802e..e0e97f6 100644 --- a/app/tabs/hosts/navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -1,87 +1,139 @@ -import { View, Text, TouchableOpacity, Animated } from "react-native"; -import Host from "@/app/tabs/hosts/navigation/Host"; -import { ChevronDown } from "lucide-react-native"; -import { useState, useRef, useEffect } from "react"; -import { SSHHost } from "@/types"; +import { View, Pressable } from "react-native"; +import { ChevronRight, Folder as FolderIcon } from "lucide-react-native"; +import { SSHHost, HostTreeNode } from "@/types"; +import Host, { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + GetHostStatus, + folderCounts, + isFolder, +} from "@/app/tabs/hosts/navigation/hostTree"; -interface FolderProps { - name: string; - hosts: SSHHost[]; - getHostStatus: (hostId: number) => "online" | "offline" | "unknown"; +interface SharedProps { + expandedPaths: Set; + onToggle: (path: string) => void; + getHostStatus: GetHostStatus; + getHostMetrics?: (hostId: number) => HostMetrics | undefined; + showTags?: boolean; + onHostPress: (host: SSHHost) => void; } -export default function Folder({ name, hosts, getHostStatus }: FolderProps) { - const [isExpanded, setIsExpanded] = useState(true); - const rotateValue = useRef(new Animated.Value(0)).current; +/** Mutable counter so striping is continuous across the whole flattened tree. */ +type StripeCounter = { i: number }; - const toggleExpanded = () => { - setIsExpanded(!isExpanded); - }; +function TreeNode({ + node, + depth, + stripe, + shared, +}: { + node: HostTreeNode; + depth: number; + stripe: StripeCounter; + shared: SharedProps; +}) { + const color = useThemeColor(); + const muted = color("muted-foreground") ?? "#9ca3af"; + const accent = color("accent-brand") ?? "#f59145"; - useEffect(() => { - Animated.timing(rotateValue, { - toValue: isExpanded ? 0 : 1, - duration: 200, - useNativeDriver: true, - }).start(); - }, [isExpanded, rotateValue]); + if (!isFolder(node)) { + const striped = stripe.i++ % 2 === 1; + return ( + + ); + } - const rotate = rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ["0deg", "180deg"], - }); + const isOpen = shared.expandedPaths.has(node.path); + const { total, online } = folderCounts(node, shared.getHostStatus); + const folderStriped = stripe.i++ % 2 === 1; return ( - - + shared.onToggle(node.path)} + className={`flex-row items-center gap-2 px-2 py-2 active:bg-muted/40 ${ + folderStriped ? "bg-muted/20" : "" + }`} > - - - - {name} + + + + + {node.name} + + + {online > 0 ? ( + + {online} - - {hosts.length} host{hosts.length !== 1 ? "s" : ""} + ) : null} + /{total} + + + + {isOpen ? ( + + {node.children.length === 0 ? ( + + No hosts in this folder - - - - - - - - - {isExpanded && ( - - {hosts.length === 0 ? ( - - - No hosts in this folder - - ) : ( - hosts.map((host, index) => ( - - - + node.children.map((child) => ( + )) )} - )} + ) : null} + + ); +} + +/** + * Renders a list of host-tree nodes (folders nest recursively, hosts are + * leaves). Expansion state is controlled by the parent so it can be persisted. + */ +export default function HostTree({ + nodes, + ...shared +}: { nodes: HostTreeNode[] } & SharedProps) { + const stripe: StripeCounter = { i: 0 }; + return ( + + {nodes.map((node) => ( + + ))} ); } diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 8960c49..a6fef69 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -1,473 +1,194 @@ -import { - View, - Text, - TouchableOpacity, - Modal, - TouchableWithoutFeedback, - Animated, - Easing, - ScrollView, -} from "react-native"; -import { - Terminal, - Server, - FolderOpen, - Key, - Lock, - MoreVertical, - X, - Activity, -} from "lucide-react-native"; +import { View, Pressable } from "react-native"; +import { Pin, Cpu, MemoryStick } from "lucide-react-native"; import { SSHHost } from "@/types"; -import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; -import { useEffect, useRef, useState } from "react"; -import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import type { HostStatus } from "@/app/tabs/hosts/navigation/hostTree"; + +export interface HostMetrics { + cpu: number | null; + ram: number | null; +} interface HostProps { host: SSHHost; - status: "online" | "offline" | "unknown"; - isLast?: boolean; + status: HostStatus; + metrics?: HostMetrics; + showTags?: boolean; + /** Even rows get a subtle striped background (matches the web density). */ + striped?: boolean; + onPress: (host: SSHHost) => void; } -function Host({ host, status, isLast = false }: HostProps) { - const { navigateToSessions } = useTerminalSessions(); - const insets = useSafeAreaInsets(); - const [showContextMenu, setShowContextMenu] = useState(false); - const [tagsContainerWidth, setTagsContainerWidth] = useState(0); - const statusLabel = - status === "online" ? "UP" : status === "offline" ? "DOWN" : "UNK"; - - const parsedStatsConfig: StatsConfig = (() => { - try { - return host.statsConfig - ? JSON.parse(host.statsConfig) - : DEFAULT_STATS_CONFIG; - } catch { - return DEFAULT_STATS_CONFIG; - } - })(); - - const getStatusColor = () => { - switch (status) { - case "online": - return "bg-green-500"; - case "offline": - return "bg-red-500"; - default: - return "bg-gray-500"; - } - }; - - const getStatusPalette = () => { - switch (status) { - case "online": - return { - main: "#22C55E", - border: "#16A34A", - glow: "rgba(34,197,94,0.45)", - }; - case "offline": - return { - main: "#EF4444", - border: "#DC2626", - glow: "rgba(239,68,68,0.45)", - }; - default: - return { - main: "#9CA3AF", - border: "#6B7280", - glow: "rgba(156,163,175,0.35)", - }; - } - }; - - const rippleAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (status === "online") { - rippleAnim.setValue(0); - const loop = Animated.loop( - Animated.timing(rippleAnim, { - toValue: 1, - duration: 1500, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ); - loop.start(); - return () => { - rippleAnim.stopAnimation(); - }; - } else { - rippleAnim.stopAnimation(); - rippleAnim.setValue(0); - } - }, [status, rippleAnim]); - - const handleHostPress = () => { - setShowContextMenu(true); - }; - - const handleTerminalPress = () => { - navigateToSessions(host, "terminal"); - setShowContextMenu(false); - }; - - const handleStatsPress = () => { - navigateToSessions(host, "stats"); - setShowContextMenu(false); - }; - - const handleFileManagerPress = () => { - navigateToSessions(host, "filemanager"); - setShowContextMenu(false); - }; - - const handleCloseContextMenu = () => { - setShowContextMenu(false); - }; - - const closeContextMenu = () => { - setShowContextMenu(false); - }; +/** Compact protocol/feature tag (e.g. RDP, VNC, DKR) shown beside a host. */ +function FeatureChip({ label }: { label: string }) { + return ( + + + {label} + + + ); +} +/** A thin labelled usage bar (CPU / RAM) shown on online hosts. */ +function MetricBar({ + icon, + value, + high, + mid, +}: { + icon: React.ReactNode; + value: number; + high: number; + mid: number; +}) { + const color = useThemeColor(); + const accent = color("accent-brand") ?? "#f59145"; + const barColor = value > high ? "#f87171" : value > mid ? "#facc15" : accent; return ( - <> - - - - - - {statusLabel} - - + + {icon} + + + + + {Math.round(value)}% + + + ); +} - - {status === "online" && ( - - )} - - - +export default function Host({ + host, + status, + metrics, + showTags = true, + striped = false, + onPress, +}: HostProps) { + const color = useThemeColor(); + const accent = color("accent-brand") ?? "#f59145"; + const online = status === "online"; + + const sshActive = host.enableSsh !== false && host.connectionType !== "rdp" && host.connectionType !== "vnc" && host.connectionType !== "telnet"; + const protocols: string[] = []; + if (sshActive) protocols.push("SSH"); + if (host.enableRdp) protocols.push("RDP"); + if (host.enableVnc) protocols.push("VNC"); + if (host.enableTelnet) protocols.push("TEL"); + if (sshActive && host.enableTerminal) protocols.push("TERM"); + if (sshActive && host.enableFileManager) protocols.push("FILES"); + if (sshActive && host.enableTunnel) protocols.push("TUNNEL"); + if (host.enableDocker) protocols.push("DKR"); + + const showCpu = online && metrics?.cpu != null && metrics.cpu > 0; + const showRam = online && metrics?.ram != null && metrics.ram > 0; - - - - {host.name} - - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - - onPress(host)} + className={`flex-row items-stretch active:bg-muted/40 ${ + striped ? "bg-muted/20" : "bg-card" + }`} + > + {/* Status stripe */} + + + + {/* Name row */} + + + - - + {host.name} +
+ {host.pin ? : null} + {protocols.map((p) => ( + + ))}
- {host.tags && host.tags.length > 0 && ( - setTagsContainerWidth(e.nativeEvent.layout.width)} - > - - {(() => { - const chips: any[] = []; - if (!tagsContainerWidth) return null; - const horizontalGap = 6; - const basePadding = 16; - const borderAllowance = 6; - const avgCharPx = 7; - let used = 0; - let shown = 0; - for (let i = 0; i < host.tags.length; i++) { - const tag = String(host.tags[i]); - const tagWidth = - basePadding + - borderAllowance + - Math.ceil(tag.length * avgCharPx); - const remainingCount = host.tags.length - (shown + 1); - const moreLabel = - remainingCount > 0 ? `+${remainingCount} more` : ""; - const moreWidth = - remainingCount > 0 - ? basePadding + - borderAllowance + - Math.ceil(moreLabel.length * avgCharPx) - : 0; - const sep = shown > 0 ? horizontalGap : 0; - if ( - used + - sep + - tagWidth + - (remainingCount > 0 ? horizontalGap + moreWidth : 0) <= - tagsContainerWidth - ) { - used += sep + tagWidth; - chips.push( - - - {tag} - - , - ); - shown += 1; - } else { - break; - } - } - const hidden = host.tags.length - shown; - if (hidden > 0) { - const label = `+${hidden} more`; - chips.push( - - - {label} - - , - ); - } else if (chips.length > 0) { - const last = chips.pop(); - if (last) { - chips.push( - - {(last as any).props.children} - , - ); - } + {/* Address */} + + {host.username ? `${host.username}@` : ""} + {host.ip} + {host.port ? `:${host.port}` : ""} + + + {/* Live metrics */} + {showCpu || showRam ? ( + + {showCpu ? ( + } + value={metrics!.cpu!} + high={80} + mid={50} + /> + ) : null} + {showRam ? ( + } - return chips; - })()} - + value={metrics!.ram!} + high={80} + mid={60} + /> + ) : null} - )} - + ) : null} - - - - {}}> + {/* Tags */} + {showTags && host.tags && host.tags.length > 0 ? ( + + {host.tags.slice(0, 4).map((tag, i) => ( - - - - - {host.name} - - - - - - - - - - {host.enableTerminal && ( - - - - - Open SSH Terminal - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - - - )} - - {parsedStatsConfig.metricsEnabled && ( - - - - - View Server Stats - - - Monitor CPU, memory, and disk usage - - - - )} - - {host.enableFileManager && ( - - - - - File Manager - - - Browse and manage files - - - - )} - - {host.enableTunnel && - host.tunnelConnections && - host.tunnelConnections.length > 0 && ( - { - navigateToSessions(host, "tunnel"); - setShowContextMenu(false); - }} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" - activeOpacity={0.7} - > - - - - Manage Tunnels - - - Browse and control SSH tunnels - - - - )} - - - - Close - - - + + {tag} + - + ))} + {host.tags.length > 4 ? ( + + +{host.tags.length - 4} + + ) : null} - - - + ) : null} + + ); } - -export default Host; diff --git a/app/tabs/hosts/navigation/hostTree.ts b/app/tabs/hosts/navigation/hostTree.ts new file mode 100644 index 0000000..23f02c0 --- /dev/null +++ b/app/tabs/hosts/navigation/hostTree.ts @@ -0,0 +1,314 @@ +import { SSHHost, HostTreeNode } from "@/types"; + +export type HostStatus = "online" | "offline" | "unknown"; +export type GetHostStatus = (hostId: number) => HostStatus; + +export const NO_FOLDER = "No Folder"; +const FOLDER_SEP = " / "; + +export type SortKey = + | "default" + | "name-asc" + | "name-desc" + | "ip-asc" + | "ip-desc" + | "status-online" + | "status-offline" + | "pinned"; + +export type FilterState = { + status: ("online" | "offline" | "pinned")[]; + protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; + features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; + tags: string[]; +}; + +export const DEFAULT_FILTERS: FilterState = { + status: [], + protocol: [], + features: [], + tags: [], +}; + +export function isFolder( + node: HostTreeNode, +): node is Extract { + return node.kind === "folder"; +} + +export function filtersActive(filters: FilterState): boolean { + return Object.values(filters).some((arr) => arr.length > 0); +} + +/** + * Build a recursive folder tree from a flat host list. Folder paths use " / " + * as the nesting delimiter (matches the web app's buildHostTree). Hosts without + * a folder are collected under a root-level "No Folder" bucket. Folder metadata + * (color) is matched by leaf folder name from `folderColors`. + */ +export function buildHostTree( + hosts: SSHHost[], + folderColors: Record = {}, +): HostTreeNode[] { + const root: HostTreeNode[] = []; + const folderMap = new Map< + string, + Extract + >(); + + const getOrCreateFolder = ( + fullPath: string, + ): Extract => { + const existing = folderMap.get(fullPath); + if (existing) return existing; + const parts = fullPath.split(FOLDER_SEP); + let siblings = root; + let accumulated = ""; + let node!: Extract; + for (const part of parts) { + accumulated = accumulated ? `${accumulated}${FOLDER_SEP}${part}` : part; + let folder = folderMap.get(accumulated); + if (!folder) { + folder = { + kind: "folder", + name: part, + path: accumulated, + color: folderColors[part], + children: [], + }; + folderMap.set(accumulated, folder); + siblings.push(folder); + } + siblings = folder.children; + node = folder; + } + return node; + }; + + // Ensure empty (host-less) folders from metadata still appear is intentionally + // skipped — the list only shows folders that contain hosts, like the web. + for (const host of hosts) { + const path = (host.folder ?? "").trim(); + if (path) { + getOrCreateFolder(path).children.push({ kind: "host", host }); + } else { + // Group folderless hosts under a single root-level "No Folder" bucket. + const folder = getOrCreateFolder(NO_FOLDER); + folder.children.push({ kind: "host", host }); + } + } + + return root; +} + +/** Recursively count total and online hosts under a folder node. */ +export function folderCounts( + node: Extract, + getStatus: GetHostStatus, +): { total: number; online: number } { + let total = 0; + let online = 0; + for (const child of node.children) { + if (isFolder(child)) { + const c = folderCounts(child, getStatus); + total += c.total; + online += c.online; + } else { + total++; + if (getStatus(child.host.id) === "online") online++; + } + } + return { total, online }; +} + +function hostMatchesQuery(host: SSHHost, query: string): boolean { + return ( + host.name.toLowerCase().includes(query) || + host.ip.toLowerCase().includes(query) || + (host.username ?? "").toLowerCase().includes(query) || + (host.tags ?? []).some((t) => t.toLowerCase().includes(query)) + ); +} + +/** True if any host under this folder matches the search query. */ +export function folderHasMatch( + node: Extract, + query: string, +): boolean { + for (const child of node.children) { + if (isFolder(child)) { + if (folderHasMatch(child, query)) return true; + } else if (hostMatchesQuery(child.host, query)) { + return true; + } + } + return false; +} + +/** + * Prune the tree to hosts matching the query. Folders with no surviving + * children are dropped. Returns a new tree (does not mutate input). + */ +export function filterTreeByQuery( + nodes: HostTreeNode[], + query: string, +): HostTreeNode[] { + if (!query) return nodes; + const out: HostTreeNode[] = []; + for (const node of nodes) { + if (isFolder(node)) { + const children = filterTreeByQuery(node.children, query); + if (children.length > 0) out.push({ ...node, children }); + } else if (hostMatchesQuery(node.host, query)) { + out.push(node); + } + } + return out; +} + +function hostPassesFilters( + host: SSHHost, + filters: FilterState, + getStatus: GetHostStatus, +): boolean { + if (filters.status.length > 0) { + const status = getStatus(host.id); + const ok = + (filters.status.includes("online") && status === "online") || + (filters.status.includes("offline") && status !== "online") || + (filters.status.includes("pinned") && !!host.pin); + if (!ok) return false; + } + if (filters.protocol.length > 0) { + // SSH-only legacy hosts have no enableSsh flag; treat them as ssh. + const isSsh = + host.enableSsh ?? + (!host.enableRdp && !host.enableVnc && !host.enableTelnet); + const ok = + (filters.protocol.includes("ssh") && isSsh) || + (filters.protocol.includes("rdp") && !!host.enableRdp) || + (filters.protocol.includes("vnc") && !!host.enableVnc) || + (filters.protocol.includes("telnet") && !!host.enableTelnet); + if (!ok) return false; + } + if (filters.features.length > 0) { + const ok = + (filters.features.includes("terminal") && + host.enableTerminal !== false) || + (filters.features.includes("fileManager") && !!host.enableFileManager) || + (filters.features.includes("tunnel") && !!host.enableTunnel) || + (filters.features.includes("docker") && !!host.enableDocker); + if (!ok) return false; + } + if (filters.tags.length > 0) { + const ok = filters.tags.some((tag) => (host.tags ?? []).includes(tag)); + if (!ok) return false; + } + return true; +} + +/** Prune the tree by the active filter state, dropping empty folders. */ +export function applyFilters( + nodes: HostTreeNode[], + filters: FilterState, + getStatus: GetHostStatus, +): HostTreeNode[] { + if (!filtersActive(filters)) return nodes; + const out: HostTreeNode[] = []; + for (const node of nodes) { + if (isFolder(node)) { + const children = applyFilters(node.children, filters, getStatus); + if (children.length > 0) out.push({ ...node, children }); + } else if (hostPassesFilters(node.host, filters, getStatus)) { + out.push(node); + } + } + return out; +} + +/** + * Recursively sort the tree. Folders always sort before hosts; the "No Folder" + * bucket sorts last among folders. Hosts within a level sort by the given key. + */ +export function sortHostTree( + nodes: HostTreeNode[], + key: SortKey, + getStatus: GetHostStatus, +): HostTreeNode[] { + const sorted = [...nodes].sort((a, b) => { + const aFolder = isFolder(a); + const bFolder = isFolder(b); + if (aFolder && !bFolder) return -1; + if (!aFolder && bFolder) return 1; + if (aFolder && bFolder) { + if (a.name === NO_FOLDER) return 1; + if (b.name === NO_FOLDER) return -1; + return a.name.localeCompare(b.name); + } + if (key === "default") return 0; + const ha = (a as Extract).host; + const hb = (b as Extract).host; + switch (key) { + case "name-asc": + return ha.name.localeCompare(hb.name); + case "name-desc": + return hb.name.localeCompare(ha.name); + case "ip-asc": + return ha.ip.localeCompare(hb.ip); + case "ip-desc": + return hb.ip.localeCompare(ha.ip); + case "status-online": + return ( + (getStatus(hb.id) === "online" ? 1 : 0) - + (getStatus(ha.id) === "online" ? 1 : 0) + ); + case "status-offline": + return ( + (getStatus(ha.id) === "online" ? 1 : 0) - + (getStatus(hb.id) === "online" ? 1 : 0) + ); + case "pinned": + return (hb.pin ? 1 : 0) - (ha.pin ? 1 : 0); + default: + return 0; + } + }); + return sorted.map((node) => + isFolder(node) + ? { ...node, children: sortHostTree(node.children, key, getStatus) } + : node, + ); +} + +/** Collect every folder path in the tree (for default "expand all"). */ +export function collectFolderPaths(nodes: HostTreeNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (isFolder(node)) { + paths.push(node.path); + paths.push(...collectFolderPaths(node.children)); + } + } + return paths; +} + +/** Collect paths of all folders that contain a query match (for auto-expand). */ +export function collectMatchingFolderPaths( + nodes: HostTreeNode[], + query: string, +): string[] { + if (!query) return []; + const paths: string[] = []; + for (const node of nodes) { + if (isFolder(node) && folderHasMatch(node, query)) { + paths.push(node.path); + paths.push(...collectMatchingFolderPaths(node.children, query)); + } + } + return paths; +} + +/** Union of all tags across the host list (for the filter sheet). */ +export function collectAllTags(hosts: SSHHost[]): string[] { + return [...new Set(hosts.flatMap((h) => h.tags ?? []))].sort(); +} diff --git a/app/tabs/sessions/ConnectionsPanel.tsx b/app/tabs/sessions/ConnectionsPanel.tsx new file mode 100644 index 0000000..b715ab0 --- /dev/null +++ b/app/tabs/sessions/ConnectionsPanel.tsx @@ -0,0 +1,365 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { View, ScrollView, Pressable, RefreshControl } from "react-native"; +import { + Plug, + SquareTerminal, + FolderOpen, + Activity, + Network, + Container, + Monitor, + ExternalLink, + X, +} from "lucide-react-native"; +import { + getActiveSessions, + type ActiveSessionInfo, + type OpenTabRecord, +} from "@/app/main-axios"; +import { + useTerminalSessions, + type SessionType, +} from "@/app/contexts/TerminalSessionsContext"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { SSHHost } from "@/types"; +import { getSSHHosts, deleteOpenTab } from "@/app/main-axios"; + +const TYPE_LABELS: Record = { + terminal: "SSH", + files: "Files", + filemanager: "Files", + stats: "Stats", + tunnel: "Tunnel", + docker: "Docker", + rdp: "RDP", + vnc: "VNC", + telnet: "Telnet", + remoteDesktop: "Remote", +}; + +function tabIcon(type: string, color: string) { + const size = 16; + switch (type) { + case "files": + case "filemanager": + return ; + case "stats": + return ; + case "tunnel": + return ; + case "docker": + return ; + case "rdp": + case "vnc": + case "telnet": + case "remoteDesktop": + return ; + default: + return ; + } +} + +/** Map an open-tabs record tabType back to our local SessionType. */ +function toSessionType(tabType: string): SessionType { + switch (tabType) { + case "files": + return "filemanager"; + case "rdp": + case "vnc": + case "telnet": + return "remoteDesktop"; + case "stats": + case "tunnel": + case "docker": + return tabType as SessionType; + default: + return "terminal"; + } +} + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( + + + {label} + + + {count} + + + ); +} + +function ConnectionRow({ + isActive, + isLive, + tabType, + name, + subLabel, + onSwitch, + onClose, + reconnectHint, +}: { + isActive?: boolean; + isLive: boolean; + tabType: string; + name: string; + subLabel: string; + onSwitch: () => void; + onClose: () => void; + reconnectHint?: boolean; +}) { + const color = useThemeColor(); + const iconColor = + (isActive ? color("accent-brand") : color("muted-foreground")) ?? "#a4a4a4"; + + return ( + + + {tabIcon(tabType, iconColor)} + + + + + + {name} + + + + {TYPE_LABELS[tabType] ?? tabType} + + + + + {subLabel} + + + + {reconnectHint ? ( + + ) : null} + { + e.stopPropagation(); + onClose(); + }} + hitSlop={8} + className="h-6 w-6 items-center justify-center" + > + + + + + ); +} + +/** + * ConnectionsPanel — the central connections surface. Shows tabs open on this + * device (Open) plus tabs/sessions that exist server-side but aren't open here + * (Background — e.g. opened on desktop, or backgrounded). Reviving a background + * tab reconnects to its live backend session when one exists, enabling + * cross-device tab switching. + */ +export function ConnectionsPanel({ onClose }: { onClose?: () => void }) { + const color = useThemeColor(); + const { + sessions, + activeSessionId, + backgroundTabRecords, + refreshBackgroundTabs, + setActiveSession, + removeSession, + addSession, + navigateToSessions, + forgetBackgroundTab, + } = useTerminalSessions(); + + const [activeSessions, setActiveSessions] = useState([]); + const [hosts, setHosts] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const pollRef = useRef | null>(null); + const refreshRef = useRef(refreshBackgroundTabs); + refreshRef.current = refreshBackgroundTabs; + + const refresh = useCallback(async () => { + const [live] = await Promise.all([ + getActiveSessions(), + refreshRef.current(), + ]); + setActiveSessions(live); + }, []); + + useEffect(() => { + getSSHHosts() + .then((res: any) => { + const list = Array.isArray(res) ? res : (res?.hosts ?? []); + setHosts(list); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + refresh(); + pollRef.current = setInterval(refresh, 5000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + // refresh is stable (no deps); interval starts once and runs until unmount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sessionByInstance = new Map( + activeSessions.map((s) => [s.tabInstanceId, s]), + ); + + // Background = server records whose instanceId isn't an open tab here. + const openInstanceIds = new Set(sessions.map((s) => s.instanceId)); + const backgroundTabs = backgroundTabRecords.filter( + (r) => !openInstanceIds.has(r.id), + ); + + const reviveBackground = (record: OpenTabRecord) => { + const host = hosts.find((h) => h.id === record.hostId); + if (!host) return; + const live = sessionByInstance.get(record.id); + addSession(host, toSessionType(record.tabType), { + instanceId: record.id, + restoredSessionId: live?.sessionId ?? record.backendSessionId ?? null, + }); + navigateToSessions(); + onClose?.(); + }; + + const hasAny = sessions.length > 0 || backgroundTabs.length > 0; + + if (!hasAny) { + return ( + + + + + + No active connections + + + Connect to a host to start a session. Tabs you open on other devices + will appear here too. + + + ); + } + + return ( + { + setRefreshing(true); + await refresh(); + setRefreshing(false); + }} + tintColor={color("accent-brand")} + /> + } + > + {sessions.length > 0 ? ( + + + {sessions.map((s) => { + const live = sessionByInstance.get(s.instanceId); + const isLive = + s.type === "terminal" + ? (live?.isConnected ?? !!s.backendSessionId) + : true; + return ( + { + setActiveSession(s.id); + navigateToSessions(); + onClose?.(); + }} + onClose={() => removeSession(s.id)} + /> + ); + })} + + ) : null} + + {backgroundTabs.length > 0 ? ( + 0 ? "mt-2" : ""}> + + + + Sessions from other devices or backgrounded tabs. Tap to revive. + + + {backgroundTabs.map((r) => { + const host = hosts.find((h) => h.id === r.hostId); + const live = sessionByInstance.get(r.id); + return ( + reviveBackground(r)} + onClose={async () => { + await deleteOpenTab(r.id); + forgetBackgroundTab(r.id); + }} + /> + ); + })} + + ) : null} + + ); +} diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index e679592..f22e858 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -1,25 +1,23 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { View, - Text, - ScrollView, - TouchableOpacity, - StyleSheet, Keyboard, - KeyboardAvoidingView, Platform, TextInput, - TouchableWithoutFeedback, - Pressable, Dimensions, BackHandler, AppState, LayoutAnimation, + Pressable, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; import { useRouter } from "expo-router"; -import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import { + SessionType, + useTerminalSessions, +} from "@/app/contexts/TerminalSessionsContext"; +import { X } from "lucide-react-native"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { Terminal, @@ -37,19 +35,25 @@ import { TunnelManager, TunnelManagerHandle, } from "@/app/tabs/sessions/tunnel/TunnelManager"; +import { RemoteDesktop } from "@/app/tabs/sessions/remote-desktop/RemoteDesktop"; +import { Docker } from "@/app/tabs/sessions/docker/Docker"; +import { ConnectionsPanel } from "@/app/tabs/sessions/ConnectionsPanel"; +import { Screen } from "@/app/components/Screen"; +import { Text } from "@/app/components/ui"; import TabBar from "@/app/tabs/sessions/navigation/TabBar"; import BottomToolbar from "@/app/tabs/sessions/terminal/keyboard/BottomToolbar"; import KeyboardBar from "@/app/tabs/sessions/terminal/keyboard/KeyboardBar"; -import { ArrowLeft } from "lucide-react-native"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; -import { - BACKGROUNDS, - BORDER_COLORS, - BORDERS, -} from "@/app/constants/designTokens"; +import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; import { addKeyCommandListener } from "@/modules/hardware-keyboard"; +type ActiveModifiers = { + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); @@ -58,8 +62,10 @@ export default function Sessions() { const { sessions, activeSessionId, + backgroundTabRecords, setActiveSession, removeSession, + setBackendSessionId, isCustomKeyboardVisible, toggleCustomKeyboard, lastKeyboardHeight, @@ -67,6 +73,8 @@ export default function Sessions() { keyboardIntentionallyHiddenRef, setKeyboardIntentionallyHidden, } = useTerminalSessions(); + + const [showConnectionsPanel, setShowConnectionsPanel] = useState(false); const { keyboardHeight, isKeyboardVisible } = useKeyboard(); const hiddenInputRef = useRef(null); const terminalRefs = useRef>>( @@ -84,21 +92,31 @@ export default function Sessions() { const [activeModifiers, setActiveModifiers] = useState({ ctrl: false, alt: false, + shift: false, }); const [screenDimensions, setScreenDimensions] = useState( Dimensions.get("window"), ); const [keyboardType, setKeyboardType] = useState("default"); + const [customKeyboardInitialTab, setCustomKeyboardInitialTab] = useState<"keyboard" | "snippets" | "history">("keyboard"); const [hiddenInputValue, setHiddenInputValue] = useState(""); const dictationBufferRef = useRef(""); const dictationSentRef = useRef(""); const dictationTimerRef = useRef | null>(null); - useEffect(() => { + const ignoreNextTextChangeRef = useRef(false); + + const resetHiddenInputState = useCallback(() => { if (dictationTimerRef.current) clearTimeout(dictationTimerRef.current); + dictationTimerRef.current = null; dictationBufferRef.current = ""; dictationSentRef.current = ""; setHiddenInputValue(""); - }, [activeSessionId]); + }, []); + + useEffect(() => { + resetHiddenInputState(); + ignoreNextTextChangeRef.current = false; + }, [activeSessionId, resetHiddenInputState]); const lastBlurTimeRef = useRef(0); const [terminalBackgroundColors, setTerminalBackgroundColors] = useState< Record @@ -123,7 +141,27 @@ export default function Sessions() { const CUSTOM_KEYBOARD_TAB_HEIGHT = 36; const KEYBOARD_BAR_HEIGHT = isLandscape ? 48 : 52; - const KEYBOARD_BAR_HEIGHT_EXTENDED = isLandscape ? 64 : 68; + // When the system keyboard is dismissed the keyboard bar reserves the + // home-indicator safe area below its keys. The TabBar floats directly on top + // of the bar, so it must be lifted by the keys' height PLUS that same + // reservation — otherwise the reserved space falls between the TabBar and the + // keys and the TabBar looks bottom-heavy. Keep these two in lockstep. + const KEYBOARD_BAR_BOTTOM_INSET = Math.max(insets.bottom, 8); + const KEYBOARD_BAR_HEIGHT_EXTENDED = + KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_BOTTOM_INSET; + + const activeSession = sessions.find( + (session) => session.id === activeSessionId, + ); + + const [isRdpKeyboardOpen, setIsRdpKeyboardOpen] = useState(false); + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", () => { + if (activeSession?.type === "remoteDesktop") setIsRdpKeyboardOpen(true); + }); + const hide = Keyboard.addListener("keyboardDidHide", () => setIsRdpKeyboardOpen(false)); + return () => { show.remove(); hide.remove(); }; + }, [activeSession?.type]); const getTabBarBottomPosition = () => { if (activeSession?.type !== "terminal") { @@ -145,9 +183,7 @@ export default function Sessions() { return KEYBOARD_BAR_HEIGHT; }; - const getBottomMargin = ( - sessionType: "terminal" | "stats" | "filemanager" = "terminal", - ) => { + const getBottomMargin = (sessionType: SessionType = "terminal") => { if (sessionType !== "terminal") { return SESSION_TAB_BAR_HEIGHT + insets.bottom; } @@ -287,6 +323,10 @@ export default function Sessions() { const backHandler = BackHandler.addEventListener( "hardwareBackPress", () => { + if (showConnectionsPanel) { + setShowConnectionsPanel(false); + return true; + } if (isKeyboardVisible) { setKeyboardIntentionallyHidden(true); Keyboard.dismiss(); @@ -300,7 +340,7 @@ export default function Sessions() { backHandler.remove(); }; } - }, [sessions.length, isKeyboardVisible]); + }, [sessions.length, isKeyboardVisible, showConnectionsPanel]); useEffect(() => { const subscription = Dimensions.addEventListener("change", ({ window }) => { @@ -395,8 +435,8 @@ export default function Sessions() { : null; if (!activeRef?.current) return; - if (event.shift && event.input === "\t") { - activeRef.current.sendInput("\x1b[Z"); + if (event.input === "\t") { + activeRef.current.sendInput(event.shift ? "\x1b[Z" : "\t"); return; } @@ -407,13 +447,51 @@ export default function Sessions() { return; } + if (event.alt && !event.ctrl && event.input.length === 1) { + activeRef.current.sendInput(`\x1b${event.shift ? event.input.toUpperCase() : event.input}`); + return; + } + const specialMap: Record = { ArrowUp: "\x1b[A", ArrowDown: "\x1b[B", ArrowLeft: "\x1b[D", ArrowRight: "\x1b[C", Escape: "\x1b", + Backspace: "\x7f", + Delete: "\x1b[3~", + Home: "\x1b[H", + End: "\x1b[F", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", + F1: "\x1bOP", + F2: "\x1bOQ", + F3: "\x1bOR", + F4: "\x1bOS", + F5: "\x1b[15~", + F6: "\x1b[17~", + F7: "\x1b[18~", + F8: "\x1b[19~", + F9: "\x1b[20~", + F10: "\x1b[21~", + F11: "\x1b[23~", + F12: "\x1b[24~", }; + + // Shift+Arrow: send xterm modifier sequences + if (event.shift) { + const shiftArrowMap: Record = { + ArrowUp: "\x1b[1;2A", + ArrowDown: "\x1b[1;2B", + ArrowRight: "\x1b[1;2C", + ArrowLeft: "\x1b[1;2D", + }; + if (shiftArrowMap[event.input]) { + activeRef.current.sendInput(shiftArrowMap[event.input]); + return; + } + } + if (specialMap[event.input]) { activeRef.current.sendInput(specialMap[event.input]); return; @@ -472,6 +550,14 @@ export default function Sessions() { }, 100); }; + const closeConnectionsPanel = useCallback(() => { + setShowConnectionsPanel(false); + if (activeSession?.type === "terminal" && !isCustomKeyboardVisible) { + setKeyboardIntentionallyHidden(false); + setTimeout(() => hiddenInputRef.current?.focus(), 100); + } + }, [activeSession?.type, isCustomKeyboardVisible, setKeyboardIntentionallyHidden]); + const handleAddSession = () => { router.navigate("/hosts" as any); }; @@ -480,6 +566,7 @@ export default function Sessions() { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); if (isCustomKeyboardVisible) { + setCustomKeyboardInitialTab("keyboard"); toggleCustomKeyboard(); setKeyboardIntentionallyHidden(false); setTimeout(() => { @@ -516,16 +603,26 @@ export default function Sessions() { } }; - const handleModifierChange = useCallback( - (modifiers: { ctrl: boolean; alt: boolean }) => { - setActiveModifiers(modifiers); - }, - [], - ); + const handleOpenSnippets = useCallback(() => { + setCustomKeyboardInitialTab("snippets"); + if (!isCustomKeyboardVisible) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + toggleCustomKeyboard(); + setKeyboardIntentionallyHidden(false); + requestAnimationFrame(() => { hiddenInputRef.current?.blur(); }); + setTimeout(() => { + const activeRef = activeSessionId ? terminalRefs.current[activeSessionId] : null; + if (activeRef?.current) { + activeRef.current.fit(); + setTimeout(() => activeRef.current?.scrollToBottom(), 50); + } + }, 300); + } + }, [isCustomKeyboardVisible, toggleCustomKeyboard, setKeyboardIntentionallyHidden, activeSessionId]); - const activeSession = sessions.find( - (session) => session.id === activeSessionId, - ); + const handleModifierChange = useCallback((modifiers: ActiveModifiers) => { + setActiveModifiers(modifiers); + }, []); const activeTerminalBgColor = activeSession?.type === "terminal" && activeSessionId @@ -540,9 +637,7 @@ export default function Sessions() { backgroundColor: activeSession?.type === "terminal" ? activeTerminalBgColor - : activeSession?.type === "filemanager" - ? BACKGROUNDS.HEADER - : "#18181b", + : BACKGROUNDS.DARK, }} > + setBackendSessionId(session.id, backendId) + } onClose={() => handleTabClose(session.id)} onBackgroundColorChange={(color) => { setTerminalBackgroundColors((prev) => ({ @@ -592,6 +697,7 @@ export default function Sessions() { hostConfig={{ id: parseInt(session.host.id.toString()), name: session.host.name, + statsConfig: session.host.statsConfig, quickActions: session.host.quickActions, }} isVisible={session.id === activeSessionId} @@ -625,95 +731,87 @@ export default function Sessions() { onClose={() => handleTabClose(session.id)} /> ); + } else if (session.type === "remoteDesktop") { + return ( + handleTabClose(session.id)} + /> + ); + } else if (session.type === "docker") { + return ( + + ); } return null; })} - {sessions.length === 0 && ( + {/* Connections panel: full screen when no sessions, overlay when sessions exist */} + {(sessions.length === 0 || showConnectionsPanel) && ( - - - No Active Terminal Sessions - - - Connect to a host from the Hosts tab to start a session - - { - handleAddSession(); - }} - > - + + + ) : ( + + - Go to Hosts - + + + Connections + + + + + + + - + )}
)} @@ -749,6 +847,8 @@ export default function Sessions() { isKeyboardIntentionallyHidden={ keyboardIntentionallyHiddenRef.current } + bottomInset={KEYBOARD_BAR_BOTTOM_INSET} + onOpenSnippets={handleOpenSnippets} />
)} @@ -778,6 +878,7 @@ export default function Sessions() { right: 0, height: SESSION_TAB_BAR_HEIGHT, zIndex: 1004, + display: isRdpKeyboardOpen ? "none" : "flex", }} > setKeyboardIntentionallyHidden(false)} keyboardIntentionallyHiddenRef={keyboardIntentionallyHiddenRef} activeSessionType={activeSession?.type} + onShowConnections={() => { + setKeyboardIntentionallyHidden(true); + Keyboard.dismiss(); + hiddenInputRef.current?.blur(); + setShowConnectionsPanel(true); + }} + hasBackgroundSessions={backgroundTabRecords.some( + (r) => !sessions.find((s) => s.instanceId === r.id), + )} />
@@ -821,6 +931,7 @@ export default function Sessions() { isKeyboardIntentionallyHidden={ keyboardIntentionallyHiddenRef.current } + initialTab={customKeyboardInitialTab} />
)} @@ -859,6 +970,38 @@ export default function Sessions() { underlineColorAndroid="transparent" value={hiddenInputValue} onChangeText={(text) => { + if (Platform.OS === "ios") { + if (ignoreNextTextChangeRef.current) { + ignoreNextTextChangeRef.current = false; + return; + } + + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + + if (!activeRef?.current || !text) { + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + setHiddenInputValue(""); + return; + } + + const alreadySent = dictationSentRef.current; + const newText = text.startsWith(alreadySent) + ? text.slice(alreadySent.length) + : text; + + dictationBufferRef.current = ""; + dictationSentRef.current = text; + setHiddenInputValue(""); + if (newText) activeRef.current.sendInput(newText); + requestAnimationFrame(() => { + dictationSentRef.current = ""; + }); + return; + } + if (text.length <= dictationSentRef.current.length) { const hasPendingBuffer = Platform.OS === "android" && @@ -926,7 +1069,7 @@ export default function Sessions() { dictationSentRef.current = finalText; if (finalText) activeRef.current?.sendInput(finalText); } - }, 300); + }, 30); }} onSubmitEditing={() => { const activeRef = activeSessionId @@ -952,7 +1095,7 @@ export default function Sessions() { finalKey = "\x7f"; break; case "Tab": - finalKey = "\t"; + finalKey = activeModifiers.shift ? "\x1b[Z" : "\t"; break; case "Escape": finalKey = "\x1b"; @@ -1025,15 +1168,36 @@ export default function Sessions() { if (activeModifiers.ctrl) { finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; + finalKey = `\x1b${activeModifiers.shift ? key.toUpperCase() : key}`; + } else if (Platform.OS === "ios") { + // On iOS, onChangeText handles bare printable chars to + // support dictation/IME composition. Skip here to avoid + // double-sending. + return; } else { - finalKey = key; - dictationSentRef.current = hiddenInputValue + key; + finalKey = activeModifiers.shift + ? key.toUpperCase() + : key; + dictationSentRef.current = hiddenInputValue + finalKey; } } } if (finalKey !== null) { + // Only reset + suppress the next onChangeText for keys that + // actually mutate the hidden input value (backspace, enter, + // tab, escape). Delete/arrows/F-keys don't change it and + // should not eat the next typed character. + const resetsInput = ["Backspace", "Enter", "Tab", "Escape"].includes(key); + if (resetsInput) { + resetHiddenInputState(); + ignoreNextTextChangeRef.current = true; + if (Platform.OS === "android") { + requestAnimationFrame(() => { + hiddenInputRef.current?.focus(); + }); + } + } activeRef.current.sendInput(finalKey); } }} diff --git a/app/tabs/sessions/_shared/AuthDialogs.tsx b/app/tabs/sessions/_shared/AuthDialogs.tsx new file mode 100644 index 0000000..24f6294 --- /dev/null +++ b/app/tabs/sessions/_shared/AuthDialogs.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { ShieldCheck, KeyRound, Lock, Clock } from "lucide-react-native"; +import { Dialog, Input, Button, Text, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { SessionAuthOverrides } from "@/types"; +import type { SessionConnectState } from "./useSessionConnect"; + +function useTotpCountdown() { + const [secondsLeft, setSecondsLeft] = useState( + () => 30 - (Math.floor(Date.now() / 1000) % 30), + ); + useEffect(() => { + const interval = setInterval(() => { + setSecondsLeft(30 - (Math.floor(Date.now() / 1000) % 30)); + }, 1000); + return () => clearInterval(interval); + }, []); + return secondsLeft; +} + +export function AuthDialogs({ + state, + errorMessage, + onSubmitTotp, + onSubmitWarpgate, + onSubmitAuth, + onCancel, +}: { + state: SessionConnectState; + errorMessage?: string; + onSubmitTotp: (code: string) => Promise; + onSubmitWarpgate?: (url: string, securityKey?: string) => Promise; + onSubmitAuth: (overrides: SessionAuthOverrides) => void; + onCancel: () => void; +}) { + const color = useThemeColor(); + + // --- TOTP --- + const [code, setCode] = useState(""); + const [totpBusy, setTotpBusy] = useState(false); + const totpCountdown = useTotpCountdown(); + useEffect(() => { + if (state !== "totp") { + setCode(""); + setTotpBusy(false); + } + }, [state]); + + const submitTotp = async () => { + if (code.trim().length < 6) return; + setTotpBusy(true); + try { + await onSubmitTotp(code.trim()); + } catch { + // error surfaced via errorMessage; keep dialog open + } finally { + setTotpBusy(false); + } + }; + + // --- Warpgate --- + const [wgUrl, setWgUrl] = useState(""); + const [wgKey, setWgKey] = useState(""); + const [wgBusy, setWgBusy] = useState(false); + useEffect(() => { + if (state !== "warpgate") { + setWgUrl(""); + setWgKey(""); + setWgBusy(false); + } + }, [state]); + + const submitWarpgate = async () => { + if (!wgUrl.trim()) return; + setWgBusy(true); + try { + await onSubmitWarpgate?.(wgUrl, wgKey || undefined); + } catch { + // error surfaced via errorMessage + } finally { + setWgBusy(false); + } + }; + + // --- Interactive auth (password / key / passphrase) --- + const [authMethod, setAuthMethod] = useState<"password" | "key">("password"); + const [authPassword, setAuthPassword] = useState(""); + const [authKey, setAuthKey] = useState(""); + const [authKeyPassword, setAuthKeyPassword] = useState(""); + const [authBusy, setAuthBusy] = useState(false); + useEffect(() => { + if (state !== "auth") { + setAuthPassword(""); + setAuthKey(""); + setAuthKeyPassword(""); + setAuthBusy(false); + } + }, [state]); + + const submitAuth = () => { + setAuthBusy(true); + if (authMethod === "password") { + onSubmitAuth({ userProvidedPassword: authPassword }); + } else { + onSubmitAuth({ + userProvidedSshKey: authKey, + userProvidedKeyPassword: authKeyPassword || undefined, + }); + } + // authBusy is reset when state leaves "auth" (via the useEffect above) + }; + + const totpExpiringSoon = totpCountdown <= 5; + + return ( + <> + {/* TOTP */} + } + title="Two-Factor Authentication" + description="Enter the 6-digit code from your authenticator app." + footer={ + + + + + } + > + setCode(t.replace(/[^0-9]/g, "").slice(0, 6))} + placeholder="000000" + keyboardType="number-pad" + autoFocus + maxLength={6} + /> + + + + {totpExpiringSoon + ? `Code expires in ${totpCountdown}s — get a fresh code` + : `${totpCountdown}s remaining`} + + + {errorMessage ? ( + {errorMessage} + ) : null} + + + {/* Warpgate */} + } + title="Warpgate Authentication" + description="Confirm the Warpgate authentication URL and security key." + footer={ + + + + + } + > + + + + + {errorMessage ? ( + {errorMessage} + ) : null} + + + {/* Interactive auth */} + } + title="Authentication Required" + description="This host needs credentials to connect." + footer={ + + + + + } + > + + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + value={authMethod} + onChange={setAuthMethod} + className="mb-3" + /> + {authMethod === "password" ? ( + + ) : ( + + + + + )} + {errorMessage ? ( + {errorMessage} + ) : null} + + + ); +} diff --git a/app/tabs/sessions/_shared/ContextSheet.tsx b/app/tabs/sessions/_shared/ContextSheet.tsx new file mode 100644 index 0000000..76f4ab4 --- /dev/null +++ b/app/tabs/sessions/_shared/ContextSheet.tsx @@ -0,0 +1,64 @@ +import { View } from "react-native"; +import { BottomSheet, SheetRow, Text } from "@/app/components/ui"; + +export interface ContextAction { + key: string; + icon: React.ReactNode; + label: string; + destructive?: boolean; + disabled?: boolean; + onPress: () => void; +} + +/** + * ContextSheet — a long-press action menu built on BottomSheet + SheetRow. + * Replaces the old absolutely-positioned context menus that could render + * off-screen. Pass a title (e.g. the file name) and a flat list of actions; + * tapping a row fires its handler and closes the sheet. + */ +export function ContextSheet({ + visible, + onClose, + title, + subtitle, + actions, +}: { + visible: boolean; + onClose: () => void; + title?: string; + subtitle?: string; + actions: (ContextAction | null | false)[]; +}) { + const items = actions.filter(Boolean) as ContextAction[]; + + return ( + + {title ? ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ) : null} + {items.map((a) => ( + { + if (a.disabled) return; + onClose(); + // Defer so the sheet's close animation isn't janked by the action. + requestAnimationFrame(a.onPress); + }} + /> + ))} + + ); +} diff --git a/app/tabs/sessions/_shared/SessionFrame.tsx b/app/tabs/sessions/_shared/SessionFrame.tsx new file mode 100644 index 0000000..efaf8f3 --- /dev/null +++ b/app/tabs/sessions/_shared/SessionFrame.tsx @@ -0,0 +1,156 @@ +import { View, ActivityIndicator, ScrollView } from "react-native"; +import { AlertCircle, RotateCcw } from "lucide-react-native"; +import { Text, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { ConnectionLog } from "./useConnectionLog"; +import type { ConnectionLogEntry } from "@/types"; + +export type SessionFrameStatus = + | "loading" + | "ready" + | "error" + | "empty"; + +/** + * SessionFrame — the standard chrome shared by every session type (file + * manager, docker, stats, tunnel). Renders an optional header (title/subtitle + + * trailing actions), a connection-log panel, and centralized loading / error / + * empty states so each session type doesn't reinvent them. + * + * The terminal and remote-desktop canvases are full-bleed and manage their own + * chrome, so they don't use this. + */ +export function SessionFrame({ + title, + subtitle, + headerActions, + toolbar, + status = "ready", + loadingLabel = "Connecting…", + errorMessage, + emptyMessage = "Nothing to show", + emptyIcon, + onRetry, + logEntries, + isConnecting, + isConnected, + hasConnectionError, + onLogClear, + scroll = false, + children, +}: { + title?: string; + subtitle?: string; + headerActions?: React.ReactNode; + toolbar?: React.ReactNode; + status?: SessionFrameStatus; + loadingLabel?: string; + errorMessage?: string; + emptyMessage?: string; + emptyIcon?: React.ReactNode; + onRetry?: () => void; + logEntries?: ConnectionLogEntry[]; + isConnecting?: boolean; + isConnected?: boolean; + hasConnectionError?: boolean; + onLogClear?: () => void; + scroll?: boolean; + children?: React.ReactNode; +}) { + const color = useThemeColor(); + + const Body: any = scroll ? ScrollView : View; + const bodyProps = scroll + ? { contentContainerStyle: { flexGrow: 1 } } + : { className: "flex-1" }; + + return ( + + {(title || headerActions) && ( + + + {title ? ( + + {title} + + ) : null} + {subtitle ? ( + + {subtitle} + + ) : null} + + {headerActions ? ( + {headerActions} + ) : null} + + )} + + {toolbar ? ( + {toolbar} + ) : null} + + {/* Connection log overlay — covers the frame while connecting/errored */} + {logEntries !== undefined && onLogClear !== undefined ? ( + + ) : null} + + {/* Suppress the loading spinner when the log overlay is covering the screen */} + {status === "loading" && !(logEntries && logEntries.length > 0 && !isConnected) ? ( + + + {loadingLabel} + + ) : status === "error" ? ( + + + + {errorMessage || "Something went wrong"} + + {onRetry ? ( + + ) : null} + + ) : status === "empty" ? ( + + {emptyIcon ?? } + + {emptyMessage} + + {onRetry ? ( + + ) : null} + + ) : ( + {children} + )} + + ); +} diff --git a/app/tabs/sessions/_shared/index.ts b/app/tabs/sessions/_shared/index.ts new file mode 100644 index 0000000..8426819 --- /dev/null +++ b/app/tabs/sessions/_shared/index.ts @@ -0,0 +1,11 @@ +export { usePolling } from "./usePolling"; +export { useConnectionLog, ConnectionLog } from "./useConnectionLog"; +export { SessionFrame } from "./SessionFrame"; +export type { SessionFrameStatus } from "./SessionFrame"; +export { ContextSheet } from "./ContextSheet"; +export type { ContextAction } from "./ContextSheet"; +export { + useSessionConnect, + type SessionConnectState, +} from "./useSessionConnect"; +export { AuthDialogs } from "./AuthDialogs"; diff --git a/app/tabs/sessions/_shared/useConnectionLog.tsx b/app/tabs/sessions/_shared/useConnectionLog.tsx new file mode 100644 index 0000000..225332d --- /dev/null +++ b/app/tabs/sessions/_shared/useConnectionLog.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { View, Pressable, ScrollView } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { + ChevronDown, + ChevronUp, + Copy, + Info, + CheckCircle2, + AlertTriangle, + XCircle, +} from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import type { + ConnectionLogEntry, + ConnectionLogLevel, + ConnectionLogPayload, +} from "@/types"; + +let logSeq = 0; + +/** + * Connection-log state for a session. Mirrors the web `connection-log`: connect + * endpoints return arrays of `{type, stage, message}` (sans id/timestamp); this + * stamps each with an id/timestamp and exposes append/ingest/clear plus the + * highest level seen (so the UI can auto-expand on error). + */ +export function useConnectionLog() { + const [entries, setEntries] = useState([]); + const highestLevelRef = useRef("info"); + + const append = useCallback((payload: ConnectionLogPayload) => { + const entry: ConnectionLogEntry = { + ...payload, + id: `log_${++logSeq}`, + timestamp: Date.now(), + }; + setEntries((prev) => [...prev, entry]); + if (rank(payload.level) > rank(highestLevelRef.current)) { + highestLevelRef.current = payload.level; + } + }, []); + + /** + * Ingest a backend `connectionLogs` array (the `type` field maps to `level`). + */ + const ingest = useCallback( + (logs: { type?: string; level?: string; stage?: string; message: string }[]) => { + for (const l of logs) { + const level = (l.level || l.type || "info") as ConnectionLogLevel; + append({ level, stage: l.stage, message: l.message }); + } + }, + [append], + ); + + const clear = useCallback(() => { + setEntries([]); + highestLevelRef.current = "info"; + }, []); + + return { + entries, + append, + ingest, + clear, + hasError: entries.some((e) => e.level === "error"), + }; +} + +function rank(level: ConnectionLogLevel): number { + switch (level) { + case "error": + return 3; + case "warning": + return 2; + case "success": + return 1; + default: + return 0; + } +} + +const LEVEL_ICON: Record< + ConnectionLogLevel, + (color: string) => React.ReactNode +> = { + info: (c) => , + success: (c) => , + warning: (c) => , + error: (c) => , +}; + +/** + * Full-screen connection-log overlay. Mirrors the web ConnectionLog: covers the + * entire session panel while connecting, auto-expands on error, auto-clears when + * the connection succeeds. The collapsed state shows only a slim bar at the bottom. + */ +export function ConnectionLog({ + entries, + isConnecting, + isConnected, + hasConnectionError, + onClear, +}: { + entries: ConnectionLogEntry[]; + isConnecting: boolean; + isConnected: boolean; + hasConnectionError: boolean; + onClear: () => void; +}) { + const color = useThemeColor(); + const [expanded, setExpanded] = useState(false); + const scrollRef = useRef(null); + + const shouldShow = + !isConnected && (isConnecting || hasConnectionError || entries.length > 0); + + // Auto-clear when successfully connected. + useEffect(() => { + if (isConnected && !hasConnectionError && !isConnecting) { + onClear(); + } + }, [isConnected, hasConnectionError, isConnecting, onClear]); + + // Auto-expand on error. + useEffect(() => { + if (hasConnectionError) { + setExpanded(true); + } + }, [hasConnectionError]); + + // Auto-scroll to latest entry when expanded. + useEffect(() => { + if (expanded && entries.length > 0) { + setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50); + } + }, [entries, expanded]); + + if (!shouldShow) return null; + + const levelColor = (level: ConnectionLogLevel): string => { + switch (level) { + case "error": + return color("destructive") ?? "#ef4444"; + case "warning": + return "#f59e0b"; + case "success": + return "#22c55e"; + default: + return color("muted-foreground") ?? "#888"; + } + }; + + const copyAll = async () => { + const text = entries + .map((e) => `[${e.level.toUpperCase()}] ${e.message}`) + .join("\n"); + await Clipboard.setStringAsync(text); + toast.success("Logs copied"); + }; + + const showBackground = expanded || hasConnectionError; + + return ( + + {/* Solid background when expanded */} + {showBackground && ( + + )} + + {/* Log panel */} + + {/* Header bar */} + setExpanded((v) => !v)} + className="flex-row items-center gap-2 px-3 py-2 active:bg-muted/30" + > + {expanded ? ( + + ) : ( + + )} + + Connection Log ({entries.length}) + + {entries.length > 0 && ( + + + + )} + + + {/* Log entries */} + {expanded && ( + + {entries.length === 0 ? ( + + {isConnecting ? "Waiting for connection…" : "No log entries"} + + ) : ( + entries.map((e) => ( + + + {LEVEL_ICON[e.level](levelColor(e.level))} + + + {e.message} + + + )) + )} + + )} + + + ); +} diff --git a/app/tabs/sessions/_shared/usePolling.ts b/app/tabs/sessions/_shared/usePolling.ts new file mode 100644 index 0000000..9401f6a --- /dev/null +++ b/app/tabs/sessions/_shared/usePolling.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import { AppState, type AppStateStatus } from "react-native"; + +/** + * Visibility-aware polling. Runs `fn` immediately when enabled, then on an + * interval — but pauses while the app is backgrounded (the OS suspends timers + * anyway, and we don't want a burst of stale requests on resume). Replaces the + * hardcoded `setInterval` that each session type used to roll on its own. + * + * `fn` is kept in a ref so callers can pass an inline closure without resetting + * the interval every render. The interval only restarts when `intervalMs` or + * `enabled` change. + */ +export function usePolling( + fn: () => void | Promise, + intervalMs: number, + enabled = true, +): void { + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + if (!enabled || intervalMs <= 0) return; + + let timer: ReturnType | null = null; + let cancelled = false; + + const tick = () => { + if (!cancelled) void fnRef.current(); + }; + + const start = () => { + if (timer) return; + tick(); + timer = setInterval(tick, intervalMs); + }; + + const stop = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const onAppStateChange = (state: AppStateStatus) => { + if (state === "active") start(); + else stop(); + }; + + if (AppState.currentState === "active") start(); + const sub = AppState.addEventListener("change", onAppStateChange); + + return () => { + cancelled = true; + stop(); + sub.remove(); + }; + }, [intervalMs, enabled]); +} diff --git a/app/tabs/sessions/_shared/useSessionConnect.ts b/app/tabs/sessions/_shared/useSessionConnect.ts new file mode 100644 index 0000000..501e297 --- /dev/null +++ b/app/tabs/sessions/_shared/useSessionConnect.ts @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState } from "react-native"; +import { SSHHost, SessionAuthOverrides } from "@/types"; +import { getCurrentUserId } from "@/app/utils/user"; +import { extractConnectionLogs } from "@/app/main-axios"; +import { useConnectionLog } from "./useConnectionLog"; + +export type SessionConnectState = + | "idle" + | "connecting" + | "connected" + | "totp" + | "warpgate" + | "auth" + | "error"; + +/** + * The transport-specific calls a session type provides. File manager passes the + * `connectSSH`/`verifySSHTOTP`/`keepSSHAlive`/`disconnectSSH` family; docker + * passes its `dockerConnect`/`dockerConnectTOTP`/... family. This lets one + * connect state machine drive every session-based REST type. + */ +export interface SessionConnectTransport { + /** sessionId prefix, e.g. "fm" or "docker". */ + prefix: string; + connect: ( + sessionId: string, + host: SSHHost, + userId: string | undefined, + overrides: SessionAuthOverrides, + ) => Promise; + submitTotp: (sessionId: string, code: string) => Promise; + submitWarpgate?: ( + sessionId: string, + url: string, + securityKey?: string, + ) => Promise; + keepAlive?: (sessionId: string) => Promise; + disconnect?: (sessionId: string) => Promise; +} + +/** + * Drives the shared connect handshake for session-based REST connection types + * (file manager, docker). Owns: session id generation, the connect call, the + * TOTP / Warpgate / interactive-auth branches, keepalive, and a connection log. + * Mirrors the web's connect flow so mobile reaches auth-method parity. + * + * UI renders wired to the returned `auth*` props; on success the + * caller's `onConnected(sessionId)` runs (load the directory, list containers…). + */ +export function useSessionConnect( + host: SSHHost | null, + transport: SessionConnectTransport, + onConnected: (sessionId: string) => void | Promise, + options?: { keepAliveMs?: number; autoConnect?: boolean }, +) { + const log = useConnectionLog(); + const [state, setState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const sessionIdRef = useRef(""); + const keepAliveRef = useRef | null>(null); + const overridesRef = useRef({}); + const onConnectedRef = useRef(onConnected); + onConnectedRef.current = onConnected; + + const keepAliveMs = options?.keepAliveMs ?? 30000; + + const stopKeepAlive = useCallback(() => { + if (keepAliveRef.current) { + clearInterval(keepAliveRef.current); + keepAliveRef.current = null; + } + }, []); + + const startKeepAlive = useCallback(() => { + if (!transport.keepAlive) return; + stopKeepAlive(); + keepAliveRef.current = setInterval(() => { + if (sessionIdRef.current && AppState.currentState === "active") { + transport.keepAlive?.(sessionIdRef.current).catch(() => {}); + } + }, keepAliveMs); + }, [transport, keepAliveMs, stopKeepAlive]); + + const markConnected = useCallback(async () => { + setState("connected"); + log.append({ level: "success", message: "Connected" }); + startKeepAlive(); + await onConnectedRef.current(sessionIdRef.current); + }, [log, startKeepAlive]); + + const runConnect = useCallback( + async (overrides: SessionAuthOverrides) => { + if (!host) return; + setState("connecting"); + setErrorMessage(""); + overridesRef.current = overrides; + try { + const userId = (await getCurrentUserId()) || undefined; + const sessionId = + sessionIdRef.current || + `${transport.prefix}-${host.id}-${Date.now()}`; + sessionIdRef.current = sessionId; + + log.append({ level: "info", message: `Connecting to ${host.name}…` }); + const result = await transport.connect( + sessionId, + host, + userId, + overrides, + ); + log.ingest(extractConnectionLogs(result)); + + if (result?.requiresTOTP || result?.totpRequired) { + setState("totp"); + return; + } + if (result?.requiresWarpgate || result?.warpgateRequired) { + setState("warpgate"); + return; + } + if ( + result?.requiresAuth || + result?.authRequired || + result?.code === "AUTH_REQUIRED" + ) { + setState("auth"); + return; + } + await markConnected(); + } catch (error: any) { + log.ingest(extractConnectionLogs(error)); + const msg = error?.message || "Failed to connect"; + setErrorMessage(msg); + log.append({ level: "error", message: msg }); + // Surface auth dialog rather than a dead error when the host needs creds. + if (error?.status === 401 || error?.code === "AUTH_REQUIRED") { + setState("auth"); + } else { + setState("error"); + } + } + }, + [host, transport, log, markConnected], + ); + + const connect = useCallback(() => { + sessionIdRef.current = ""; + log.clear(); + return runConnect({}); + }, [runConnect, log]); + + const retry = useCallback(() => connect(), [connect]); + + const submitTotp = useCallback( + async (code: string) => { + setState("connecting"); + try { + const result = await transport.submitTotp(sessionIdRef.current, code); + log.ingest(extractConnectionLogs(result)); + await markConnected(); + } catch (error: any) { + const msg = error?.message || "Invalid code"; + log.append({ level: "error", message: msg }); + setErrorMessage(msg); + setState("totp"); + throw error; + } + }, + [transport, log, markConnected], + ); + + const submitWarpgate = useCallback( + async (url: string, securityKey?: string) => { + if (!transport.submitWarpgate) return; + setState("connecting"); + try { + const result = await transport.submitWarpgate( + sessionIdRef.current, + url, + securityKey, + ); + log.ingest(extractConnectionLogs(result)); + await markConnected(); + } catch (error: any) { + const msg = error?.message || "Warpgate authentication failed"; + log.append({ level: "error", message: msg }); + setErrorMessage(msg); + setState("warpgate"); + throw error; + } + }, + [transport, log, markConnected], + ); + + const submitAuth = useCallback( + (overrides: SessionAuthOverrides) => runConnect(overrides), + [runConnect], + ); + + const cancelAuth = useCallback(() => { + setState("error"); + setErrorMessage("Authentication cancelled"); + }, []); + + const disconnect = useCallback(() => { + stopKeepAlive(); + if (sessionIdRef.current) { + transport.disconnect?.(sessionIdRef.current).catch(() => {}); + } + sessionIdRef.current = ""; + setState("idle"); + }, [transport, stopKeepAlive]); + + useEffect(() => { + if (options?.autoConnect && host && state === "idle") { + connect(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [host?.id, options?.autoConnect]); + + useEffect(() => stopKeepAlive, [stopKeepAlive]); + + return { + state, + errorMessage, + sessionId: sessionIdRef, + logEntries: log.entries, + logClear: log.clear, + connect, + retry, + disconnect, + // Auth dialog wiring + submitTotp, + submitWarpgate, + submitAuth, + cancelAuth, + }; +} diff --git a/app/tabs/sessions/docker/ContainerDetail.tsx b/app/tabs/sessions/docker/ContainerDetail.tsx new file mode 100644 index 0000000..57d54d1 --- /dev/null +++ b/app/tabs/sessions/docker/ContainerDetail.tsx @@ -0,0 +1,333 @@ +import { useCallback, useState } from "react"; +import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { + ArrowLeft, + Copy, + Play, + Square, + RotateCcw, + Trash2, +} from "lucide-react-native"; +import { SSHHost, DockerContainer, DockerContainerAction } from "@/types"; +import { + getDockerContainerLogs, + getDockerContainerStats, +} from "@/app/main-axios"; +import { Text, Badge, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { toast } from "@/app/utils/toast"; +import { usePolling } from "@/app/tabs/sessions/_shared"; +import { DockerConsole } from "./DockerConsole"; +import type { DockerContainerStats } from "@/types"; + +type DetailTab = "logs" | "stats" | "console"; + +function isRunning(c: DockerContainer): boolean { + return /^up|running/i.test(c.state || c.status || ""); +} +function cleanName(name: string): string { + return name.startsWith("/") ? name.slice(1) : name; +} + +/** + * Full-frame container detail: header with quick actions + a Logs / Stats / + * Console tab strip. Logs poll; Stats poll (every 2s while running); Console is + * an interactive exec shell over the docker-console WebSocket. + */ +export function ContainerDetail({ + host, + sessionId, + container, + onBack, + onAction, +}: { + host: SSHHost; + sessionId: string; + container: DockerContainer; + onBack: () => void; + onAction: (c: DockerContainer, a: DockerContainerAction) => void; +}) { + const color = useThemeColor(); + const [tab, setTab] = useState("logs"); + const running = isRunning(container); + + return ( + + {/* Header */} + + + + + + + {cleanName(container.name)} + + + {container.image} + + + + {running ? "running" : container.state || "stopped"} + + + + {/* Quick actions */} + + {running ? ( + } + label="Stop" + onPress={() => onAction(container, "stop")} + /> + ) : ( + } + label="Start" + accent + onPress={() => onAction(container, "start")} + /> + )} + } + label="Restart" + onPress={() => onAction(container, "restart")} + /> + } + label="Remove" + destructive + onPress={() => { + onAction(container, "remove"); + onBack(); + }} + /> + + + {/* Tab strip */} + + + value={tab} + onChange={setTab} + options={[ + { id: "logs", label: "Logs" }, + { id: "stats", label: "Stats" }, + { id: "console", label: "Console" }, + ]} + /> + + + {/* Tab content */} + + {tab === "logs" ? ( + + ) : tab === "stats" ? ( + + ) : ( + + )} + + + ); +} + +function ActionChip({ + icon, + label, + onPress, + accent, + destructive, +}: { + icon: React.ReactNode; + label: string; + onPress: () => void; + accent?: boolean; + destructive?: boolean; +}) { + return ( + + {icon} + + {label} + + + ); +} + +function LogsTab({ + sessionId, + container, +}: { + sessionId: string; + container: DockerContainer; +}) { + const color = useThemeColor(); + const [logs, setLogs] = useState(""); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + const text = await getDockerContainerLogs(sessionId, container.id, 300); + setLogs(text || "(no output)"); + } catch (e: any) { + setLogs(e?.message || "Failed to load logs"); + } finally { + setLoading(false); + } + }, [sessionId, container.id]); + + usePolling(load, 4000, true); + + const copy = async () => { + await Clipboard.setStringAsync(logs); + toast.success("Logs copied"); + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + + + + Copy + + + + + {logs} + + + + ); +} + +function StatsTab({ + sessionId, + container, + running, +}: { + sessionId: string; + container: DockerContainer; + running: boolean; +}) { + const color = useThemeColor(); + const [stats, setStats] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + if (!running) return; + try { + const data = await getDockerContainerStats(sessionId, container.id); + setStats(data); + setError(""); + } catch (e: any) { + setError(e?.message || "Failed to load stats"); + } + }, [sessionId, container.id, running]); + + usePolling(load, 2000, running); + + if (!running) { + return ( + + + Container is not running — no live stats. + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!stats) { + return ( + + + + ); + } + + return ( + + + + + + + + ); +} + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( + + + {label} + + + {value} + + {sub ? ( + {sub} + ) : null} + + ); +} diff --git a/app/tabs/sessions/docker/Docker.tsx b/app/tabs/sessions/docker/Docker.tsx new file mode 100644 index 0000000..4266e89 --- /dev/null +++ b/app/tabs/sessions/docker/Docker.tsx @@ -0,0 +1,395 @@ +import { useCallback, useMemo, useState } from "react"; +import { View, ScrollView, Pressable, RefreshControl } from "react-native"; +import { + Container as ContainerIcon, + Search, + RefreshCw, + Play, + Square, + RotateCcw, + Pause, + Trash2, + MoreVertical, +} from "lucide-react-native"; +import { SSHHost, DockerContainer, DockerContainerAction } from "@/types"; +import { + dockerConnect, + dockerConnectTOTP, + dockerKeepAlive, + dockerDisconnect, + dockerValidate, + getDockerContainers, + dockerContainerAction, +} from "@/app/main-axios"; +import { Text, Input, Badge, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { + SessionFrame, + usePolling, + useSessionConnect, + AuthDialogs, + ContextSheet, + type ContextAction, +} from "@/app/tabs/sessions/_shared"; +import { ContainerDetail } from "./ContainerDetail"; + +interface DockerProps { + host: SSHHost; + isVisible: boolean; +} + +type StatusFilter = "all" | "running" | "stopped"; + +function isRunning(c: DockerContainer): boolean { + return /^up|running/i.test(c.state || c.status || ""); +} + +function cleanName(name: string): string { + return name.startsWith("/") ? name.slice(1) : name; +} + +export function Docker({ host, isVisible }: DockerProps) { + const color = useThemeColor(); + const [containers, setContainers] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [selected, setSelected] = useState(null); + const [menuFor, setMenuFor] = useState(null); + const [busyId, setBusyId] = useState(null); + const [dockerAvailable, setDockerAvailable] = useState(null); + const [initialLoadDone, setInitialLoadDone] = useState(false); + + const loadContainers = useCallback( + async (sessionId: string) => { + try { + const list = await getDockerContainers(sessionId); + setContainers(list); + } catch (e: any) { + toast.error(e?.message || "Failed to load containers"); + } + }, + [], + ); + + const connectTransport = useMemo( + () => ({ + prefix: "docker", + connect: (sessionId: string, h: SSHHost) => + dockerConnect(sessionId, h.id), + submitTotp: (sessionId: string, code: string) => + dockerConnectTOTP(sessionId, code), + keepAlive: (sessionId: string) => dockerKeepAlive(sessionId), + disconnect: (sessionId: string) => dockerDisconnect(sessionId), + }), + [], + ); + + const onConnected = useCallback( + async (sessionId: string) => { + setInitialLoadDone(false); + // Confirm Docker is actually installed before listing. + try { + const v = await dockerValidate(sessionId); + setDockerAvailable(v.available); + if (!v.available) { + setInitialLoadDone(true); + return; + } + } catch { + setDockerAvailable(true); // assume available; list call will surface real errors + } + await loadContainers(sessionId); + setInitialLoadDone(true); + }, + [loadContainers], + ); + + const conn = useSessionConnect( + isVisible ? host : null, + connectTransport, + onConnected, + { autoConnect: true, keepAliveMs: 30000 }, + ); + + // Poll the container list while connected and visible. + usePolling( + () => { + if (conn.state === "connected" && conn.sessionId.current) { + loadContainers(conn.sessionId.current); + } + }, + 5000, + isVisible && conn.state === "connected", + ); + + const runAction = useCallback( + async (c: DockerContainer, action: DockerContainerAction) => { + const sessionId = conn.sessionId.current; + if (!sessionId) return; + setBusyId(c.id); + try { + await dockerContainerAction(sessionId, c.id, action); + toast.success(`${action} · ${cleanName(c.name)}`); + await loadContainers(sessionId); + } catch (e: any) { + toast.error(e?.message || `Failed to ${action}`); + } finally { + setBusyId(null); + } + }, + [conn.sessionId, loadContainers], + ); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return containers.filter((c) => { + if (statusFilter === "running" && !isRunning(c)) return false; + if (statusFilter === "stopped" && isRunning(c)) return false; + if (!q) return true; + return ( + cleanName(c.name).toLowerCase().includes(q) || + c.image?.toLowerCase().includes(q) || + c.id?.toLowerCase().includes(q) + ); + }); + }, [containers, query, statusFilter]); + + if (!isVisible) return null; + + // Detail view takes over the whole frame. + if (selected) { + return ( + setSelected(null)} + onAction={runAction} + /> + ); + } + + // Map connect-state → SessionFrame status. + const frameStatus = + conn.state === "connecting" || conn.state === "idle" || (conn.state === "connected" && !initialLoadDone) + ? "loading" + : conn.state === "error" + ? "error" + : dockerAvailable === false + ? "empty" + : filtered.length === 0 + ? "empty" + : "ready"; + + return ( + <> + } + onRetry={conn.state === "error" ? conn.retry : undefined} + logEntries={conn.logEntries} + isConnecting={conn.state === "connecting" || conn.state === "idle"} + isConnected={conn.state === "connected"} + hasConnectionError={conn.state === "error"} + onLogClear={conn.logClear} + headerActions={ + + conn.sessionId.current && + loadContainers(conn.sessionId.current) + } + hitSlop={8} + className="p-1.5" + > + + + } + toolbar={ + conn.state === "connected" && dockerAvailable !== false ? ( + + } + /> + + value={statusFilter} + onChange={setStatusFilter} + options={[ + { id: "all", label: "All" }, + { id: "running", label: "Running" }, + { id: "stopped", label: "Stopped" }, + ]} + /> + + ) : undefined + } + > + { + setRefreshing(true); + if (conn.sessionId.current) + await loadContainers(conn.sessionId.current); + setRefreshing(false); + }} + /> + } + > + {filtered.map((c) => ( + setSelected(c)} + onMenu={() => setMenuFor(c)} + /> + ))} + + + + {/* Container action menu */} + setMenuFor(null)} + title={menuFor ? cleanName(menuFor.name) : undefined} + subtitle={menuFor?.image} + actions={buildActions(menuFor, color, runAction)} + /> + + {/* Shared auth dialogs (TOTP / Warpgate / interactive) */} + + + ); +} + +function buildActions( + c: DockerContainer | null, + color: ReturnType, + run: (c: DockerContainer, a: DockerContainerAction) => void, +): (ContextAction | null)[] { + if (!c) return []; + const running = isRunning(c); + const fg = color("foreground") ?? "#fafafa"; + return [ + running + ? null + : { + key: "start", + icon: , + label: "Start", + onPress: () => run(c, "start"), + }, + running + ? { + key: "stop", + icon: , + label: "Stop", + onPress: () => run(c, "stop"), + } + : null, + { + key: "restart", + icon: , + label: "Restart", + onPress: () => run(c, "restart"), + }, + running + ? { + key: "pause", + icon: , + label: "Pause", + onPress: () => run(c, "pause"), + } + : { + key: "unpause", + icon: , + label: "Unpause", + onPress: () => run(c, "unpause"), + }, + { + key: "remove", + icon: , + label: "Remove", + destructive: true, + onPress: () => run(c, "remove"), + }, + ]; +} + +function ContainerRow({ + container, + busy, + onPress, + onMenu, +}: { + container: DockerContainer; + busy: boolean; + onPress: () => void; + onMenu: () => void; +}) { + const color = useThemeColor(); + const running = isRunning(container); + return ( + + + + + + + {cleanName(container.name)} + + + {container.image} + + + + {running ? "running" : container.state || "stopped"} + + + + + + + ); +} diff --git a/app/tabs/sessions/docker/DockerConsole.tsx b/app/tabs/sessions/docker/DockerConsole.tsx new file mode 100644 index 0000000..98bd89b --- /dev/null +++ b/app/tabs/sessions/docker/DockerConsole.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { View, ActivityIndicator, Pressable } from "react-native"; +import { WebView } from "react-native-webview"; +import { RotateCcw } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + getCookie, + getDockerConsoleWebSocketUrl, +} from "@/app/main-axios"; +import type { SSHHost, DockerContainer } from "@/types"; + +/** + * Interactive `docker exec` console. Renders a minimal xterm.js inside a WebView + * (same approach as the SSH Terminal) and bridges it to the backend docker + * console WebSocket (port 30009). Connect → input/resize/output round-trip. + * + * Kept deliberately small: this is a shell into a container, not the full + * terminal experience (no command history / snippets), so it skips the heavier + * Terminal machinery. + */ +export function DockerConsole({ + host, + container, + isVisible, +}: { + host: SSHHost; + container: DockerContainer; + isVisible: boolean; +}) { + const color = useThemeColor(); + const webViewRef = useRef(null); + const wsRef = useRef(null); + const [status, setStatus] = useState< + "connecting" | "connected" | "error" | "closed" + >("connecting"); + const [errorMessage, setErrorMessage] = useState(""); + const [webViewKey, setWebViewKey] = useState(0); + + const send = useCallback((msg: object) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(msg)); + } + }, []); + + const connect = useCallback(async () => { + setStatus("connecting"); + setErrorMessage(""); + const token = await getCookie("jwt"); + if (!token) { + setStatus("error"); + setErrorMessage("Not authenticated"); + return; + } + try { + wsRef.current?.close(); + } catch {} + + const ws = new WebSocket(getDockerConsoleWebSocketUrl(token)); + wsRef.current = ws; + + ws.onopen = () => { + send({ + type: "connect", + data: { + hostConfig: { id: host.id, enableDocker: true }, + containerId: container.id, + cols: 80, + rows: 24, + }, + }); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg.type === "output") { + // Feed raw output into xterm in the WebView. + webViewRef.current?.injectJavaScript( + `window.__write(${JSON.stringify(msg.data)}); true;`, + ); + } else if (msg.type === "connected") { + setStatus("connected"); + } else if (msg.type === "error") { + setStatus("error"); + setErrorMessage(msg.message || "Console error"); + } else if (msg.type === "disconnected") { + setStatus("closed"); + } + } catch {} + }; + + ws.onerror = () => { + setStatus("error"); + setErrorMessage("Connection failed"); + }; + + ws.onclose = () => { + setStatus((s) => (s === "error" ? s : "closed")); + }; + }, [host.id, container.id, send]); + + useEffect(() => { + connect(); + return () => { + try { + send({ type: "disconnect" }); + wsRef.current?.close(); + } catch {} + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [webViewKey]); + + const onWebViewMessage = useCallback( + (event: { nativeEvent: { data: string } }) => { + try { + const msg = JSON.parse(event.nativeEvent.data); + if (msg.type === "input") { + send({ type: "input", data: msg.data }); + } else if (msg.type === "resize") { + send({ type: "resize", data: { cols: msg.cols, rows: msg.rows } }); + } + } catch {} + }, + [send], + ); + + const reconnect = () => setWebViewKey((k) => k + 1); + + if (!isVisible) return null; + + return ( + + + {status !== "connected" ? ( + + {status === "connecting" ? ( + <> + + + Attaching to {container.name}… + + + ) : ( + <> + + {errorMessage || "Console disconnected"} + + + + Reconnect + + + )} + + ) : null} + + ); +} + +const CONSOLE_HTML = ` + + + + + + + + + +
+ + +`; diff --git a/app/tabs/sessions/file-manager/ContextMenu.tsx b/app/tabs/sessions/file-manager/ContextMenu.tsx index 3a2bd8a..9ce4d26 100644 --- a/app/tabs/sessions/file-manager/ContextMenu.tsx +++ b/app/tabs/sessions/file-manager/ContextMenu.tsx @@ -68,18 +68,18 @@ export function ContextMenu({ supportedOrientations={["portrait", "landscape"]} > - + {}}> - - + + {fileName} @@ -90,103 +90,109 @@ export function ContextMenu({ {onView && fileType === "file" && ( handleAction(onView)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - View + View )} {onEdit && fileType === "file" && ( handleAction(onEdit)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Edit + Edit )} handleAction(onRename)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Rename + Rename handleAction(onCopy)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Copy + Copy handleAction(onCut)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Cut + Cut {onDownload && fileType === "file" && ( handleAction(onDownload)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Download + + Download + )} {onPermissions && ( handleAction(onPermissions)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Permissions + + Permissions + )} {onCompress && ( handleAction(onCompress)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Compress + + Compress + )} {onExtract && isArchive && ( handleAction(onExtract)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Extract + Extract )} handleAction(onDelete)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Delete + Delete diff --git a/app/tabs/sessions/file-manager/FileItem.tsx b/app/tabs/sessions/file-manager/FileItem.tsx index c7a093a..15ec27f 100644 --- a/app/tabs/sessions/file-manager/FileItem.tsx +++ b/app/tabs/sessions/file-manager/FileItem.tsx @@ -55,14 +55,14 @@ export function FileItem({ {selectionMode && ( {isSelected && ( - + )} @@ -73,25 +73,27 @@ export function FileItem({ - + {name} - + {type === "directory" ? ( - Folder + Folder ) : ( <> {size !== undefined && ( - + {formatFileSize(size)} )} {modified && ( <> {size !== undefined && ( - + + • + )} - + {formatDate(modified)} diff --git a/app/tabs/sessions/file-manager/FileList.tsx b/app/tabs/sessions/file-manager/FileList.tsx index abcd997..3286b7a 100644 --- a/app/tabs/sessions/file-manager/FileList.tsx +++ b/app/tabs/sessions/file-manager/FileList.tsx @@ -1,7 +1,6 @@ -import { ScrollView, RefreshControl, View, Text } from "react-native"; +import { ScrollView, RefreshControl, Text } from "react-native"; import { FileItem } from "./FileItem"; import { sortFiles } from "./utils/fileUtils"; -import { getColumnCount } from "@/app/utils/responsive"; interface FileListItem { name: string; @@ -48,7 +47,7 @@ export function FileList({ if (!isLoading && files.length === 0) { return ( } > - This folder is empty + + This folder is empty + ); } return ( 0 ? toolbarHeight + 12 : 12, }} diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 944f3fa..884f303 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -1,35 +1,61 @@ import { useState, - useEffect, - useRef, useCallback, + useMemo, forwardRef, useImperativeHandle, + useRef, } from "react"; import { View, + ScrollView, + Pressable, + RefreshControl, Alert, - TextInput, Modal, - Text, - TouchableOpacity, - ActivityIndicator, + TextInput, KeyboardAvoidingView, Platform, + Animated, + ActivityIndicator, } from "react-native"; +import { Swipeable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Server } from "lucide-react-native"; -import { SSHHost } from "@/types"; -import { useOrientation } from "@/app/utils/orientation"; -import { getResponsivePadding, getTabBarHeight } from "@/app/utils/responsive"; +import * as Clipboard from "expo-clipboard"; +import * as DocumentPicker from "expo-document-picker"; import { - BORDERS, - BORDER_COLORS, - RADIUS, - BACKGROUNDS, -} from "@/app/constants/designTokens"; + ChevronRight, + File as FileIcon, + Folder, + Link as LinkIcon, + Search, + RefreshCw, + FolderPlus, + FilePlus, + ClipboardPaste, + Eye, + Pencil, + Copy, + Scissors, + Trash2, + Lock, + ClipboardCopy, + MoreVertical, + ChevronLeft, + ArrowUpDown, + Upload, + Archive, + CheckSquare, + Square, + X, +} from "lucide-react-native"; +import { SSHHost, SessionAuthOverrides } from "@/types"; import { connectSSH, + verifySSHTOTP, + verifySSHWarpgate, + keepSSHAlive, + disconnectSSH, listSSHFiles, readSSHFile, writeSSHFile, @@ -39,22 +65,35 @@ import { renameSSHItem, copySSHItem, moveSSHItem, - verifySSHTOTP, - keepSSHAlive, + changeSSHPermissions, identifySSHSymlink, + uploadSSHFile, + extractSSHArchive, } from "@/app/main-axios"; -import { FileList } from "@/app/tabs/sessions/file-manager/FileList"; -import { FileManagerHeader } from "@/app/tabs/sessions/file-manager/FileManagerHeader"; -import { FileManagerToolbar } from "@/app/tabs/sessions/file-manager/FileManagerToolbar"; -import { ContextMenu } from "@/app/tabs/sessions/file-manager/ContextMenu"; -import { FileViewer } from "@/app/tabs/sessions/file-manager/FileViewer"; +import { Text, Input, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { + SessionFrame, + useSessionConnect, + AuthDialogs, + ContextSheet, + type ContextAction, +} from "@/app/tabs/sessions/_shared"; +import { FileViewer } from "./FileViewer"; +import { PermissionsDialog } from "./PermissionsDialog"; import { joinPath, isTextFile, isArchiveFile, -} from "@/app/tabs/sessions/file-manager/utils/fileUtils"; -import { showToast } from "@/app/utils/toast"; -import { TOTPDialog } from "@/app/tabs/dialogs"; + formatFileSize, + formatDate, + getFileIconColor, + breadcrumbsFromPath, + getBreadcrumbLabel, + getParentPath, + sortFiles, +} from "./utils/fileUtils"; interface FileManagerProps { host: SSHHost; @@ -69,674 +108,1078 @@ interface FileItem { size?: number; modified?: string; permissions?: string; + owner?: string; + group?: string; } export interface FileManagerHandle { handleDisconnect: () => void; } +type SortBy = "name" | "size" | "modified"; + export const FileManager = forwardRef( - ({ host, sessionId, isVisible }, ref) => { + ({ host, isVisible }, ref) => { + const color = useThemeColor(); const insets = useSafeAreaInsets(); - const { width, isLandscape } = useOrientation(); + const [currentPath, setCurrentPath] = useState("/"); const [files, setFiles] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isConnected, setIsConnected] = useState(false); - const [sshSessionId, setSshSessionId] = useState(null); + const [loadingDir, setLoadingDir] = useState(false); + const [, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + + const [sortBy, setSortBy] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [selectionMode, setSelectionMode] = useState(false); - const [selectedFiles, setSelectedFiles] = useState([]); const [clipboard, setClipboard] = useState<{ files: string[]; operation: "copy" | "cut" | null; }>({ files: [], operation: null }); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - file: FileItem | null; - }>({ visible: false, file: null }); - const [totpDialog, setTotpDialog] = useState(false); - const [totpCode, setTotpCode] = useState(""); - const [createDialog, setCreateDialog] = useState<{ - visible: boolean; - type: "file" | "folder" | null; - }>({ visible: false, type: null }); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + + const [menuFile, setMenuFile] = useState(null); + const [moreMenuVisible, setMoreMenuVisible] = useState(false); + const [permsFile, setPermsFile] = useState(null); + const [createDialog, setCreateDialog] = useState<"file" | "folder" | null>(null); const [createName, setCreateName] = useState(""); - const [renameDialog, setRenameDialog] = useState<{ - visible: boolean; - file: FileItem | null; - }>({ visible: false, file: null }); + const [renameTarget, setRenameTarget] = useState(null); const [renameName, setRenameName] = useState(""); - const [fileViewer, setFileViewer] = useState<{ - visible: boolean; - file: FileItem | null; + const [viewer, setViewer] = useState<{ + file: FileItem; content: string; - }>({ visible: false, file: null, content: "" }); - - const keepaliveInterval = useRef(null); - - const connectToSSH = useCallback(async () => { - try { - setIsLoading(true); - const response = await connectSSH(sessionId, { - hostId: host.id, - ip: host.ip, - port: host.port, - username: host.username, - password: host.authType === "password" ? host.password : undefined, - sshKey: host.authType === "key" ? host.key : undefined, - keyPassword: host.keyPassword, - authType: host.authType, - credentialId: host.credentialId, - userId: host.userId, - forceKeyboardInteractive: host.forceKeyboardInteractive, - overrideCredentialUsername: host.overrideCredentialUsername, - jumpHosts: host.jumpHosts, - }); - - if (response.requires_totp) { - setTotpDialog(true); - return; - } - - setSshSessionId(sessionId); - setIsConnected(true); - - keepaliveInterval.current = setInterval(() => { - keepSSHAlive(sessionId).catch(() => {}); - }, 30000); - - await loadDirectory(host.defaultPath || "/"); - } catch (error: any) { - showToast.error(error.message || "Failed to connect to SSH"); - } finally { - setIsLoading(false); - } - }, [host, sessionId]); + } | null>(null); - const handleTOTPVerify = async (code: string) => { - try { - await verifySSHTOTP(sessionId, code); - setTotpDialog(false); - setTotpCode(""); - setSshSessionId(sessionId); - setIsConnected(true); - - keepaliveInterval.current = setInterval(() => { - keepSSHAlive(sessionId).catch(() => {}); - }, 30000); - - await loadDirectory(host.defaultPath || "/"); - } catch (error: any) { - showToast.error(error.message || "Invalid TOTP code"); - } - }; + // --- Transport --- + const transport = useMemo( + () => ({ + prefix: "fm", + connect: ( + sid: string, + h: SSHHost, + userId: string | undefined, + overrides: SessionAuthOverrides, + ) => + connectSSH(sid, { + hostId: h.id, + ip: h.ip, + port: h.port, + username: h.username, + password: + overrides.userProvidedPassword ?? + (h.authType === "password" ? h.password : undefined), + sshKey: + overrides.userProvidedSshKey ?? + (h.authType === "key" ? h.key : undefined), + keyPassword: overrides.userProvidedKeyPassword ?? h.keyPassword, + authType: h.authType, + credentialId: h.credentialId, + userId, + forceKeyboardInteractive: h.forceKeyboardInteractive, + overrideCredentialUsername: h.overrideCredentialUsername, + jumpHosts: h.jumpHosts, + }), + submitTotp: (sid: string, code: string) => verifySSHTOTP(sid, code), + submitWarpgate: (sid: string, url: string, key?: string) => + verifySSHWarpgate(sid, url, key), + keepAlive: (sid: string) => keepSSHAlive(sid), + disconnect: (sid: string) => disconnectSSH(sid), + }), + [], + ); const loadDirectory = useCallback( - async (path: string) => { - if (!sessionId) return; - + async (path: string, sid?: string) => { + const session = sid || conn.sessionId.current; + if (!session) return; + setBusy(true); + setLoadingDir(true); try { - setIsLoading(true); - const response = await listSSHFiles(sessionId, path); - setFiles(response.files || []); + const response = await listSSHFiles(session, path); + setFiles((response.files as FileItem[]) || []); setCurrentPath(response.path || path); - } catch (error: any) { - showToast.error(error.message || "Failed to load directory"); + setSelectedFiles(new Set()); + setSelectionMode(false); + } catch (e: any) { + toast.error(e?.message || "Failed to load directory"); } finally { - setIsLoading(false); + setBusy(false); + setLoadingDir(false); } }, - [sessionId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const onConnected = useCallback( + (sid: string) => loadDirectory(host.defaultPath || "/", sid), + [host.defaultPath, loadDirectory], + ); + + const conn = useSessionConnect( + isVisible && host.enableFileManager ? host : null, + transport, + onConnected, + { autoConnect: true, keepAliveMs: 30000 }, ); - const handleFilePress = async (file: FileItem) => { + useImperativeHandle(ref, () => ({ + handleDisconnect: () => conn.disconnect(), + })); + + // --- File interactions --- + const openFile = async (file: FileItem) => { + if (selectionMode) { + toggleSelect(file.path); + return; + } if (file.type === "link") { try { - setIsLoading(true); - const symlinkInfo = await identifySSHSymlink(sessionId!, file.path); - - if (symlinkInfo.type === "directory") { - await loadDirectory(symlinkInfo.target); - } else if (isTextFile(symlinkInfo.target)) { - const targetFile: FileItem = { - name: file.name, - path: symlinkInfo.target, - type: "file", - }; - await handleViewFile(targetFile); + setBusy(true); + const info = await identifySSHSymlink(conn.sessionId.current, file.path); + if (info.type === "directory") { + await loadDirectory(info.target); + } else if (isTextFile(info.target)) { + await viewFile({ ...file, path: info.target, type: "file" }); } else { - showToast.info("File type not supported for viewing"); + toast.info("File type not supported for viewing"); } - } catch (error: any) { - showToast.error(error.message || "Failed to follow symlink"); + } catch (e: any) { + toast.error(e?.message || "Failed to follow symlink"); } finally { - setIsLoading(false); + setBusy(false); } return; } - if (file.type === "directory") { loadDirectory(file.path); } else { - handleViewFile(file); + viewFile(file); } }; - const handleFileLongPress = (file: FileItem) => { - setContextMenu({ visible: true, file }); - }; - - const handleViewFile = async (file: FileItem) => { + const viewFile = async (file: FileItem) => { try { - setIsLoading(true); - const response = await readSSHFile(sessionId!, file.path); - setFileViewer({ visible: true, file, content: response.content }); - } catch (error: any) { - showToast.error(error.message || "Failed to read file"); + setBusy(true); + const response = await readSSHFile(conn.sessionId.current, file.path); + setViewer({ file, content: response.content }); + } catch (e: any) { + toast.error(e?.message || "Failed to read file"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleSaveFile = async (content: string) => { - if (!fileViewer.file) return; - - try { - await writeSSHFile(sessionId!, fileViewer.file.path, content, host.id); - showToast.success("File saved successfully"); - await loadDirectory(currentPath); - } catch (error: any) { - throw new Error(error.message || "Failed to save file"); - } - }; - - const handleCreateFolder = () => { - setCreateDialog({ visible: true, type: "folder" }); - setCreateName(""); + const saveFile = async (content: string) => { + if (!viewer) return; + await writeSSHFile(conn.sessionId.current, viewer.file.path, content, host.id); + toast.success("File saved"); + await loadDirectory(currentPath); }; - const handleCreateFile = () => { - setCreateDialog({ visible: true, type: "file" }); - setCreateName(""); - }; - - const handleCreateConfirm = async () => { - if (!createDialog.type || !createName.trim()) return; - + const confirmCreate = async () => { + if (!createDialog || !createName.trim()) return; try { - setIsLoading(true); - if (createDialog.type === "folder") { - await createSSHFolder(sessionId!, currentPath, createName, host.id); - showToast.success("Folder created successfully"); + setBusy(true); + if (createDialog === "folder") { + await createSSHFolder(conn.sessionId.current, currentPath, createName, host.id); } else { - await createSSHFile(sessionId!, currentPath, createName, "", host.id); - showToast.success("File created successfully"); + await createSSHFile(conn.sessionId.current, currentPath, createName, "", host.id); } - setCreateDialog({ visible: false, type: null }); + toast.success(`${createDialog === "folder" ? "Folder" : "File"} created`); + setCreateDialog(null); setCreateName(""); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to create item"); + } catch (e: any) { + toast.error(e?.message || "Failed to create"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleRename = (file: FileItem) => { - setRenameDialog({ visible: true, file }); - setRenameName(file.name); - }; - - const handleRenameConfirm = async () => { - if (!renameDialog.file || !renameName.trim()) return; - + const confirmRename = async () => { + if (!renameTarget || !renameName.trim()) return; try { - setIsLoading(true); - await renameSSHItem( - sessionId!, - renameDialog.file.path, - renameName, - host.id, - ); - showToast.success("Item renamed successfully"); - setRenameDialog({ visible: false, file: null }); + setBusy(true); + await renameSSHItem(conn.sessionId.current, renameTarget.path, renameName, host.id); + toast.success("Renamed"); + setRenameTarget(null); setRenameName(""); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to rename item"); + } catch (e: any) { + toast.error(e?.message || "Failed to rename"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleCopy = (file?: FileItem) => { - const filesToCopy = file ? [file.path] : selectedFiles; - setClipboard({ files: filesToCopy, operation: "copy" }); - setSelectionMode(false); - setSelectedFiles([]); - showToast.success(`${filesToCopy.length} item(s) copied`); + const doDelete = (file: FileItem) => { + Alert.alert("Delete", `Delete "${file.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setBusy(true); + await deleteSSHItem( + conn.sessionId.current, + file.path, + file.type === "directory", + host.id, + ); + toast.success("Deleted"); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to delete"); + } finally { + setBusy(false); + } + }, + }, + ]); }; - const handleCut = (file?: FileItem) => { - const filesToCut = file ? [file.path] : selectedFiles; - setClipboard({ files: filesToCut, operation: "cut" }); - setSelectionMode(false); - setSelectedFiles([]); - showToast.success(`${filesToCut.length} item(s) cut`); + const doDeleteSelected = () => { + const count = selectedFiles.size; + Alert.alert("Delete", `Delete ${count} item${count !== 1 ? "s" : ""}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setBusy(true); + for (const path of selectedFiles) { + const file = files.find((f) => f.path === path); + await deleteSSHItem( + conn.sessionId.current, + path, + file?.type === "directory", + host.id, + ); + } + toast.success(`${count} item${count !== 1 ? "s" : ""} deleted`); + setSelectionMode(false); + setSelectedFiles(new Set()); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to delete"); + } finally { + setBusy(false); + } + }, + }, + ]); }; - const handlePaste = async () => { - if (clipboard.files.length === 0 || !clipboard.operation) return; - + const doPaste = async () => { + if (!clipboard.files.length || !clipboard.operation) return; try { - setIsLoading(true); - for (const filePath of clipboard.files) { + setBusy(true); + for (const p of clipboard.files) { if (clipboard.operation === "copy") { - await copySSHItem(sessionId!, filePath, currentPath, host.id); + await copySSHItem(conn.sessionId.current, p, currentPath, host.id); } else { await moveSSHItem( - sessionId!, - filePath, - joinPath(currentPath, filePath.split("/").pop()!), + conn.sessionId.current, + p, + joinPath(currentPath, p.split("/").pop()!), host.id, ); } } - showToast.success(`${clipboard.files.length} item(s) pasted`); + toast.success(`${clipboard.files.length} item(s) pasted`); setClipboard({ files: [], operation: null }); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to paste items"); + } catch (e: any) { + toast.error(e?.message || "Failed to paste"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleDelete = async (file?: FileItem) => { - const filesToDelete = file - ? [file] - : files.filter((f) => selectedFiles.includes(f.path)); - - Alert.alert( - "Confirm Delete", - `Are you sure you want to delete ${filesToDelete.length} item(s)?`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - try { - setIsLoading(true); - for (const fileItem of filesToDelete) { - await deleteSSHItem( - sessionId!, - fileItem.path, - fileItem.type === "directory", - host.id, - ); - } - showToast.success(`${filesToDelete.length} item(s) deleted`); - setSelectionMode(false); - setSelectedFiles([]); - await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to delete items"); - } finally { - setIsLoading(false); - } - }, - }, - ], - ); + const applyPermissions = async (octal: string) => { + if (!permsFile) return; + try { + await changeSSHPermissions(conn.sessionId.current, permsFile.path, octal, host.id); + toast.success(`Permissions set to ${octal}`); + setPermsFile(null); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to change permissions"); + } }; - const handleSelectToggle = (path: string) => { - setSelectedFiles((prev) => - prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], - ); + const doUpload = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + setBusy(true); + const response = await fetch(asset.uri); + const buffer = await response.arrayBuffer(); + const base64 = btoa( + String.fromCharCode(...new Uint8Array(buffer)), + ); + await uploadSSHFile(conn.sessionId.current, currentPath, asset.name, base64, host.id); + toast.success(`Uploaded ${asset.name}`); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to upload file"); + } finally { + setBusy(false); + } }; - const handleCancelSelection = () => { + const doExtract = async (file: FileItem) => { + try { + setBusy(true); + await extractSSHArchive(conn.sessionId.current, file.path, currentPath, host.id); + toast.success("Extracted successfully"); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to extract archive"); + } finally { + setBusy(false); + } + }; + + // --- Selection helpers --- + const toggleSelect = (path: string) => { + setSelectedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const activateSelection = (file: FileItem) => { + setSelectionMode(true); + setSelectedFiles(new Set([file.path])); + }; + + const cancelSelection = () => { setSelectionMode(false); - setSelectedFiles([]); + setSelectedFiles(new Set()); }; - useEffect(() => { - connectToSSH(); + const copySelected = () => { + setClipboard({ files: Array.from(selectedFiles), operation: "copy" }); + cancelSelection(); + toast.success(`${selectedFiles.size} item(s) copied`); + }; - return () => { - if (keepaliveInterval.current) { - clearInterval(keepaliveInterval.current); - } - }; - }, [connectToSSH]); + const cutSelected = () => { + setClipboard({ files: Array.from(selectedFiles), operation: "cut" }); + cancelSelection(); + toast.success(`${selectedFiles.size} item(s) cut`); + }; - useImperativeHandle(ref, () => ({ - handleDisconnect: () => { - if (keepaliveInterval.current) { - clearInterval(keepaliveInterval.current); + // --- Sort cycling: tap once to switch field, tap again to flip order --- + const SORT_FIELDS: SortBy[] = ["name", "size", "modified"]; + const cycleSortBy = () => { + setSortBy((prevField) => { + setSortOrder((prevOrder) => { + if (prevOrder === "asc") return "desc"; + // was desc → advance to next field, reset to asc + return "asc"; + }); + // Only advance field when flipping from desc back to asc + // We do this by reading sortOrder directly (stale closure is fine here — + // we just need the value at the moment of the press) + if (sortOrder === "desc") { + const idx = SORT_FIELDS.indexOf(prevField); + return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]; } - setIsConnected(false); - }, - })); + return prevField; + }); + }; + + const sortLabel: Record = { name: "Name", size: "Size", modified: "Date" }; + + // --- Filtered + sorted files --- + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = q ? files.filter((f) => f.name.toLowerCase().includes(q)) : files; + return sortFiles(list, sortBy, sortOrder); + }, [files, query, sortBy, sortOrder]); + + if (!isVisible) return null; if (!host.enableFileManager) { return ( - - - - File Manager Disabled - - - File Manager is not enabled for this host. Contact your - administrator to enable it. - - + ); } - if (!isConnected) { - return ( - - - Connecting to {host.name}... - - { - setTotpDialog(false); - setTotpCode(""); - }} - prompt="Two-Factor Authentication" - isPasswordPrompt={false} - /> - - ); - } + const frameStatus = + conn.state === "connecting" || conn.state === "idle" + ? "loading" + : conn.state === "error" + ? "error" + : "ready"; - const padding = getResponsivePadding(isLandscape); - const tabBarHeight = getTabBarHeight(isLandscape); + const breadcrumbs = breadcrumbsFromPath(currentPath); + const isConnected = conn.state === "connected"; - const toolbarPaddingVertical = isLandscape ? 8 : 12; - const toolbarContentHeight = isLandscape ? 34 : 44; - const toolbarBorderHeight = 2; - const effectiveToolbarHeight = - selectionMode || clipboard.files.length > 0 - ? toolbarPaddingVertical * 2 + - toolbarContentHeight + - toolbarBorderHeight - : 0; + // More-menu actions + const moreActions: (ContextAction | null)[] = [ + { + key: "new-folder", + icon: , + label: "New Folder", + onPress: () => setCreateDialog("folder"), + }, + { + key: "new-file", + icon: , + label: "New File", + onPress: () => setCreateDialog("file"), + }, + { + key: "upload", + icon: , + label: "Upload File", + onPress: doUpload, + }, + clipboard.files.length > 0 + ? { + key: "paste", + icon: , + label: `Paste ${clipboard.files.length} item${clipboard.files.length !== 1 ? "s" : ""}`, + onPress: doPaste, + } + : null, + { + key: "refresh", + icon: , + label: "Refresh", + onPress: () => loadDirectory(currentPath), + }, + ]; return ( - - loadDirectory(currentPath)} - onCreateFolder={handleCreateFolder} - onCreateFile={handleCreateFile} - onMenuPress={() => setSelectionMode(true)} - isLoading={isLoading} - isLandscape={isLandscape} - /> - - loadDirectory(currentPath)} - isLandscape={isLandscape} - width={width} - toolbarHeight={effectiveToolbarHeight} - /> - - handleCopy()} - onCut={() => handleCut()} - onPaste={handlePaste} - onDelete={() => handleDelete()} - onCancelSelection={handleCancelSelection} - onCancelClipboard={() => setClipboard({ files: [], operation: null })} - clipboardCount={clipboard.files.length} - clipboardOperation={clipboard.operation} - isLandscape={isLandscape} - bottomInset={insets.bottom} - tabBarHeight={tabBarHeight} - /> + <> + + {/* Nav row */} + + {/* Back / up */} + loadDirectory(getParentPath(currentPath))} + hitSlop={8} + className="p-1.5 rounded active:bg-muted/40" + disabled={currentPath === "/"} + style={{ opacity: currentPath === "/" ? 0.35 : 1 }} + > + + - {contextMenu.file && ( - setContextMenu({ visible: false, file: null })} - fileName={contextMenu.file.name} - fileType={contextMenu.file.type} - onView={ - contextMenu.file.type === "file" - ? () => handleViewFile(contextMenu.file!) - : undefined - } - onEdit={ - contextMenu.file.type === "file" - ? () => handleViewFile(contextMenu.file!) - : undefined - } - onRename={() => handleRename(contextMenu.file!)} - onCopy={() => handleCopy(contextMenu.file!)} - onCut={() => handleCut(contextMenu.file!)} - onDelete={() => handleDelete(contextMenu.file!)} - isArchive={isArchiveFile(contextMenu.file.name)} - /> - )} + {/* Breadcrumb scroll */} + + {breadcrumbs.map((bc, i) => ( + + {i > 0 ? ( + + ) : null} + loadDirectory(bc)} + className="px-1 py-1 rounded active:bg-muted/40" + hitSlop={4} + > + + {getBreadcrumbLabel(bc)} + + + + ))} + - - - - - - Create New{" "} - {createDialog.type === "folder" ? "Folder" : "File"} - - - - { - setCreateDialog({ visible: false, type: null }); - setCreateName(""); - }} - className="flex-1 py-3" - style={{ - backgroundColor: BACKGROUNDS.BUTTON, - borderWidth: BORDERS.MAJOR, - borderColor: BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} + {/* Sort toggle */} + - - Cancel + + + {sortLabel[sortBy]} {sortOrder === "asc" ? "↑" : "↓"} - - + + {/* More menu */} + setMoreMenuVisible(true)} + hitSlop={8} + className="p-1.5 rounded active:bg-muted/40" > - - Create - - + + + + + {/* Search row */} + + } + className="h-8 text-xs" + /> - - - - - - - - 0 ? 80 : 16 }} + refreshControl={ + { + setRefreshing(true); + await loadDirectory(currentPath); + setRefreshing(false); }} - > - - Rename Item - - + } + > + {loadingDir && !refreshing ? ( + + + + ) : filtered.length === 0 ? ( + + {query ? "No matching files" : "Empty folder"} + + ) : ( + filtered.map((file) => ( + openFile(file)} + onLongPress={() => { + if (selectionMode) { + toggleSelect(file.path); + } else { + activateSelection(file); + } }} - value={renameName} - onChangeText={setRenameName} - placeholder="New name" - placeholderTextColor="#6B7280" - autoFocus + onMenu={() => setMenuFile(file)} + onDelete={() => doDelete(file)} + color={color} /> - - { - setRenameDialog({ visible: false, file: null }); - setRenameName(""); - }} - className="flex-1 py-3" - style={{ - backgroundColor: BACKGROUNDS.BUTTON, - borderWidth: BORDERS.MAJOR, - borderColor: BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} + )) + )} + + + {/* Bottom action bar — selection or clipboard */} + {(selectionMode || clipboard.files.length > 0) && ( + + + {selectionMode ? ( + <> + + {selectedFiles.size} selected + + - - Cancel - - - + + - - Rename - - - - + + + + + + + + + + ) : clipboard.files.length > 0 ? ( + <> + + {clipboard.files.length} item{clipboard.files.length !== 1 ? "s" : ""}{" "} + {clipboard.operation === "copy" ? "copied" : "cut"} + + + + Paste + + setClipboard({ files: [], operation: null })} + hitSlop={6} + className="p-2 rounded border border-border active:bg-muted/40" + > + + + + ) : null} + - - + )} + - {fileViewer.file && ( + {/* Per-file action menu */} + setMenuFile(null)} + title={menuFile?.name} + subtitle={menuFile?.path} + actions={buildFileActions(menuFile, color, { + view: viewFile, + rename: (f) => { + setRenameTarget(f); + setRenameName(f.name); + }, + copy: (f) => setClipboard({ files: [f.path], operation: "copy" }), + cut: (f) => setClipboard({ files: [f.path], operation: "cut" }), + perms: (f) => setPermsFile(f), + copyPath: async (f) => { + await Clipboard.setStringAsync(f.path); + toast.success("Path copied"); + }, + del: doDelete, + extract: doExtract, + })} + /> + + {/* More / overflow menu */} + setMoreMenuVisible(false)} + title="Actions" + actions={moreActions} + /> + + {/* Permissions editor */} + setPermsFile(null)} + onApply={applyPermissions} + /> + + {/* Create / rename dialogs */} + { + setCreateDialog(null); + setCreateName(""); + }} + onConfirm={confirmCreate} + confirmLabel="Create" + insetBottom={insets.bottom} + /> + { + setRenameTarget(null); + setRenameName(""); + }} + onConfirm={confirmRename} + confirmLabel="Rename" + insetBottom={insets.bottom} + /> + + {/* Text/code viewer + editor */} + {viewer ? ( - setFileViewer({ visible: false, file: null, content: "" }) - } - fileName={fileViewer.file.name} - filePath={fileViewer.file.path} - initialContent={fileViewer.content} - onSave={handleSaveFile} + visible + onClose={() => setViewer(null)} + fileName={viewer.file.name} + filePath={viewer.file.path} + initialContent={viewer.content} + onSave={saveFile} /> - )} - + ) : null} + + {/* Shared auth dialogs */} + + ); }, ); FileManager.displayName = "FileManager"; + +// ─── buildFileActions ──────────────────────────────────────────────────────── + +function buildFileActions( + file: FileItem | null, + color: ReturnType, + handlers: { + view: (f: FileItem) => void; + rename: (f: FileItem) => void; + copy: (f: FileItem) => void; + cut: (f: FileItem) => void; + perms: (f: FileItem) => void; + copyPath: (f: FileItem) => void; + del: (f: FileItem) => void; + extract: (f: FileItem) => void; + }, +): (ContextAction | null)[] { + if (!file) return []; + const fg = color("foreground") ?? "#fafafa"; + const isFile = file.type === "file"; + return [ + isFile && isTextFile(file.name) + ? { + key: "view", + icon: , + label: "View / Edit", + onPress: () => handlers.view(file), + } + : null, + { + key: "rename", + icon: , + label: "Rename", + onPress: () => handlers.rename(file), + }, + { + key: "copy", + icon: , + label: "Copy", + onPress: () => handlers.copy(file), + }, + { + key: "cut", + icon: , + label: "Cut", + onPress: () => handlers.cut(file), + }, + isFile && isArchiveFile(file.name) + ? { + key: "extract", + icon: , + label: "Extract Here", + onPress: () => handlers.extract(file), + } + : null, + { + key: "perms", + icon: , + label: "Permissions", + onPress: () => handlers.perms(file), + }, + { + key: "copyPath", + icon: , + label: "Copy Path", + onPress: () => handlers.copyPath(file), + }, + { + key: "delete", + icon: , + label: "Delete", + destructive: true, + onPress: () => handlers.del(file), + }, + ]; +} + +// ─── FileRow ───────────────────────────────────────────────────────────────── + +function FileRow({ + file, + selected, + selectionMode, + onPress, + onLongPress, + onMenu, + onDelete, + color, +}: { + file: FileItem; + selected: boolean; + selectionMode: boolean; + onPress: () => void; + onLongPress: () => void; + onMenu: () => void; + onDelete: () => void; + color: ReturnType; +}) { + const swipeableRef = useRef(null); + const iconColor = getFileIconColor(file.name, file.type); + const Icon = + file.type === "directory" ? Folder : file.type === "link" ? LinkIcon : FileIcon; + const isDir = file.type === "directory"; + + const renderRightActions = ( + progress: Animated.AnimatedInterpolation, + dragX: Animated.AnimatedInterpolation, + ) => { + const scale = dragX.interpolate({ + inputRange: [-80, 0], + outputRange: [1, 0.85], + extrapolate: "clamp", + }); + return ( + + { + swipeableRef.current?.close(); + onDelete(); + }} + style={{ flex: 1, justifyContent: "center", alignItems: "center", width: "100%" }} + > + + + + ); + }; + + const rowContent = ( + + {/* Selection checkbox */} + {selectionMode ? ( + + {selected ? ( + + ) : ( + + )} + + ) : null} + + {/* File icon */} + + + {/* Name + meta */} + + + {file.name} + + + {isDir ? ( + Folder + ) : ( + <> + {file.size !== undefined ? ( + + {formatFileSize(file.size)} + + ) : null} + {file.modified ? ( + <> + {file.size !== undefined ? ( + · + ) : null} + + {formatDate(file.modified)} + + + ) : null} + + )} + {file.permissions ? ( + <> + · + + {file.permissions} + + + ) : null} + + + + {/* Right indicator */} + {isDir ? ( + + ) : !selectionMode ? ( + + + + ) : null} + + ); + + // In selection mode, disable swipe + if (selectionMode) { + return rowContent; + } + + return ( + + {rowContent} + + ); +} + +// ─── NameDialog ────────────────────────────────────────────────────────────── + +function NameDialog({ + visible, + title, + value, + onChange, + onClose, + onConfirm, + confirmLabel, + insetBottom, +}: { + visible: boolean; + title: string; + value: string; + onChange: (v: string) => void; + onClose: () => void; + onConfirm: () => void; + confirmLabel: string; + insetBottom: number; +}) { + const color = useThemeColor(); + return ( + + + + e.stopPropagation()} + > + + {title} + + + + + + + + + + + ); +} diff --git a/app/tabs/sessions/file-manager/FileManagerHeader.tsx b/app/tabs/sessions/file-manager/FileManagerHeader.tsx index daf8ae3..5d188a2 100644 --- a/app/tabs/sessions/file-manager/FileManagerHeader.tsx +++ b/app/tabs/sessions/file-manager/FileManagerHeader.tsx @@ -9,10 +9,7 @@ import { MoreVertical, } from "lucide-react-native"; import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; -import { - getResponsivePadding, - getResponsiveFontSize, -} from "@/app/utils/responsive"; +import { getResponsivePadding } from "@/app/utils/responsive"; import { BORDERS, BORDER_COLORS, diff --git a/app/tabs/sessions/file-manager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx index 6fea01f..c274229 100644 --- a/app/tabs/sessions/file-manager/FileViewer.tsx +++ b/app/tabs/sessions/file-manager/FileViewer.tsx @@ -2,17 +2,18 @@ import { useState, useEffect } from "react"; import { Modal, View, - Text, - TouchableOpacity, TextInput, ActivityIndicator, Alert, Platform, KeyboardAvoidingView, + Pressable, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { X, Save, RotateCcw } from "lucide-react-native"; -import { showToast } from "@/app/utils/toast"; +import { X, Save, RotateCcw, FileText } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; import { useOrientation } from "@/app/utils/orientation"; interface FileViewerProps { @@ -26,7 +27,7 @@ interface FileViewerProps { } const MONOSPACE_FONT = Platform.select({ - ios: "Courier", + ios: "Courier New", android: "monospace", default: "monospace", }); @@ -42,6 +43,7 @@ export function FileViewer({ }: FileViewerProps) { const insets = useSafeAreaInsets(); const { isLandscape } = useOrientation(); + const color = useThemeColor(); const [content, setContent] = useState(initialContent); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); @@ -58,13 +60,12 @@ export function FileViewer({ const handleSave = async () => { if (!hasChanges || readOnly) return; - try { setIsSaving(true); await onSave(content); setHasChanges(false); } catch (error: any) { - showToast.error(error.message || "Failed to save file"); + toast.error(error.message || "Failed to save file"); } finally { setIsSaving(false); } @@ -72,45 +73,32 @@ export function FileViewer({ const handleRevert = () => { if (!hasChanges) return; - - Alert.alert( - "Revert Changes", - "Are you sure you want to discard your changes?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Revert", - style: "destructive", - onPress: () => { - setContent(initialContent); - setHasChanges(false); - }, + Alert.alert("Revert Changes", "Discard your changes?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Revert", + style: "destructive", + onPress: () => { + setContent(initialContent); + setHasChanges(false); }, - ], - ); + }, + ]); }; const handleClose = () => { if (hasChanges && !readOnly) { - Alert.alert( - "Unsaved Changes", - "You have unsaved changes. Do you want to save before closing?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Discard", - style: "destructive", - onPress: onClose, - }, - { - text: "Save", - onPress: async () => { - await handleSave(); - onClose(); - }, + Alert.alert("Unsaved Changes", "Save before closing?", [ + { text: "Cancel", style: "cancel" }, + { text: "Discard", style: "destructive", onPress: onClose }, + { + text: "Save", + onPress: async () => { + await handleSave(); + onClose(); }, - ], - ); + }, + ]); } else { onClose(); } @@ -126,105 +114,90 @@ export function FileViewer({ > - + + {/* Header */} - - - - {fileName} - - - {filePath} - - - - - {!readOnly && hasChanges && ( - <> - - - - - - {isSaving ? ( - - ) : ( - - )} - - - )} - - - - - + + + + {fileName} + + + {filePath} + - {readOnly && ( - - Read-only mode - - )} + + {!readOnly && hasChanges ? ( + <> + + + + + {isSaving ? ( + + ) : ( + + )} + + + ) : null} + + + + + {readOnly ? ( + + Read-only + + ) : null} + diff --git a/app/tabs/sessions/file-manager/PermissionsDialog.tsx b/app/tabs/sessions/file-manager/PermissionsDialog.tsx new file mode 100644 index 0000000..03373fb --- /dev/null +++ b/app/tabs/sessions/file-manager/PermissionsDialog.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { View, Pressable } from "react-native"; +import { Dialog, Button, Text } from "@/app/components/ui"; + +type Bit = "r" | "w" | "x"; +const CLASSES = ["owner", "group", "other"] as const; +const BITS: Bit[] = ["r", "w", "x"]; +const BIT_VALUE: Record = { r: 4, w: 2, x: 1 }; + +/** Parse a permissions string (octal "755" or symbolic "rwxr-xr-x") to 3 digits. */ +function parsePermissions(perms?: string): [number, number, number] { + if (!perms) return [7, 5, 5]; + const octal = perms.match(/([0-7])([0-7])([0-7])\s*$/); + if (octal) { + return [Number(octal[1]), Number(octal[2]), Number(octal[3])]; + } + // Symbolic: take the last 9 chars (rwxrwxrwx), ignoring the type char. + const sym = perms.replace(/[^rwx-]/g, ""); + const tail = sym.slice(-9); + if (tail.length === 9) { + const calc = (g: string) => + (g[0] === "r" ? 4 : 0) + (g[1] === "w" ? 2 : 0) + (g[2] === "x" ? 1 : 0); + return [calc(tail.slice(0, 3)), calc(tail.slice(3, 6)), calc(tail.slice(6, 9))]; + } + return [7, 5, 5]; +} + +/** + * chmod editor. Shows owner/group/other read/write/execute toggles plus the + * resulting octal, and calls onApply with the 3-digit octal string. + */ +export function PermissionsDialog({ + visible, + fileName, + permissions, + onClose, + onApply, +}: { + visible: boolean; + fileName: string; + permissions?: string; + onClose: () => void; + onApply: (octal: string) => void; +}) { + const [digits, setDigits] = useState<[number, number, number]>([7, 5, 5]); + + useEffect(() => { + if (visible) setDigits(parsePermissions(permissions)); + }, [visible, permissions]); + + const has = (idx: number, bit: Bit) => (digits[idx] & BIT_VALUE[bit]) !== 0; + const toggle = (idx: number, bit: Bit) => { + setDigits((prev) => { + const next: [number, number, number] = [...prev] as any; + next[idx] = next[idx] ^ BIT_VALUE[bit]; + return next; + }); + }; + + const octal = digits.join(""); + + return ( + + + + + } + > + + {CLASSES.map((cls, idx) => ( + + + {cls} + + + {BITS.map((bit) => { + const active = has(idx, bit); + return ( + toggle(idx, bit)} + className={`flex-1 py-2 items-center border ${ + active + ? "bg-accent-brand/10 border-accent-brand/40" + : "border-border active:bg-muted/40" + }`} + > + + {bit} + + + ); + })} + + + ))} + + + ); +} diff --git a/app/tabs/sessions/file-manager/utils/fileUtils.ts b/app/tabs/sessions/file-manager/utils/fileUtils.ts index 1d90051..98ea217 100644 --- a/app/tabs/sessions/file-manager/utils/fileUtils.ts +++ b/app/tabs/sessions/file-manager/utils/fileUtils.ts @@ -35,47 +35,25 @@ export function joinPath(...parts: string[]): string { } export function isTextFile(filename: string): boolean { + // Dotfiles with no extension (e.g. .bashrc, .zshrc, .profile) are always text + if (filename.startsWith(".") && !filename.slice(1).includes(".")) return true; + const ext = getFileExtension(filename); - const textExtensions = [ - "txt", - "md", - "json", - "xml", - "html", - "css", - "js", - "ts", - "tsx", - "jsx", - "py", - "java", - "c", - "cpp", - "h", - "hpp", - "cs", - "php", - "rb", - "go", - "rs", - "sh", - "bash", - "zsh", - "fish", - "yml", - "yaml", - "toml", - "ini", - "cfg", - "conf", - "log", - "env", - "gitignore", - "dockerignore", - "editorconfig", - "prettierrc", + + // No extension at all — treat as text (e.g. Makefile, Dockerfile, LICENSE) + if (!ext) return true; + + const binaryExtensions = [ + "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", + "mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", + "mp3", "wav", "ogg", "flac", "aac", "m4a", + "zip", "tar", "gz", "bz2", "xz", "7z", "rar", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "exe", "bin", "so", "dylib", "dll", "class", "pyc", + "db", "sqlite", "sqlite3", + "woff", "woff2", "ttf", "otf", "eot", ]; - return textExtensions.includes(ext); + return !binaryExtensions.includes(ext); } export function isArchiveFile(filename: string): boolean { diff --git a/app/tabs/sessions/navigation/TabBar.tsx b/app/tabs/sessions/navigation/TabBar.tsx index 3157c14..f903dd3 100644 --- a/app/tabs/sessions/navigation/TabBar.tsx +++ b/app/tabs/sessions/navigation/TabBar.tsx @@ -6,6 +6,7 @@ import { ScrollView, TextInput, Keyboard, + StyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -15,19 +16,47 @@ import { Minus, ChevronDown, ChevronUp, + SquareTerminal, + Activity, + Folder, + Network, + Container, + Monitor, + Layers, } 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"; import { getTabBarHeight, getButtonSize } from "@/app/utils/responsive"; import { - BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS, + ACCENT, + TEXT_COLORS, } from "@/app/constants/designTokens"; +function getSessionIcon(type: SessionType) { + switch (type) { + case "terminal": + return SquareTerminal; + case "stats": + return Activity; + case "filemanager": + return Folder; + case "tunnel": + return Network; + case "docker": + return Container; + case "remoteDesktop": + return Monitor; + } +} + interface TabBarProps { sessions: TerminalSession[]; activeSessionId: string | null; @@ -40,7 +69,9 @@ interface TabBarProps { onHideKeyboard?: () => void; onShowKeyboard?: () => void; keyboardIntentionallyHiddenRef: React.MutableRefObject; - activeSessionType?: "terminal" | "stats" | "filemanager"; + activeSessionType?: SessionType; + onShowConnections?: () => void; + hasBackgroundSessions?: boolean; } export default function TabBar({ @@ -56,6 +87,8 @@ export default function TabBar({ onShowKeyboard, keyboardIntentionallyHiddenRef, activeSessionType, + onShowConnections, + hasBackgroundSessions, }: TabBarProps) { const router = useRouter(); const { isKeyboardVisible } = useKeyboard(); @@ -87,12 +120,10 @@ export default function TabBar({ + {/* Connections panel button */} + + + + + + + {/* Back to hosts button */} router.navigate("/hosts" as any)} focusable={false} @@ -116,7 +174,7 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, + borderWidth: StyleSheet.hairlineWidth, borderColor: BORDER_COLORS.BUTTON, backgroundColor: BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, @@ -159,6 +217,8 @@ export default function TabBar({ > {sessions.map((session) => { const isActive = session.id === activeSessionId; + const SessionIcon = getSessionIcon(session.type); + const iconColor = isActive ? ACCENT : TEXT_COLORS.SECONDARY; return ( - + + {session.title} @@ -203,18 +271,18 @@ export default function TabBar({ className="items-center justify-center" activeOpacity={0.7} style={{ - width: isLandscape ? 32 : 36, + width: isLandscape ? 28 : 32, height: buttonSize, - borderLeftWidth: BORDERS.STANDARD, + borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, }} > @@ -232,7 +300,7 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, + borderWidth: StyleSheet.hairlineWidth, borderColor: BORDER_COLORS.BUTTON, backgroundColor: BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, @@ -261,20 +329,20 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, - borderColor: BORDER_COLORS.BUTTON, - backgroundColor: BACKGROUNDS.BUTTON, + borderWidth: StyleSheet.hairlineWidth, + borderColor: isCustomKeyboardVisible + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + backgroundColor: isCustomKeyboardVisible + ? `${ACCENT}18` + : BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, elevation: 2, marginLeft: isLandscape ? 6 : 8, }} > {isCustomKeyboardVisible ? ( - + ) : ( )} @@ -282,18 +350,6 @@ export default function TabBar({ )} - {activeSessionType === "terminal" && isCustomKeyboardVisible && ( - - )} ); } diff --git a/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx new file mode 100644 index 0000000..771fc9d --- /dev/null +++ b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx @@ -0,0 +1,1004 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Keyboard, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { WebView } from "react-native-webview"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Keyboard as KeyboardIcon, + RotateCcw, + Settings2, + X, +} from "lucide-react-native"; +import type { SSHHost } from "@/types"; +import { + getGuacamoleTokenFromHost, + getGuacamoleWebSocketUrl, +} from "@/app/main-axios"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { BottomSheet, SegmentedControl, Button } from "@/app/components/ui"; + +// Height of the always-visible key strip at the bottom. +// The parent session area already accounts for the tab bar + safe-area insets, +// so we only need our own strip height here. +const KEY_STRIP_HEIGHT = 44; + +type ConnectionState = + | "idle" + | "connecting" + | "connected" + | "disconnected" + | "failed"; + +type MouseMode = "touch" | "trackpad"; + +interface Modifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; + win: boolean; +} + +interface RemoteDesktopProps { + host: SSHHost; + isVisible: boolean; + title: string; + onClose?: () => void; +} + +export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { + const color = useThemeColor(); + const { bottom: safeBottom } = useSafeAreaInsets(); + + const themeBg = color("background") ?? "rgb(12,13,11)"; + const themeCard = color("card") ?? "rgb(24,25,23)"; + const themeBorder = color("border") ?? "rgb(50,50,50)"; + const themeFg = color("foreground") ?? "rgb(250,250,250)"; + const themeMuted = color("muted-foreground") ?? "rgb(164,164,164)"; + const themeAccent = color("accent-brand") ?? "#f59145"; + + // True available size measured from onLayout — avoids using full window + // height which includes areas already consumed by the tab bar and insets. + const [availableSize, setAvailableSize] = useState<{ w: number; h: number } | null>(null); + const availableSizeRef = useRef<{ w: number; h: number } | null>(null); + const containerRef = useRef(null); + + const handleContainerLayout = useCallback((e: any) => { + const { width: lw, height: lh } = e.nativeEvent.layout; + const w = Math.round(lw); + const h = Math.round(lh); + availableSizeRef.current = { w, h }; + setAvailableSize({ w, h }); + }, []); + + // Stores the resolved initial size once connect() runs + const initialSizeRef = useRef({ width: 1280, height: 720 }); + + 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 [mouseMode, setMouseMode] = useState("touch"); + const [modifiers, setModifiers] = useState({ + ctrl: false, + alt: false, + shift: false, + win: false, + }); + const [showFunctionKeys, setShowFKeys] = useState(false); + const [showSettingsSheet, setShowSettings] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1.0); + const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + // Track keyboard visibility — use the raw keyboard height minus the bottom + // margin that Sessions.tsx already applies (tab bar + safe area insets). + // We get the container's page position at show-time to compute exact overlap. + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", (e) => { + setIsKeyboardOpen(true); + setKeyboardHeight(e.endCoordinates.height); + }); + const hide = Keyboard.addListener("keyboardDidHide", () => { + setIsKeyboardOpen(false); + setKeyboardHeight(0); + }); + return () => { show.remove(); hide.remove(); }; + }, []); + + // ── Connection ──────────────────────────────────────────────────────────── + + const connect = useCallback(async () => { + try { + setConnectionState("connecting"); + setErrorMessage(null); + const { token } = await getGuacamoleTokenFromHost(Number(host.id)); + // Use measured layout size; fall back to ref if layout fired already + const measured = availableSizeRef.current; + const remW = measured ? measured.w : 1280; + const remH = measured ? Math.max(1, measured.h - KEY_STRIP_HEIGHT) : 720; + initialSizeRef.current = { width: remW, height: remH }; + setWebSocketUrl(getGuacamoleWebSocketUrl(token, remW, remH)); + } 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"); + } else if (payload.type === "zoomChange") { + setZoomLevel(payload.zoom); + } + } catch { + setConnectionState("failed"); + setErrorMessage("Remote session returned an invalid message"); + } + }, []); + + // ── WebView HTML ────────────────────────────────────────────────────────── + + const htmlContent = useMemo(() => { + if (!webSocketUrl) return ""; + + return ` + + + + + + + +
+ + +`; + }, [webSocketUrl]); + + // ── Reconnect ───────────────────────────────────────────────────────────── + + const reconnect = useCallback(() => { + setWebSocketUrl(null); + setZoomLevel(1.0); + setModifiers({ ctrl: false, alt: false, shift: false, win: false }); + setWebViewKey((k) => k + 1); + }, []); + + // ── JS bridge helpers ───────────────────────────────────────────────────── + + const inject = useCallback((script: string) => { + webViewRef.current?.injectJavaScript(`${script}; true;`); + }, []); + + const sendKeysym = useCallback( + (k: number) => inject(`window.termixRemote && window.termixRemote.sendKeysym(${k})`), + [inject], + ); + + const sendKeysyms = useCallback( + (ks: number[]) => inject(`window.termixRemote && window.termixRemote.sendKeysyms(${JSON.stringify(ks)})`), + [inject], + ); + + const sendText = useCallback( + (t: string) => inject(`window.termixRemote && window.termixRemote.sendText(${JSON.stringify(t)})`), + [inject], + ); + + const sendWithMods = useCallback( + (k: number) => { + inject(`window.termixRemote && window.termixRemote.sendWithModifiers(${k}, ${JSON.stringify(modifiers)})`); + setModifiers({ ctrl: false, alt: false, shift: false, win: false }); + }, + [inject, modifiers], + ); + + const toggleModifier = useCallback( + (key: keyof Modifiers) => setModifiers((m) => ({ ...m, [key]: !m[key] })), + [], + ); + + const resetZoom = useCallback(() => { + inject("window.termixRemote && window.termixRemote.resetZoom()"); + setZoomLevel(1.0); + }, [inject]); + + const canSendInput = connectionState === "connected"; + const protocol = (host.connectionType || "rdp").toUpperCase(); + + // The keyboard height event is from the screen bottom. Our container's bottom + // already sits SESSION_TAB_BAR_HEIGHT + safeBottom above the screen bottom + // (applied by Sessions.tsx). The actual overlap on our container is: + const SESSION_TAB_BAR_HEIGHT = 62; // getTabBarHeight(portrait) + 2 + const kbOverlap = Math.max(0, keyboardHeight - SESSION_TAB_BAR_HEIGHT - safeBottom); + + // Tell remote to render at the true available size when connected or when + // the container size changes (orientation change) or keyboard opens/closes. + useEffect(() => { + if (!canSendInput || !availableSize) return; + const remW = availableSize.w; + const remH = Math.max(1, availableSize.h - KEY_STRIP_HEIGHT - kbOverlap); + inject( + `window.termixRemote && window.termixRemote.resize(${remW}, ${remH})`, + ); + }, [canSendInput, availableSize, kbOverlap, inject]); + + // ── Styles ──────────────────────────────────────────────────────────────── + + const styles = useMemo( + () => + StyleSheet.create({ + // Outer wrapper: absolute-fills the parent session area. + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#000", + }, + // WebView fills everything except the key strip at the bottom. + webView: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: KEY_STRIP_HEIGHT, + backgroundColor: "#000", + }, + // Connecting/error overlay: fills the full area (strip not visible yet). + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + backgroundColor: themeBg, + }, + overlayTitle: { + marginTop: 12, + color: themeFg, + fontSize: 16, + fontWeight: "600", + textAlign: "center", + }, + overlayText: { + marginTop: 8, + color: themeMuted, + fontSize: 13, + lineHeight: 18, + textAlign: "center", + }, + // Key strip: sits at the very bottom of the parent session area + keyStrip: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: KEY_STRIP_HEIGHT, + backgroundColor: themeBg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: themeBorder, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 4, + }, + scrollContent: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 4, + }, + // Key button in the strip + key: { + height: 34, + paddingHorizontal: 9, + borderRadius: 6, + borderWidth: 1, + borderColor: themeBorder, + backgroundColor: themeCard, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 4, + }, + keyText: { + color: themeFg, + fontSize: 12, + fontWeight: "600", + }, + // Modifier pill (active = accent tinted) + modKey: { + height: 34, + paddingHorizontal: 9, + borderRadius: 6, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + modKeyText: { + fontSize: 12, + fontWeight: "700", + }, + divider: { + width: 1, + height: 22, + backgroundColor: themeBorder, + marginHorizontal: 2, + }, + iconKey: { + width: 36, + height: 34, + borderRadius: 6, + borderWidth: 1, + borderColor: themeBorder, + backgroundColor: themeCard, + alignItems: "center", + justifyContent: "center", + }, + hiddenInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + color: "transparent", + }, + // Settings sheet sections + sheetSection: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: themeBorder, + gap: 8, + }, + sheetLabel: { + color: themeMuted, + fontSize: 10, + fontWeight: "700", + textTransform: "uppercase" as const, + letterSpacing: 1, + }, + sheetDesc: { + color: themeMuted, + fontSize: 11, + lineHeight: 16, + }, + zoomRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + zoomLabel: { + color: themeMuted, + fontSize: 12, + flex: 1, + }, + }), + [themeBg, themeCard, themeBorder, themeFg, themeMuted], + ); + + const modKeyStyle = (active: boolean) => ({ + ...styles.modKey, + borderColor: active ? themeAccent : themeBorder, + backgroundColor: active ? themeAccent : themeCard, + }); + + const modKeyTextStyle = (active: boolean) => ({ + ...styles.modKeyText, + color: active ? "#fff" : themeMuted, + }); + + const FKEYS = useMemo(() => Array.from({ length: 12 }, (_, i) => 0xffbe + i), []); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + {/* WebView */} + {htmlContent ? ( + { setConnectionState("failed"); setErrorMessage(e.nativeEvent.description); }} + onHttpError={(e) => { setConnectionState("failed"); setErrorMessage(`HTTP ${e.nativeEvent.statusCode}`); }} + scrollEnabled={false} + overScrollMode="never" + bounces={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + setSupportMultipleWindows={false} + /> + ) : null} + + {/* Connecting overlay */} + {(connectionState === "connecting" || connectionState === "idle") && ( + + + Connecting {protocol} + {title} + + )} + + {/* Failed / disconnected overlay */} + {(connectionState === "failed" || connectionState === "disconnected") && ( + + + {connectionState === "failed" ? "Connection Failed" : "Disconnected"} + + {errorMessage ? ( + {errorMessage} + ) : null} + + + )} + + {/* Hidden text input for keyboard */} + { if (text) sendText(text); }} + onKeyPress={({ nativeEvent }) => { + if (nativeEvent.key === "Backspace") sendKeysym(0xff08); + }} + onSubmitEditing={() => { + sendKeysym(0xff0d); + // Re-focus so the keyboard stays open after Enter + setTimeout(() => inputRef.current?.focus(), 10); + }} + blurOnSubmit={false} + returnKeyType="send" + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + style={styles.hiddenInput} + /> + + {/* ── Key strip ── */} + {canSendInput && ( + + + {/* Modifier keys */} + {(["ctrl", "alt", "shift", "win"] as const).map((k) => ( + toggleModifier(k)}> + + {k === "ctrl" ? "Ctrl" : k === "alt" ? "Alt" : k === "shift" ? "⇧" : "Win"} + + + ))} + + + + {/* Keyboard toggle */} + {isKeyboardOpen ? ( + Keyboard.dismiss()} + > + + Done + + ) : ( + inputRef.current?.focus()} + > + + + )} + + + + {/* Common keys */} + {[ + { label: "Esc", k: 0xff1b }, + { label: "Tab", k: 0xff09 }, + ].map(({ label, k }) => ( + sendWithMods(k)}> + {label} + + ))} + + + + {/* Arrow keys */} + sendWithMods(0xff51)}> + + + sendWithMods(0xff52)}> + + + sendWithMods(0xff54)}> + + + sendWithMods(0xff53)}> + + + + + + {/* Nav keys */} + {[ + { label: "Home", k: 0xff50 }, + { label: "End", k: 0xff57 }, + { label: "PgUp", k: 0xff55 }, + { label: "PgDn", k: 0xff56 }, + { label: "Bksp", k: 0xff08 }, + { label: "Del", k: 0xffff }, + { label: "Enter", k: 0xff0d }, + ].map(({ label, k }) => ( + sendWithMods(k)}> + {label} + + ))} + + + + {/* System combos */} + sendKeysyms([0xffe3, 0xffe9, 0xffff])}> + CAD + + sendKeysyms([0xffeb])}> + Win + + + + + {/* F-key toggle */} + setShowFKeys((v) => !v)} + > + Fn + + + {/* F1–F12 (shown inline when toggled) */} + {showFunctionKeys && FKEYS.map((ks, i) => ( + sendWithMods(ks)}> + F{i + 1} + + ))} + + + + {/* Settings */} + setShowSettings(true)}> + + + + + )} + + {/* ── Settings sheet (mouse mode + zoom only — compact) ── */} + setShowSettings(false)} + title="Remote Settings" + scroll={false} + > + {/* Mouse mode */} + + Mouse Mode + { + setMouseMode(m); + inject(`window.termixRemote && window.termixRemote.setMouseMode('${m}')`); + }} + /> + + {mouseMode === "touch" + ? "Tap directly where you want to click." + : "Drag to move pointer. Single tap = click. Two-finger tap = right-click."} + + + + {/* Zoom */} + + + Zoom: {zoomLevel.toFixed(1)}× — pinch anywhere to zoom + + + + + + ); +} diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx index 26852c8..36632a3 100644 --- a/app/tabs/sessions/server-stats/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -1,582 +1,268 @@ -import React, { - useRef, +import { + forwardRef, + useImperativeHandle, useEffect, useState, + useRef, useCallback, - forwardRef, - useImperativeHandle, } from "react"; +import { View, ScrollView, RefreshControl, Pressable } from "react-native"; +import { Zap } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; import { - View, - Text, - ScrollView, - ActivityIndicator, - RefreshControl, - TouchableOpacity, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - Cpu, - MemoryStick, - HardDrive, - Activity, - Clock, - Server, -} from "lucide-react-native"; -import { getServerMetricsById, executeSnippet } from "../../../main-axios"; -import { showToast } from "../../../utils/toast"; -import type { ServerMetrics, QuickAction } from "../../../../types"; -import { useOrientation } from "@/app/utils/orientation"; + getServerMetricsById, + startMetricsPolling, + stopMetricsPolling, + registerMetricsViewer, + unregisterMetricsViewer, + sendMetricsHeartbeat, + executeSnippet, +} from "@/app/main-axios"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { SessionFrame, usePolling } from "@/app/tabs/sessions/_shared"; import { - getResponsivePadding, - getColumnCount, - getTabBarHeight, -} from "@/app/utils/responsive"; + StatsConfig, + WidgetType, + DEFAULT_STATS_CONFIG, +} from "@/constants/stats-config"; import { - BACKGROUNDS, - BORDER_COLORS, - RADIUS, - TEXT_COLORS, -} from "@/app/constants/designTokens"; + CpuWidget, + MemoryWidget, + DiskWidget, + NetworkWidget, + UptimeWidget, + SystemWidget, + ProcessesWidget, + PortsWidget, + FirewallWidget, + LoginStatsWidget, +} from "./widgets"; interface ServerStatsProps { hostConfig: { id: number; name: string; - quickActions?: QuickAction[]; + statsConfig?: string; + quickActions?: { name: string; snippetId: number }[]; }; isVisible: boolean; title?: string; onClose?: () => void; } -export type ServerStatsHandle = { +export interface ServerStatsHandle { refresh: () => void; -}; +} -export const ServerStats = forwardRef( - ({ hostConfig, isVisible, title = "Server Stats", onClose }, ref) => { - const insets = useSafeAreaInsets(); - const { width, isLandscape } = useOrientation(); - const [metrics, setMetrics] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - const [error, setError] = useState(null); - const [executingActions, setExecutingActions] = useState>( - new Set(), - ); - const refreshIntervalRef = useRef(null); +const HISTORY_LEN = 20; - const padding = getResponsivePadding(isLandscape); - const columnCount = getColumnCount(width, isLandscape, 350); - const tabBarHeight = getTabBarHeight(isLandscape); +function parseConfig(raw?: string): StatsConfig { + try { + return raw ? { ...DEFAULT_STATS_CONFIG, ...JSON.parse(raw) } : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } +} - const fetchMetrics = useCallback( - async (showLoadingSpinner = true) => { - try { - if (showLoadingSpinner) { - setIsLoading(true); - } - setError(null); +export const ServerStats = forwardRef( + ({ hostConfig, isVisible }, ref) => { + const color = useThemeColor(); + const config = parseConfig(hostConfig.statsConfig); + const enabled = config.enabledWidgets; - const data = await getServerMetricsById(hostConfig.id); - setMetrics(data); - } catch (err: any) { - const errorMessage = err?.message || "Failed to fetch server metrics"; - setError(errorMessage); - if (showLoadingSpinner) { - showToast.error(errorMessage); - } - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, - [hostConfig.id], + const [metrics, setMetrics] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", ); + const [error, setError] = useState(""); + const [refreshing, setRefreshing] = useState(false); + const startedRef = useRef(false); + const viewerSessionIdRef = useRef(null); - const handleRefresh = useCallback(() => { - setIsRefreshing(true); - fetchMetrics(false); - }, [fetchMetrics]); - - useImperativeHandle( - ref, - () => ({ - refresh: handleRefresh, - }), - [handleRefresh], + // Sparkline history for cpu/memory/disk. + const historyRef = useRef<{ cpu: number[]; memory: number[]; disk: number[] }>( + { cpu: [], memory: [], disk: [] }, ); + const [, forceTick] = useState(0); - useEffect(() => { - if (isVisible) { - fetchMetrics(); + const pushHistory = useCallback((m: ServerMetrics) => { + const h = historyRef.current; + const add = (arr: number[], v: number | null | undefined) => { + arr.push(v ?? 0); + if (arr.length > HISTORY_LEN) arr.shift(); + }; + add(h.cpu, m.cpu?.percent); + add(h.memory, m.memory?.percent); + add(h.disk, m.disk?.percent); + }, []); - refreshIntervalRef.current = setInterval(() => { - fetchMetrics(false); - }, 5000); - } else { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - refreshIntervalRef.current = null; + const fetchMetrics = useCallback(async () => { + try { + const data = await getServerMetricsById(hostConfig.id); + if (data === null) return; // Not ready yet — keep polling silently. + setMetrics(data); + pushHistory(data); + setStatus("ready"); + setError(""); + forceTick((t) => t + 1); + } catch (err: any) { + if (!metrics) { + setError(err?.message || "Failed to load metrics"); + setStatus("error"); } } + }, [hostConfig.id, pushHistory, metrics]); + // Start polling + register viewer when the screen becomes visible. + useEffect(() => { + if (!isVisible || startedRef.current) return; + startedRef.current = true; + (async () => { + try { + const res = await startMetricsPolling(hostConfig.id); + if (res?.requiresTOTP) { + // Stats TOTP is rare; surface as an error with retry for now. + setError("Two-factor required for this host's stats."); + setStatus("error"); + return; + } + if (res?.viewerSessionId) { + viewerSessionIdRef.current = res.viewerSessionId; + } + } catch { + // Non-fatal — metrics endpoint may already be polling. + } + // If start didn't return a viewerSessionId, register separately. + if (!viewerSessionIdRef.current) { + const reg = await registerMetricsViewer(hostConfig.id); + if (reg?.viewerSessionId) { + viewerSessionIdRef.current = reg.viewerSessionId; + } + } + fetchMetrics(); + })(); return () => { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + startedRef.current = false; + const vsid = viewerSessionIdRef.current; + viewerSessionIdRef.current = null; + if (vsid) { + unregisterMetricsViewer(hostConfig.id, vsid); + stopMetricsPolling(hostConfig.id, vsid); + } else { + stopMetricsPolling(hostConfig.id); } }; - }, [isVisible, fetchMetrics]); - - const cardWidth = - isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; - - const formatUptime = (seconds: number | null): string => { - if (seconds === null || seconds === undefined) return "N/A"; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, hostConfig.id]); - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - if (days > 0) { - return `${days}d ${hours}h ${minutes}m`; - } else if (hours > 0) { - return `${hours}h ${minutes}m`; - } else { - return `${minutes}m`; - } - }; - - const handleQuickAction = async (action: QuickAction) => { - setExecutingActions((prev) => new Set(prev).add(action.snippetId)); - showToast.info(`Executing ${action.name}...`); + // Poll metrics + heartbeat while visible. + usePolling( + () => { + fetchMetrics(); + if (viewerSessionIdRef.current) { + sendMetricsHeartbeat(viewerSessionIdRef.current); + } + }, + 3000, + isVisible && status !== "error", + ); - try { - const result = await executeSnippet(action.snippetId, hostConfig.id); + useImperativeHandle(ref, () => ({ refresh: fetchMetrics }), [fetchMetrics]); - if (result.success) { - showToast.success(`${action.name} completed successfully`); - } else { - showToast.error(`${action.name} failed`); + const runQuickAction = useCallback( + async (snippetId: number, name: string) => { + try { + await executeSnippet(snippetId, hostConfig.id); + toast.success(`Ran ${name}`); + } catch (e: any) { + toast.error(e?.message || `Failed to run ${name}`); } - } catch (error: any) { - showToast.error(error?.message || `Failed to execute ${action.name}`); - } finally { - setExecutingActions((prev) => { - const next = new Set(prev); - next.delete(action.snippetId); - return next; - }); - } - }; - - const renderMetricCard = ( - icon: React.ReactNode, - title: string, - value: string, - subtitle: string, - color: string, - ) => { - return ( - 1 ? 0 : 12, - width: cardWidth, - }} - > - - {icon} - - {title} - - + }, + [hostConfig.id], + ); - - - {value} - - {subtitle} - - - ); - }; + if (!isVisible) return null; - if (!isVisible) { - return null; - } + const has = (w: WidgetType) => enabled.includes(w); + const h = historyRef.current; return ( - { + setStatus("loading"); + startedRef.current = false; + fetchMetrics(); + } + : undefined + } > - {isLoading && !metrics ? ( - - - - Loading server metrics... - - - ) : error ? ( - - - { + setRefreshing(true); + await fetchMetrics(); + setRefreshing(false); }} - > - Failed to Load Metrics - - - {error} - - - - Retry - - - - ) : ( - - } - > - - - {hostConfig.name} - - - Server Statistics - - - - {hostConfig?.quickActions && hostConfig.quickActions.length > 0 && ( - - - Quick Actions - - - {hostConfig.quickActions.map((action) => { - const isExecuting = executingActions.has(action.snippetId); - return ( - handleQuickAction(action)} - disabled={isExecuting} - style={{ - backgroundColor: isExecuting ? "#374151" : "#22C55E", - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: RADIUS.BUTTON, - flexDirection: "row", - alignItems: "center", - gap: 8, - opacity: isExecuting ? 0.6 : 1, - }} - activeOpacity={0.7} - > - {isExecuting && ( - - )} - - {action.name} - - - ); - })} - - - )} - - 1 ? "row" : "column", - flexWrap: "wrap", - gap: 12, - }} - > - 1 ? 0 : 12, - width: cardWidth, - }} - > - - - - CPU Usage - - - - + } + > + {/* Quick actions */} + {hostConfig.quickActions && hostConfig.quickActions.length > 0 ? ( + + {hostConfig.quickActions.map((qa) => ( + runQuickAction(qa.snippetId, qa.name)} + className="flex-row items-center gap-1.5 px-2.5 py-1.5 border border-accent-brand/40 bg-accent-brand/10 active:opacity-80" > - - {typeof metrics?.cpu?.percent === "number" - ? `${metrics.cpu.percent}%` - : "N/A"} - - - {typeof metrics?.cpu?.cores === "number" - ? `${metrics.cpu.cores} cores` - : "N/A"} + + + {qa.name} - - - {metrics?.cpu?.load && ( - - - Load Average - - - - - {metrics.cpu.load[0].toFixed(2)} - - - 1 min - - - - - {metrics.cpu.load[1].toFixed(2)} - - - 5 min - - - - - {metrics.cpu.load[2].toFixed(2)} - - - 15 min - - - - - )} - - - {renderMetricCard( - , - "Memory Usage", - typeof metrics?.memory?.percent === "number" - ? `${metrics.memory.percent}%` - : "N/A", - (() => { - const used = metrics?.memory?.usedGiB; - const total = metrics?.memory?.totalGiB; - if (typeof used === "number" && typeof total === "number") { - return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; - } - return "N/A"; - })(), - "#34D399", - )} - - {renderMetricCard( - , - "Disk Usage", - typeof metrics?.disk?.percent === "number" - ? `${metrics.disk.percent}%` - : "N/A", - (() => { - const used = metrics?.disk?.usedHuman; - const total = metrics?.disk?.totalHuman; - if (used && total) { - return `${used} / ${total}`; - } - return "N/A"; - })(), - "#F59E0B", - )} + + ))} - - )} - + ) : null} + + {metrics ? ( + <> + {has("cpu") ? ( + + ) : null} + {has("memory") ? ( + + ) : null} + {has("disk") ? ( + + ) : null} + {has("network") ? : null} + {has("uptime") ? : null} + {has("processes") ? : null} + {has("ports") ? : null} + {has("login_stats") ? ( + + ) : null} + {has("firewall") ? : null} + {has("system") ? : null} + + ) : null} +
+ ); }, ); -export default ServerStats; +ServerStats.displayName = "ServerStats"; diff --git a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx index 18da3c2..d2c742a 100644 --- a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx @@ -1,131 +1,39 @@ -import React from "react"; -import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; import { Cpu } from "lucide-react-native"; import { ServerMetrics } from "@/types"; -import { - BORDERS, - BORDER_COLORS, - RADIUS, - BACKGROUNDS, -} from "@/app/constants/designTokens"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const CpuWidget: React.FC = ({ metrics, isLoading }) => { - const cpuPercent = metrics?.cpu?.percent ?? null; - const cores = metrics?.cpu?.cores ?? null; - const load = metrics?.cpu?.load ?? null; +export function CpuWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const cpu = metrics.cpu; + const percent = Number(cpu?.percent ?? 0); return ( - - - - CPU Usage - - - - - {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : "N/A"} + } + title="CPU" + trailing={ + + {percent.toFixed(0)}% - - {cores !== null ? `${cores} cores` : "N/A"} + } + > + + {cpu?.cores != null ? ( + + {cpu.cores} cores + {cpu.load + ? ` · load ${cpu.load.map((l) => Number(l).toFixed(2)).join(", ")}` + : ""} - - - {load && ( - - - {load[0].toFixed(2)} - 1m - - - {load[1].toFixed(2)} - 5m - - - {load[2].toFixed(2)} - 15m - - - )} - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: "relative", - }, - header: { - flexDirection: "row", - alignItems: "center", - marginBottom: 12, - }, - title: { - color: "#ffffff", - fontSize: 16, - fontWeight: "600", - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: "700", - marginBottom: 4, - }, - subtitle: { - color: "#9CA3AF", - fontSize: 14, - }, - loadRow: { - flexDirection: "row", - justifyContent: "space-around", - marginTop: 8, - }, - loadItem: { - alignItems: "center", - }, - loadValue: { - color: "#ffffff", - fontSize: 16, - fontWeight: "600", - }, - loadLabel: { - color: "#9CA3AF", - fontSize: 12, - marginTop: 2, - }, - loadingOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.3)", - justifyContent: "center", - alignItems: "center", - borderRadius: 12, - }, -}); +} diff --git a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx index e663730..b2325a1 100644 --- a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx @@ -1,98 +1,37 @@ -import React from "react"; -import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; import { HardDrive } from "lucide-react-native"; import { ServerMetrics } from "@/types"; -import { - BORDERS, - BORDER_COLORS, - RADIUS, - BACKGROUNDS, -} from "@/app/constants/designTokens"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const DiskWidget: React.FC = ({ metrics, isLoading }) => { - const diskPercent = metrics?.disk?.percent ?? null; - const usedHuman = metrics?.disk?.usedHuman ?? null; - const totalHuman = metrics?.disk?.totalHuman ?? null; +export function DiskWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const disk = metrics.disk; + const percent = Number(disk?.percent ?? 0); return ( - - - - Disk Usage - - - - - {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : "N/A"} + } + title="Disk" + trailing={ + + {percent.toFixed(0)}% - - {usedHuman !== null && totalHuman !== null - ? `${usedHuman} / ${totalHuman}` - : "N/A"} + } + > + + {disk?.usedHuman && disk?.totalHuman ? ( + + {disk.usedHuman} / {disk.totalHuman} + {disk.availableHuman ? ` · ${disk.availableHuman} free` : ""} - - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: "relative", - }, - header: { - flexDirection: "row", - alignItems: "center", - marginBottom: 12, - }, - title: { - color: "#ffffff", - fontSize: 16, - fontWeight: "600", - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: "700", - marginBottom: 4, - }, - subtitle: { - color: "#9CA3AF", - fontSize: 14, - }, - loadingOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.3)", - justifyContent: "center", - alignItems: "center", - borderRadius: 12, - }, -}); +} diff --git a/app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx b/app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx new file mode 100644 index 0000000..9e4679d --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; +import { Shield } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text, Badge } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +export function FirewallWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const fw = metrics.firewall; + const status = fw?.status ?? "unknown"; + const ruleCount = + fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0; + + return ( + } + title="Firewall" + trailing={ + + {status} + + } + > + + {fw?.type && fw.type !== "none" ? ( + + {fw.type} · {ruleCount} rules + + ) : ( + + No firewall detected + + )} + + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx new file mode 100644 index 0000000..974f25c --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx @@ -0,0 +1,95 @@ +import { View } from "react-native"; +import { Users } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function LoginStatsWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const login = metrics.loginStats; + + const recent = login?.recentLogins?.slice(0, 5) ?? []; + const failed = login?.failedLogins?.slice(0, 5) ?? []; + const total = login?.totalLogins ?? 0; + const uniqueIPs = login?.uniqueIPs ?? 0; + + const isEmpty = recent.length === 0 && failed.length === 0; + + return ( + } + title="Login Activity" + trailing={ + total > 0 ? ( + {total} total + ) : undefined + } + > + {isEmpty ? ( + + No login activity recorded + + ) : ( + + + {total} logins · {uniqueIPs} unique IP{uniqueIPs !== 1 ? "s" : ""} + + + {recent.length > 0 ? ( + + + Recent + + {recent.map((e, i) => ( + + + {e.user} + + + {[e.from, e.time, e.type].filter(Boolean).join(" · ")} + + + ))} + + ) : null} + + {failed.length > 0 ? ( + + + Failed + + {failed.map((e, i) => ( + + + {e.user} + + + {[e.from, e.time, e.type].filter(Boolean).join(" · ")} + + + ))} + + ) : null} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx index 58ed401..23d58b4 100644 --- a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx @@ -1,98 +1,36 @@ -import React from "react"; -import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; import { MemoryStick } from "lucide-react-native"; import { ServerMetrics } from "@/types"; -import { - BORDERS, - BORDER_COLORS, - RADIUS, - BACKGROUNDS, -} from "@/app/constants/designTokens"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const MemoryWidget: React.FC = ({ metrics, isLoading }) => { - const memoryPercent = metrics?.memory?.percent ?? null; - const usedGiB = metrics?.memory?.usedGiB ?? null; - const totalGiB = metrics?.memory?.totalGiB ?? null; +export function MemoryWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const mem = metrics.memory; + const percent = Number(mem?.percent ?? 0); return ( - - - - Memory Usage - - - - - {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : "N/A"} + } + title="Memory" + trailing={ + + {percent.toFixed(0)}% - - {usedGiB !== null && totalGiB !== null - ? `${usedGiB.toFixed(2)} / ${totalGiB.toFixed(2)} GiB` - : "N/A"} + } + > + + {mem?.usedGiB != null && mem?.totalGiB != null ? ( + + {Number(mem.usedGiB).toFixed(1)} / {Number(mem.totalGiB).toFixed(1)} GiB - - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: "relative", - }, - header: { - flexDirection: "row", - alignItems: "center", - marginBottom: 12, - }, - title: { - color: "#ffffff", - fontSize: 16, - fontWeight: "600", - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: "700", - marginBottom: 4, - }, - subtitle: { - color: "#9CA3AF", - fontSize: 14, - }, - loadingOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.3)", - justifyContent: "center", - alignItems: "center", - borderRadius: 12, - }, -}); +} diff --git a/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx new file mode 100644 index 0000000..e7a8da2 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx @@ -0,0 +1,61 @@ +import { View } from "react-native"; +import { Network } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +export function NetworkWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const interfaces = metrics.network?.interfaces ?? []; + + return ( + } + title="Network" + > + {interfaces.length === 0 ? ( + + No interfaces + + ) : ( + + {interfaces.map((iface) => { + const up = (iface.state || "").toUpperCase() === "UP"; + return ( + + + + + {iface.name} + {iface.ip ? ( + + {" "} + {iface.ip} + + ) : null} + + + {iface.rx != null || iface.tx != null || iface.rxBytes != null || iface.txBytes != null ? ( + + ↓ {iface.rx ?? iface.rxBytes ?? "—"} ↑ {iface.tx ?? iface.txBytes ?? "—"} + + ) : null} + + ); + })} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx b/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx new file mode 100644 index 0000000..611a520 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx @@ -0,0 +1,51 @@ +import { View } from "react-native"; +import { Plug } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function PortsWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const ports = metrics.ports?.ports ?? []; + + return ( + } + title="Listening Ports" + trailing={ + ports.length ? ( + {ports.length} + ) : undefined + } + > + {ports.length === 0 ? ( + + No listening ports + + ) : ( + + {ports.slice(0, 20).map((p, i) => ( + + + {p.protocol}/{p.localPort} + + + {p.process || p.pid || "—"} + {p.localAddress ? ` ${p.localAddress}` : ""} + + + ))} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx new file mode 100644 index 0000000..bbedc0e --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx @@ -0,0 +1,66 @@ +import { View } from "react-native"; +import { Activity } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function ProcessesWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const proc = metrics.processes; + const top = (proc?.top ?? []).slice(0, 8); + + return ( + } + title="Processes" + trailing={ + proc?.total != null ? ( + + {proc.running ?? 0}/{proc.total} + + ) : undefined + } + > + {top.length === 0 ? ( + + No process data + + ) : ( + + + CPU% + MEM% + + COMMAND + + + {top.map((p, i) => ( + + + {p.cpu.toFixed(1)} + + + {p.mem.toFixed(1)} + + + {p.command} + + + ))} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx new file mode 100644 index 0000000..11503e3 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx @@ -0,0 +1,42 @@ +import { View } from "react-native"; +import { Server } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +function Row({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + {label} + + {value} + + + ); +} + +export function SystemWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const sys = metrics.system; + + return ( + } + title="System" + > + + + + {!sys?.hostname && !sys?.os && !sys?.kernel ? ( + + No system info + + ) : null} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx new file mode 100644 index 0000000..cef8101 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx @@ -0,0 +1,35 @@ +import { Clock } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts: string[] = []; + if (d) parts.push(`${d}d`); + if (h) parts.push(`${h}h`); + if (m || parts.length === 0) parts.push(`${m}m`); + return parts.join(" "); +} + +export function UptimeWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const uptime = metrics.uptime; + const text = + uptime?.formatted || + (uptime?.seconds != null ? formatUptime(uptime.seconds) : "—"); + + return ( + } + title="Uptime" + > + + {text} + + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx b/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx new file mode 100644 index 0000000..ce48ec2 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx @@ -0,0 +1,95 @@ +import { View } from "react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** Shared card chrome for every stats widget: icon + title + optional trailing. */ +export function WidgetCard({ + icon, + title, + trailing, + children, +}: { + icon: React.ReactNode; + title: string; + trailing?: React.ReactNode; + children?: React.ReactNode; +}) { + return ( + + + {icon} + + {title} + + {trailing ? {trailing} : null} + + {children} + + ); +} + +/** A labeled usage bar (CPU / memory / disk). */ +export function Meter({ + percent, + history, +}: { + percent: number; + history?: number[]; +}) { + const color = useThemeColor(); + const pct = Math.max(0, Math.min(100, percent)); + const barColor = + pct >= 90 + ? (color("destructive") ?? "#ef4444") + : pct >= 70 + ? "#f59e0b" + : (color("accent-brand") ?? "#f59145"); + return ( + + {history && history.length > 1 ? ( + + ) : null} + + + + + ); +} + +/** + * Tiny sparkline rendered with stacked Views (no SVG dependency). Each sample + * is a thin vertical bar scaled to the series max — good enough for a 20-point + * trend strip and avoids pulling in react-native-svg. + */ +export function Sparkline({ + data, + stroke, + height = 28, +}: { + data: number[]; + stroke: string; + height?: number; +}) { + const max = Math.max(1, ...data); + return ( + + {data.map((v, i) => ( + + ))} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/index.ts b/app/tabs/sessions/server-stats/widgets/index.ts index 3707803..39988ae 100644 --- a/app/tabs/sessions/server-stats/widgets/index.ts +++ b/app/tabs/sessions/server-stats/widgets/index.ts @@ -1,3 +1,11 @@ export { CpuWidget } from "./CpuWidget"; export { MemoryWidget } from "./MemoryWidget"; export { DiskWidget } from "./DiskWidget"; +export { NetworkWidget } from "./NetworkWidget"; +export { UptimeWidget } from "./UptimeWidget"; +export { SystemWidget } from "./SystemWidget"; +export { ProcessesWidget } from "./ProcessesWidget"; +export { PortsWidget } from "./PortsWidget"; +export { FirewallWidget } from "./FirewallWidget"; +export { LoginStatsWidget } from "./LoginStatsWidget"; +export { WidgetCard, Meter, Sparkline } from "./WidgetCard"; diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index 48b1563..f6f74e2 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -12,6 +12,9 @@ export interface TerminalHostConfig { keyPassword?: string; keyType?: string; credentialId?: number; + jumpHosts?: { hostId: number }[]; + forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; } export type WsState = @@ -34,6 +37,10 @@ export interface HostKeyData { export interface NativeWSConfig { hostConfig: TerminalHostConfig; + /** Stable tab instance id for cross-device session tracking (open-tabs). */ + tabInstanceId?: string; + /** Backend session id to attach to on first connect (reviving a tab). */ + initialSessionId?: string | null; onStateChange: (state: WsState, data?: Record) => void; onData: (data: string) => void; onTotpRequired: (prompt: string, isPassword: boolean) => void; @@ -44,9 +51,15 @@ export interface NativeWSConfig { scenario: "new" | "changed", data: HostKeyData, ) => void; + onPassphraseRequired?: () => void; + onWarpgateAuthRequired?: (url: string, securityKey: string) => void; onPostConnectionSetup: () => void; onDisconnected: (hostName: string) => void; onConnectionFailed: (message: string) => void; + /** Fired when the backend session id is created/attached/cleared. */ + onSessionIdChange?: (sessionId: string | null) => void; + /** Fired for each `connection_log` WS message from the server. */ + onConnectionLog?: (entry: { level?: string; stage?: string; message: string }) => void; } export class NativeWebSocketManager { @@ -57,6 +70,7 @@ export class NativeWebSocketManager { private reconnectTimeout: ReturnType | null = null; private connectionTimeout: ReturnType | null = null; private pingInterval: ReturnType | null = null; + private pongTimeout: ReturnType | null = null; private shouldNotReconnect = false; private hasNotifiedFailure = false; private isAppInBackground = false; @@ -69,9 +83,20 @@ export class NativeWebSocketManager { private wsUrl: string | null = null; private serverSessionId: string | null = null; private pendingReattach = false; + private awaitingAuthCredentials = false; constructor(config: NativeWSConfig) { this.config = config; + // Seed the backend session id when reviving a backgrounded/cross-device tab. + if (config.initialSessionId) { + this.serverSessionId = config.initialSessionId; + } + } + + private setServerSessionId(id: string | null) { + if (this.serverSessionId === id) return; + this.serverSessionId = id; + this.config.onSessionIdChange?.(id); } async connect(cols: number, rows: number): Promise { @@ -107,6 +132,7 @@ export class NativeWebSocketManager { destroy(): void { this.destroyed = true; this.shouldNotReconnect = true; + this.awaitingAuthCredentials = false; if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { this.ws.send(JSON.stringify({ type: "disconnect" })); @@ -178,7 +204,7 @@ export class NativeWebSocketManager { ): void { this.cols = cols; this.rows = rows; - const updatedHostConfig = { + const updatedHostConfig: TerminalHostConfig = { ...this.config.hostConfig, password: credentials.password, key: credentials.sshKey, @@ -188,6 +214,11 @@ export class NativeWebSocketManager { | "key", }; + this.config.hostConfig = updatedHostConfig; + this.awaitingAuthCredentials = false; + this.shouldNotReconnect = false; + this.hasNotifiedFailure = false; + const messageData = { password: credentials.password, sshKey: credentials.sshKey, @@ -206,6 +237,38 @@ export class NativeWebSocketManager { }), ); } catch (e) {} + return; + } + + // Clear any pending connection timeout before starting a new connect to + // prevent the old timer from closing the newly-created socket. + this.clearAllTimers(); + this.connectWebSocket(); + } + + sendWarpgateContinue(): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "warpgate_auth_continue", data: {} })); + } catch (_) {} + } + } + + sendPassphraseResponse(passphrase: string): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send( + JSON.stringify({ + type: "reconnect_with_credentials", + data: { + keyPassword: passphrase, + hostConfig: { ...this.config.hostConfig, keyPassword: passphrase }, + cols: this.cols, + rows: this.rows, + }, + }), + ); + } catch (_) {} } } @@ -234,7 +297,7 @@ export class NativeWebSocketManager { } this.isReconnectFromBackground = true; - this.reconnectAttempts = 1; + this.reconnectAttempts = 0; if (this.ws) { try { @@ -326,6 +389,7 @@ export class NativeWebSocketManager { sessionId: this.serverSessionId, cols: this.cols, rows: this.rows, + tabInstanceId: this.config.tabInstanceId, }, }), ); @@ -337,6 +401,7 @@ export class NativeWebSocketManager { cols: this.cols, rows: this.rows, hostConfig: this.config.hostConfig, + tabInstanceId: this.config.tabInstanceId, }, }), ); @@ -361,6 +426,8 @@ export class NativeWebSocketManager { false, ); } else if (msg.type === "password_required") { + this.awaitingAuthCredentials = true; + this.shouldNotReconnect = true; this.config.onTotpRequired( (msg.prompt as string) || "Password:", true, @@ -369,6 +436,8 @@ export class NativeWebSocketManager { msg.type === "keyboard_interactive_available" || msg.type === "auth_method_not_available" ) { + this.awaitingAuthCredentials = true; + this.shouldNotReconnect = true; this.config.onAuthDialogNeeded("no_keyboard"); } else if (msg.type === "host_key_verification_required") { if (this.connectionTimeout) { @@ -380,6 +449,11 @@ export class NativeWebSocketManager { "new", msg.data as HostKeyData, ); + } else { + this.shouldNotReconnect = true; + this.notifyFailureOnce( + "Host key verification required but no handler is registered", + ); } } else if (msg.type === "host_key_changed") { if (this.connectionTimeout) { @@ -391,9 +465,39 @@ export class NativeWebSocketManager { "changed", msg.data as HostKeyData, ); + } else { + this.shouldNotReconnect = true; + this.notifyFailureOnce( + "Host key changed but no handler is registered", + ); + } + } else if (msg.type === "passphrase_required") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.config.onPassphraseRequired?.(); + } else if (msg.type === "warpgate_auth_required") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; } + this.config.onWarpgateAuthRequired?.( + (msg.url as string) || "", + (msg.securityKey as string) || "", + ); } else if (msg.type === "error") { const message = (msg.message as string) || "Unknown error"; + if ( + this.config.hostConfig.authType === "none" && + this.isAuthenticationError(message) + ) { + this.awaitingAuthCredentials = true; + this.shouldNotReconnect = true; + this.config.onAuthDialogNeeded("no_keyboard"); + return; + } + if (this.isUnrecoverableError(message)) { this.shouldNotReconnect = true; this.notifyFailureOnce("Authentication failed: " + message); @@ -414,16 +518,17 @@ export class NativeWebSocketManager { this.config.onPostConnectionSetup(); } } else if (msg.type === "disconnected") { - this.serverSessionId = null; + this.setServerSessionId(null); this.config.onDisconnected(this.config.hostConfig.name); } else if (msg.type === "pong") { + this.clearPongTimeout(); } else if (msg.type === "resized") { } else if (msg.type === "sessionCreated") { - this.serverSessionId = msg.sessionId as string; + this.setServerSessionId(msg.sessionId as string); } else if (msg.type === "sessionAttached") { - this.serverSessionId = msg.sessionId as string; + this.setServerSessionId(msg.sessionId as string); } else if (msg.type === "sessionExpired") { - this.serverSessionId = null; + this.setServerSessionId(null); this.pendingReattach = false; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send( @@ -433,17 +538,22 @@ export class NativeWebSocketManager { cols: this.cols, rows: this.rows, hostConfig: this.config.hostConfig, + tabInstanceId: this.config.tabInstanceId, }, }), ); } } else if (msg.type === "sessionTakenOver") { - this.serverSessionId = null; + this.setServerSessionId(null); this.shouldNotReconnect = true; this.config.onDisconnected(this.config.hostConfig.name); + } else if (msg.type === "connection_log") { + if (msg.data) { + this.config.onConnectionLog?.(msg.data as { level?: string; stage?: string; message: string }); + } } } catch (_) { - this.config.onData(event.data as string); + // Malformed/non-JSON frame — discard rather than printing garbage to terminal. } }; @@ -453,6 +563,13 @@ export class NativeWebSocketManager { this.connectionTimeout = null; } this.stopPingInterval(); + this.clearPongTimeout(); + // Only reset awaitingAuthCredentials when the connection closed without + // us actively waiting for user input (e.g. network drop). If shouldNotReconnect + // is set alongside it, we're mid-auth-dialog — leave both intact. + if (!this.shouldNotReconnect) { + this.awaitingAuthCredentials = false; + } if (this.isAppInBackground) { return; @@ -520,6 +637,21 @@ export class NativeWebSocketManager { if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { this.ws.send(JSON.stringify({ type: "ping" })); + // Start a pong timeout — if no pong arrives within 10s the connection + // is silently dead (TCP hang) and we need to force-reconnect. + this.clearPongTimeout(); + this.pongTimeout = setTimeout(() => { + this.pongTimeout = null; + if (this.destroyed || this.isAppInBackground) return; + if (this.ws) { + try { + this.ws.onclose = null; + this.ws.close(); + } catch (_) {} + this.ws = null; + } + this.scheduleReconnect(); + }, 10000); } catch (_) {} } }, 25000); @@ -532,6 +664,13 @@ export class NativeWebSocketManager { } } + private clearPongTimeout(): void { + if (this.pongTimeout) { + clearTimeout(this.pongTimeout); + this.pongTimeout = null; + } + } + private clearReconnectTimeout(): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); @@ -542,6 +681,7 @@ export class NativeWebSocketManager { private clearAllTimers(): void { this.stopPingInterval(); this.clearReconnectTimeout(); + this.clearPongTimeout(); if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; @@ -557,6 +697,10 @@ export class NativeWebSocketManager { } private isUnrecoverableError(message: string): boolean { + return this.isAuthenticationError(message); + } + + private isAuthenticationError(message: string): boolean { if (!message) return false; const m = message.toLowerCase(); return ( diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 6444752..0b02ca5 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -12,16 +12,20 @@ import { ActivityIndicator, Dimensions, AccessibilityInfo, + TouchableOpacity, } from "react-native"; import { WebView } from "react-native-webview"; +import { ChevronDown } from "lucide-react-native"; import { logActivity, getSnippets } from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; -import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; +import { BACKGROUNDS, ACCENT, TEXT_COLORS } from "../../../constants/designTokens"; import { TOTPDialog, SSHAuthDialog, HostKeyVerificationDialog, + PassphraseDialog, + WarpgateDialog, } from "@/app/tabs/dialogs"; import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; @@ -31,6 +35,8 @@ import { type TerminalHostConfig, type HostKeyData, } from "./NativeWebSocketManager"; +import { loadXtermAssets } from "./loadXtermAssets"; +import { useConnectionLog, ConnectionLog } from "../_shared/useConnectionLog"; interface TerminalProps { hostConfig: { @@ -45,12 +51,21 @@ interface TerminalProps { keyPassword?: string; keyType?: string; credentialId?: number; + jumpHosts?: { hostId: number }[]; + forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; terminalConfig?: Partial; }; isVisible: boolean; title?: string; onClose?: () => void; onBackgroundColorChange?: (color: string) => void; + /** Stable tab instance id (cross-device session tracking). */ + tabInstanceId?: string; + /** Backend session id to attach to on first connect (reviving a tab). */ + initialSessionId?: string | null; + /** Fired when the backend session id is created/attached/cleared. */ + onSessionIdChange?: (sessionId: string | null) => void; } export type TerminalHandle = { @@ -71,6 +86,9 @@ const TerminalComponent = forwardRef( title = "Terminal", onClose, onBackgroundColorChange, + tabInstanceId, + initialSessionId, + onSessionIdChange, }, ref, ) => { @@ -84,6 +102,7 @@ const TerminalComponent = forwardRef( ); const { config } = useTerminalCustomization(); + const log = useConnectionLog(); const [webViewKey, setWebViewKey] = useState(0); const [screenDimensions, setScreenDimensions] = useState( Dimensions.get("window"), @@ -100,7 +119,7 @@ const TerminalComponent = forwardRef( const [hasReceivedData, setHasReceivedData] = useState(false); const [htmlContent, setHtmlContent] = useState(""); const [terminalBackgroundColor, setTerminalBackgroundColor] = - useState("#09090b"); + useState(BACKGROUNDS.DARKEST); const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); @@ -109,12 +128,25 @@ const TerminalComponent = forwardRef( const [authDialogReason, setAuthDialogReason] = useState< "no_keyboard" | "auth_failed" | "timeout" >("auth_failed"); + const [passphraseRequired, setPassphraseRequired] = useState(false); + const [warpgateAuth, setWarpgateAuth] = useState<{ + url: string; + securityKey: string; + } | null>(null); const [isSelecting, setIsSelecting] = useState(false); + const [showScrollToBottomButton, setShowScrollToBottomButton] = + useState(false); const [hostKeyVerification, setHostKeyVerification] = useState<{ scenario: "new" | "changed"; data: HostKeyData; } | null>(null); + const xtermAssetsRef = useRef<{ + xtermJs: string; + xtermCss: string; + fitAddonJs: string; + } | null>(null); + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); const isScreenReaderEnabledRef = useRef(false); const [accessibilityText, setAccessibilityText] = useState(""); @@ -191,7 +223,7 @@ const TerminalComponent = forwardRef( [onClose], ); - const generateHTML = useCallback(() => { + const generateHTML = useCallback((assets: { xtermJs: string; xtermCss: string; fitAddonJs: string }) => { const { width, height } = screenDimensions; const terminalConfig: Partial = { @@ -231,9 +263,9 @@ const TerminalComponent = forwardRef( Terminal - - - + + +