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
+
+
+
+
+
+
+
+
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 (
+ <>
+
+ Server Address
+
+ }
+ onSubmitEditing={onConnect}
+ returnKeyType="go"
+ />
+
+
+ Enter the address of your self-hosted Termix server, including http://
+ or https://.
+
+
+ {busy ? "Connecting…" : "Continue"}
+
+
+
+
+
+
+ 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 ? (
+
+ Username
+
+ }
+ />
+
+ Password
+
+
+
+
+
+ {busy ? "Signing in…" : "Sign in"}
+
+
+
+
+
+ Forgot password?
+
+
+ {caps.registrationAllowed ? (
+
+ Sign up
+
+ ) : null}
+
+
+ ) : (
+
+
+ Password login is disabled on this server. Use external sign-in
+ below.
+
+
+ )}
+
+ {caps.oidcAvailable ? (
+ <>
+ {showPasswordCard ? (
+
+
+
+ OR
+
+
+
+ ) : (
+
+ )}
+ }
+ >
+ Continue with SSO
+
+ >
+ ) : 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 (
+
+ Two-Factor Code
+
+ }
+ />
+
+
+ Enter the 6-digit code from your authenticator app, or one of your
+ backup codes.
+
+
+ {busy ? "Verifying…" : "Verify"}
+
+
+
+ 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 ? "Create Admin Account" : "Create Account"}
+ {firstUser ? (
+
+ This is the first account on the server and will be an administrator.
+
+ ) : (
+
+ )}
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {busy ? "Creating…" : "Create account"}
+
+
+
+ {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 (
+
+ Reset Password
+
+ {phase === "request" && (
+ <>
+
+ Enter your username. A reset code will be generated and printed to
+ the server's logs.
+
+ }
+ />
+
+ {busy ? "Sending…" : "Send reset code"}
+
+ >
+ )}
+
+ {phase === "code" && (
+ <>
+
+ Enter the 6-digit reset code from the server logs.
+
+ }
+ />
+
+ {busy ? "Verifying…" : "Verify code"}
+
+ >
+ )}
+
+ {phase === "password" && (
+ <>
+
+ Choose a new password.
+
+
+
+ {busy ? "Saving…" : "Set 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.
+
+ openBrowser(browserAuthUrl)}
+ >
+ {authenticating ? "Signing in..." : "Open browser"}
+
+
+ ) : 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:
-
-
-
+
+ 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
-
-
+
+
+ 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}
+
+ openAuthFlow(hasServerConfigured ? "login" : "server")}
+ >
+ {hasServerConfigured ? "Sign in" : "Add server"}
+
+
+ );
+}
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 */}
+
+
+ Cancel
+
+
{isChanged ? "Accept New Key" : "Connect & Trust"}
-
-
+
+
-
-
-
+
+
+
);
-};
-
-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={
+
+
+ Cancel
+
+
+ Connect
+
+
+ }
+ >
+
+ {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={
+
+
+ Cancel
+
+
+ Connect
+
+
+ }
>
-
-
-
+ {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={
+
+
+ Cancel
+
+
+ {isPasswordPrompt ? "Submit" : "Verify"}
+
+
+ }
>
-
-
-
+ 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={
+
-
- {prompt}
-
-
- {isPasswordPrompt
- ? "Enter your password to continue"
- : "Enter your TOTP verification code"}
-
-
-
-
-
-
-
-
-
-
- Cancel
-
-
-
-
- {isPasswordPrompt ? "Submit" : "Verify"}
-
-
-
-
-
-
-
+
+
+ }
+ />
+
);
-};
-
-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={
+
+
+ Cancel
+
+ }
+ className="flex-1 border-accent-brand/40"
+ textClassName="text-accent-brand"
+ >
+ Open Browser
+
+
+ Continue
+
+
+ }
+ >
+ {/* 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"}
+
+
+ Save
+
+
+
+ {/* 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" ? (
+
+ ) : 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" ? (
+
+ ) : null}
+
+ {/* SSH Key auth */}
+ {form.authType === "key" ? (
+ <>
+
+
+
+
+
+
+ Generate new key pair
+
+
+ Replaces current private and public key
+
+
+
+
+ handleGenerateKeyPair(
+ "ssh-ed25519",
+ undefined,
+ "ed25519",
+ )
+ }
+ >
+ Ed25519
+
+
+ handleGenerateKeyPair(
+ "ecdsa-sha2-nistp256",
+ undefined,
+ "ecdsa",
+ )
+ }
+ >
+ ECDSA
+
+
+ handleGenerateKeyPair("ssh-rsa", 2048, "rsa")
+ }
+ >
+ RSA 2048
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Certificate signed by a CA for certificate-based auth
+
+
+ }
+ >
+ Upload
+
+
+ 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 (
+
+ {label}
+ {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
+
+ }
+ >
+ Add
+
+
+
+ {/* 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() ? (
+
+ Add credential
+
+ ) : 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={
+ <>
+ setDeleteTarget(null)}
+ >
+ Cancel
+
+
+ Delete
+
+ >
+ }
+ />
+
+ {/* 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"}
+
+
+ Save
+
+
+
+ {/* 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 */}
+
+
+ {/* 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 */}
+
+
+ {/* Notes */}
+
+ >
+ ) : 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" ? (
+ <>
+
+ Private Key
+ }
+ >
+ Choose file
+
+
+ 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}
+
+
+
+ >
+ ) : null}
+
+ {activeTab === "rdp" ? (
+
+ ) : null}
+
+ {activeTab === "vnc" ? (
+
+ ) : null}
+
+ {activeTab === "telnet" ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {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
-
-
+ }
+ />
+ setCredentialListOpen(true)}
+ icon={ }
+ />
+ setQuickConnectOpen(true)}
+ icon={ }
+ />
+ setShowFilter(true)}
+ icon={
+
+ }
+ />
+ setShowSort(true)}
+ icon={
+
+ }
+ />
+
-
-
+ disabled={refreshing}
+ icon={
+
+ }
+ />
-
+
+ }
+ trailing={
+ searchQuery ? (
+ setSearchQuery("")} hitSlop={8}>
+
+
+ ) : undefined
+ }
/>
+
+
+ {loading ? (
+
+
+
+ Loading hosts…
+
+
+ ) : (
}
>
- {filteredFolders.length === 0 ? (
-
-
- {searchQuery
- ? "No hosts found matching your search"
- : "No hosts configured"}
+ {isEmpty ? (
+
+
+ {q || isFilterActive
+ ? "No hosts match your search"
+ : "No hosts yet"}
+ {!q && !isFilterActive ? (
+ }
+ >
+ Add host
+
+ ) : 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.
+
+
+
+
+
+
+
+ Host / IP
+
+
+
+ Port
+ setPort(v.replace(/\D/g, ""))}
+ keyboardType="number-pad"
+ />
+
+
+
+
+ Username
+
+
+
+
+ value={authType}
+ onChange={setAuthType}
+ options={[
+ { id: "password", label: "Password" },
+ { id: "key", label: "Key" },
+ ]}
+ />
+
+ {authType === "password" ? (
+
+ Password
+
+
+ ) : (
+
+ Private Key
+
+
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ Connect
+
+
+
+
+
+ );
+}
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={
+
+
+ Cancel
+
+
+ Verify
+
+
+ }
+ >
+ 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={
+
+
+ Cancel
+
+
+ Authenticate
+
+
+ }
+ >
+
+
+
+
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+
+ {/* Interactive auth */}
+ }
+ title="Authentication Required"
+ description="This host needs credentials to connect."
+ footer={
+
+
+ Cancel
+
+
+ Connect
+
+
+ }
+ >
+
+ 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 ? (
+ }
+ onPress={onRetry}
+ >
+ Retry
+
+ ) : null}
+
+ ) : status === "empty" ? (
+
+ {emptyIcon ?? }
+
+ {emptyMessage}
+
+ {onRetry ? (
+ }
+ onPress={onRetry}
+ >
+ Refresh
+
+ ) : 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}
+
+
+
+
+ Cancel
+
+
+ {confirmLabel}
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ Cancel
+
+ onApply(octal)}
+ >{`Apply (${octal})`}
+
+ }
+ >
+
+ {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}
+ }
+ >
+ Reconnect
+
+
+ )}
+
+ {/* 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
+
+
+ Reset
+
+
+
+
+ );
+}
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
-
-
-
+
+
+