mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
organize routes
This commit is contained in:
@@ -1,6 +1,519 @@
|
|||||||
|
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
||||||
|
import { useStore } from '@tanstack/react-form';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Host from '../host';
|
import {
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context';
|
||||||
|
import { useAppForm, useFieldContext } from '@/components/form-components';
|
||||||
|
import { KeyList } from '@/components/key-manager/KeyList';
|
||||||
|
import { useSshConnMutation } from '@/lib/query-fns';
|
||||||
|
import {
|
||||||
|
type ConnectionDetails,
|
||||||
|
connectionDetailsSchema,
|
||||||
|
secretsManager,
|
||||||
|
} from '@/lib/secrets-manager';
|
||||||
|
import { useTheme } from '@/theme';
|
||||||
|
|
||||||
export default function TabsIndex() {
|
export default function TabsIndex() {
|
||||||
return <Host />;
|
return <Host />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultValues: ConnectionDetails = {
|
||||||
|
host: 'test.rebex.net',
|
||||||
|
port: 22,
|
||||||
|
username: 'demo',
|
||||||
|
security: {
|
||||||
|
type: 'password',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function Host() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const sshConnMutation = useSshConnMutation();
|
||||||
|
const connectionForm = useAppForm({
|
||||||
|
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||||
|
defaultValues,
|
||||||
|
validators: {
|
||||||
|
onChange: connectionDetailsSchema,
|
||||||
|
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const securityType = useStore(
|
||||||
|
connectionForm.store,
|
||||||
|
(state) => state.values.security.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSubmitting = useStore(
|
||||||
|
connectionForm.store,
|
||||||
|
(state) => state.isSubmitting,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: Math.max(32, insets.bottom + 24) },
|
||||||
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: theme.colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.appName}>fressh</Text>
|
||||||
|
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Connect to SSH Server</Text>
|
||||||
|
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
||||||
|
|
||||||
|
<connectionForm.AppForm>
|
||||||
|
<connectionForm.AppField name="host">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Host"
|
||||||
|
testID="host"
|
||||||
|
placeholder="example.com or 192.168.0.10"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="port">
|
||||||
|
{(field) => (
|
||||||
|
<field.NumberField
|
||||||
|
label="Port"
|
||||||
|
placeholder="22"
|
||||||
|
testID="port"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="username">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Username"
|
||||||
|
testID="username"
|
||||||
|
placeholder="root"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
<connectionForm.AppField name="security.type">
|
||||||
|
{(field) => (
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<SegmentedControl
|
||||||
|
values={['Password', 'Private Key']}
|
||||||
|
selectedIndex={field.state.value === 'password' ? 0 : 1}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.handleChange(
|
||||||
|
event.nativeEvent.selectedSegmentIndex === 0
|
||||||
|
? 'password'
|
||||||
|
: 'key',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
{securityType === 'password' ? (
|
||||||
|
<connectionForm.AppField name="security.password">
|
||||||
|
{(field) => (
|
||||||
|
<field.TextField
|
||||||
|
label="Password"
|
||||||
|
testID="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
) : (
|
||||||
|
<connectionForm.AppField name="security.keyId">
|
||||||
|
{() => <KeyIdPickerField />}
|
||||||
|
</connectionForm.AppField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<connectionForm.SubmitButton
|
||||||
|
title="Connect"
|
||||||
|
testID="connect"
|
||||||
|
onPress={() => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
void connectionForm.handleSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</connectionForm.AppForm>
|
||||||
|
</View>
|
||||||
|
<PreviousConnectionsSection
|
||||||
|
onSelect={(connection) => {
|
||||||
|
connectionForm.setFieldValue('host', connection.host);
|
||||||
|
connectionForm.setFieldValue('port', connection.port);
|
||||||
|
connectionForm.setFieldValue('username', connection.username);
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.type',
|
||||||
|
connection.security.type,
|
||||||
|
);
|
||||||
|
if (connection.security.type === 'password') {
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.password',
|
||||||
|
connection.security.password,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
connectionForm.setFieldValue(
|
||||||
|
'security.keyId',
|
||||||
|
connection.security.keyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyIdPickerField() {
|
||||||
|
const field = useFieldContext<string>();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||||
|
const defaultPick = React.useMemo(() => {
|
||||||
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
|
const def = keys.find((k) => k.metadata?.isDefault);
|
||||||
|
return def ?? keys[0];
|
||||||
|
}, [listPrivateKeysQuery.data]);
|
||||||
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
|
|
||||||
|
const fieldValue = field.state.value;
|
||||||
|
const defaultPickId = defaultPick?.id;
|
||||||
|
const fieldHandleChange = field.handleChange;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!fieldValue && defaultPickId) {
|
||||||
|
fieldHandleChange(defaultPickId);
|
||||||
|
}
|
||||||
|
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
||||||
|
|
||||||
|
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
||||||
|
const selected = keys.find((k) => k.id === computedSelectedId);
|
||||||
|
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Private Key</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.input, { justifyContent: 'center' }]}
|
||||||
|
onPress={() => {
|
||||||
|
void listPrivateKeysQuery.refetch();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{!selected && (
|
||||||
|
<Text style={styles.mutedText}>
|
||||||
|
Open Key Manager to add/select a key
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.title}>Select Key</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalCloseButton}
|
||||||
|
onPress={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalCloseText}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<KeyList
|
||||||
|
mode="select"
|
||||||
|
onSelect={async (id) => {
|
||||||
|
field.handleChange(id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviousConnectionsSection(props: {
|
||||||
|
onSelect: (connection: ConnectionDetails) => void;
|
||||||
|
}) {
|
||||||
|
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.listSection}>
|
||||||
|
<Text style={styles.listTitle}>Previous Connections</Text>
|
||||||
|
{listConnectionsQuery.isLoading ? (
|
||||||
|
<Text style={styles.mutedText}>Loading connections...</Text>
|
||||||
|
) : listConnectionsQuery.isError ? (
|
||||||
|
<Text style={styles.errorText}>Error loading connections</Text>
|
||||||
|
) : listConnectionsQuery.data?.length ? (
|
||||||
|
<View style={styles.listContainer}>
|
||||||
|
{listConnectionsQuery.data?.map((conn) => (
|
||||||
|
<ConnectionRow
|
||||||
|
key={conn.id}
|
||||||
|
id={conn.id}
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.mutedText}>No saved connections yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionRow(props: {
|
||||||
|
id: string;
|
||||||
|
onSelect: (connection: ConnectionDetails) => void;
|
||||||
|
}) {
|
||||||
|
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
||||||
|
const details = detailsQuery.data?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => {
|
||||||
|
if (details) props.onSelect(details);
|
||||||
|
}}
|
||||||
|
disabled={!details}
|
||||||
|
>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
|
<Text style={styles.rowTitle}>
|
||||||
|
{details ? `${details.username}@${details.host}` : 'Loading...'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowSubtitle}>
|
||||||
|
{details ? `Port ${details.port} • ${details.security.type}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowChevron}>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
appTagline: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9AA0A6',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#111B34',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 24,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E293B',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
marginBottom: 6,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#9AA0A6',
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: 6,
|
||||||
|
color: '#FCA5A5',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#2563EB',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
listSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
listTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#E5E7EB',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
// Intentionally empty for RN compatibility
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
rowTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
color: '#E5E7EB',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
rowSubtitle: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
rowChevron: {
|
||||||
|
color: '#9AA0A6',
|
||||||
|
fontSize: 22,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderColor: '#1E293B',
|
||||||
|
borderWidth: 1,
|
||||||
|
maxHeight: '85%',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
modalCloseButton: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
},
|
||||||
|
modalCloseText: {
|
||||||
|
color: '#C6CBD3',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1 +1,25 @@
|
|||||||
export { default } from '../../settings';
|
import { Link } from 'expo-router';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export default function Tab() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
||||||
|
<Link
|
||||||
|
href="/(tabs)/settings/key-manager"
|
||||||
|
style={{ color: '#60A5FA', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
Manage Keys
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,217 @@
|
|||||||
import React from 'react';
|
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||||
import ShellDetail from '../../../shell/[connectionId]/[channelId]';
|
import {
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
useRouter,
|
||||||
|
} from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useTheme } from '@/theme';
|
||||||
|
|
||||||
export default function TabsShellDetail() {
|
export default function TabsShellDetail() {
|
||||||
return <ShellDetail />;
|
return <ShellDetail />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShellDetail() {
|
||||||
|
const { connectionId, channelId } = useLocalSearchParams<{
|
||||||
|
connectionId: string;
|
||||||
|
channelId: string;
|
||||||
|
}>();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const channelIdNum = Number(channelId);
|
||||||
|
const connection = RnRussh.getSshConnection(connectionId);
|
||||||
|
const shell = RnRussh.getSshShell(connectionId, channelIdNum);
|
||||||
|
|
||||||
|
const [shellData, setShellData] = useState('');
|
||||||
|
|
||||||
|
// Subscribe to data frames on the connection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection) return;
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
const channelListenerId = connection.addChannelListener(
|
||||||
|
(data: ArrayBuffer) => {
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(data);
|
||||||
|
const chunk = decoder.decode(bytes);
|
||||||
|
setShellData((prev) => prev + chunk);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode shell data', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
connection.removeChannelListener(channelListenerId);
|
||||||
|
};
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, [shellData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await connection?.disconnect();
|
||||||
|
} catch {}
|
||||||
|
router.replace('/shell');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
|
||||||
|
Disconnect
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||||
|
>
|
||||||
|
<View style={styles.terminal}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
contentContainerStyle={styles.terminalContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Text selectable style={styles.terminalText}>
|
||||||
|
{shellData || 'Connected. Output will appear here...'}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
<CommandInput
|
||||||
|
executeCommand={async (command) => {
|
||||||
|
await shell?.sendData(
|
||||||
|
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput(props: {
|
||||||
|
executeCommand: (command: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
|
||||||
|
async function handleExecute() {
|
||||||
|
if (!command.trim()) return;
|
||||||
|
await props.executeCommand(command);
|
||||||
|
setCommand('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
testID="command-input"
|
||||||
|
style={styles.commandInput}
|
||||||
|
value={command}
|
||||||
|
onChangeText={setCommand}
|
||||||
|
placeholder="Type a command and press Enter or Execute"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="send"
|
||||||
|
onSubmitEditing={handleExecute}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.executeButton, { marginTop: 8 }]}
|
||||||
|
onPress={handleExecute}
|
||||||
|
testID="execute-button"
|
||||||
|
>
|
||||||
|
<Text style={styles.executeButtonText}>Execute</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0B1324',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
terminalContent: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
terminalText: {
|
||||||
|
color: '#D1D5DB',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: 'Menlo',
|
||||||
|
android: 'monospace',
|
||||||
|
default: 'monospace',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
commandInput: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0E172B',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A3655',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: '#E5E7EB',
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: 'Menlo',
|
||||||
|
android: 'monospace',
|
||||||
|
default: 'monospace',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
executeButton: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
executeButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ShellStackLayout from '../../shell/_layout';
|
|
||||||
|
|
||||||
export default function TabsShellStack() {
|
export default function TabsShellStack() {
|
||||||
return <ShellStackLayout />;
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerBlurEffect: 'systemMaterial',
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Shells' }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="[connectionId]/[channelId]"
|
||||||
|
options={{ title: 'SSH Shell' }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,99 @@
|
|||||||
|
import {
|
||||||
|
type RnRussh,
|
||||||
|
type SshConnection,
|
||||||
|
type SshShellSession,
|
||||||
|
} from '@fressh/react-native-uniffi-russh';
|
||||||
|
import { FlashList } from '@shopify/flash-list';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ShellList from '../../shell/index';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { listSshShellsQueryOptions } from '@/lib/query-fns';
|
||||||
|
|
||||||
export default function TabsShellList() {
|
export default function TabsShellList() {
|
||||||
return <ShellList />;
|
return <ShellList />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ShellWithConnection = SshShellSession & { connection: SshConnection };
|
||||||
|
|
||||||
|
function ShellList() {
|
||||||
|
const connectionsWithShells = useQuery(listSshShellsQueryOptions);
|
||||||
|
|
||||||
|
if (!connectionsWithShells.data) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
return <LoadedState connectionsWithShells={connectionsWithShells.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadedState({
|
||||||
|
connectionsWithShells,
|
||||||
|
}: {
|
||||||
|
connectionsWithShells: ReturnType<
|
||||||
|
typeof RnRussh.listSshConnectionsWithShells
|
||||||
|
>;
|
||||||
|
}) {
|
||||||
|
const shellsFirstList = connectionsWithShells.reduce<ShellWithConnection[]>(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{shellsFirstList.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={shellsFirstList}
|
||||||
|
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
|
||||||
|
renderItem={({ item }) => <ShellCard shell={item} />}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||||
|
contentContainerStyle={{ paddingVertical: 16 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
|
||||||
|
<Link href="/">Go to Host</Link>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShellCard({ shell }: { shell: ShellWithConnection }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>{shell.connectionId}</Text>
|
||||||
|
<Text style={styles.text}>{shell.createdAtMs}</Text>
|
||||||
|
<Text style={styles.text}>{shell.pty}</Text>
|
||||||
|
<Text style={styles.text}>{shell.connection.connectionDetails.host}</Text>
|
||||||
|
<Text style={styles.text}>{shell.connection.connectionDetails.port}</Text>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
{shell.connection.connectionDetails.username}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
{shell.connection.connectionDetails.security.type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
text: { color: 'black', marginBottom: 8 },
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,575 +0,0 @@
|
|||||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
|
||||||
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
|
||||||
import { useStore } from '@tanstack/react-form';
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
Modal,
|
|
||||||
} from 'react-native';
|
|
||||||
import {
|
|
||||||
SafeAreaView,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from 'react-native-safe-area-context';
|
|
||||||
import { KeyList } from '@/components/key-manager/KeyList';
|
|
||||||
import { AbortSignalTimeout } from '@/lib/utils';
|
|
||||||
import { useAppForm, useFieldContext } from '../components/form-components';
|
|
||||||
import {
|
|
||||||
type ConnectionDetails,
|
|
||||||
connectionDetailsSchema,
|
|
||||||
secretsManager,
|
|
||||||
} from '../lib/secrets-manager';
|
|
||||||
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
|
||||||
import { useTheme } from '../theme';
|
|
||||||
|
|
||||||
const defaultValues: ConnectionDetails = {
|
|
||||||
host: 'test.rebex.net',
|
|
||||||
port: 22,
|
|
||||||
username: 'demo',
|
|
||||||
security: {
|
|
||||||
type: 'password',
|
|
||||||
password: 'password',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const useSshConnMutation = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (connectionDetails: ConnectionDetails) => {
|
|
||||||
try {
|
|
||||||
console.log('Connecting to SSH server...');
|
|
||||||
const sshConnection = await RnRussh.connect({
|
|
||||||
host: connectionDetails.host,
|
|
||||||
port: connectionDetails.port,
|
|
||||||
username: connectionDetails.username,
|
|
||||||
security:
|
|
||||||
connectionDetails.security.type === 'password'
|
|
||||||
? {
|
|
||||||
type: 'password',
|
|
||||||
password: connectionDetails.security.password,
|
|
||||||
}
|
|
||||||
: { type: 'key', privateKey: 'TODO' },
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
console.log('SSH connection status', status);
|
|
||||||
},
|
|
||||||
abortSignal: AbortSignalTimeout(5_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
await secretsManager.connections.utils.upsertConnection({
|
|
||||||
id: 'default',
|
|
||||||
details: connectionDetails,
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
const shellInterface = await sshConnection.startShell({
|
|
||||||
pty: 'Xterm',
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
console.log('SSH shell status', status);
|
|
||||||
},
|
|
||||||
abortSignal: AbortSignalTimeout(5_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const channelId = shellInterface.channelId as number;
|
|
||||||
const connectionId =
|
|
||||||
sshConnection.connectionId ??
|
|
||||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
|
||||||
console.log('Connected to SSH server', connectionId, channelId);
|
|
||||||
router.push({
|
|
||||||
pathname: '/shell/[connectionId]/[channelId]',
|
|
||||||
params: {
|
|
||||||
connectionId: connectionId,
|
|
||||||
channelId: String(channelId),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to SSH server', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Host() {
|
|
||||||
const theme = useTheme();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const sshConnMutation = useSshConnMutation();
|
|
||||||
const connectionForm = useAppForm({
|
|
||||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
|
||||||
defaultValues,
|
|
||||||
validators: {
|
|
||||||
onChange: connectionDetailsSchema,
|
|
||||||
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const securityType = useStore(
|
|
||||||
connectionForm.store,
|
|
||||||
(state) => state.values.security.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSubmitting = useStore(
|
|
||||||
connectionForm.store,
|
|
||||||
(state) => state.isSubmitting,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
{ paddingBottom: Math.max(32, insets.bottom + 24) },
|
|
||||||
]}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
style={{ backgroundColor: theme.colors.background }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ backgroundColor: theme.colors.background },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.appName}>fressh</Text>
|
|
||||||
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.title}>Connect to SSH Server</Text>
|
|
||||||
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
|
||||||
|
|
||||||
<connectionForm.AppForm>
|
|
||||||
<connectionForm.AppField name="host">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Host"
|
|
||||||
testID="host"
|
|
||||||
placeholder="example.com or 192.168.0.10"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="port">
|
|
||||||
{(field) => (
|
|
||||||
<field.NumberField
|
|
||||||
label="Port"
|
|
||||||
placeholder="22"
|
|
||||||
testID="port"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="username">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Username"
|
|
||||||
testID="username"
|
|
||||||
placeholder="root"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
<connectionForm.AppField name="security.type">
|
|
||||||
{(field) => (
|
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<SegmentedControl
|
|
||||||
values={['Password', 'Private Key']}
|
|
||||||
selectedIndex={field.state.value === 'password' ? 0 : 1}
|
|
||||||
onChange={(event) => {
|
|
||||||
field.handleChange(
|
|
||||||
event.nativeEvent.selectedSegmentIndex === 0
|
|
||||||
? 'password'
|
|
||||||
: 'key',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
{securityType === 'password' ? (
|
|
||||||
<connectionForm.AppField name="security.password">
|
|
||||||
{(field) => (
|
|
||||||
<field.TextField
|
|
||||||
label="Password"
|
|
||||||
testID="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
secureTextEntry
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
) : (
|
|
||||||
<connectionForm.AppField name="security.keyId">
|
|
||||||
{() => <KeyIdPickerField />}
|
|
||||||
</connectionForm.AppField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<connectionForm.SubmitButton
|
|
||||||
title="Connect"
|
|
||||||
testID="connect"
|
|
||||||
onPress={() => {
|
|
||||||
if (isSubmitting) return;
|
|
||||||
void connectionForm.handleSubmit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</connectionForm.AppForm>
|
|
||||||
</View>
|
|
||||||
<PreviousConnectionsSection
|
|
||||||
onSelect={(connection) => {
|
|
||||||
connectionForm.setFieldValue('host', connection.host);
|
|
||||||
connectionForm.setFieldValue('port', connection.port);
|
|
||||||
connectionForm.setFieldValue('username', connection.username);
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.type',
|
|
||||||
connection.security.type,
|
|
||||||
);
|
|
||||||
if (connection.security.type === 'password') {
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.password',
|
|
||||||
connection.security.password,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
connectionForm.setFieldValue(
|
|
||||||
'security.keyId',
|
|
||||||
connection.security.keyId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyIdPickerField() {
|
|
||||||
const field = useFieldContext<string>();
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
|
||||||
const defaultPick = React.useMemo(() => {
|
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
|
||||||
const def = keys.find((k) => k.metadata?.isDefault);
|
|
||||||
return def ?? keys[0];
|
|
||||||
}, [listPrivateKeysQuery.data]);
|
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
|
||||||
|
|
||||||
const fieldValue = field.state.value;
|
|
||||||
const defaultPickId = defaultPick?.id;
|
|
||||||
const fieldHandleChange = field.handleChange;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!fieldValue && defaultPickId) {
|
|
||||||
fieldHandleChange(defaultPickId);
|
|
||||||
}
|
|
||||||
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
|
||||||
|
|
||||||
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
|
||||||
const selected = keys.find((k) => k.id === computedSelectedId);
|
|
||||||
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<Text style={styles.label}>Private Key</Text>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.input, { justifyContent: 'center' }]}
|
|
||||||
onPress={() => {
|
|
||||||
void listPrivateKeysQuery.refetch();
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{!selected && (
|
|
||||||
<Text style={styles.mutedText}>
|
|
||||||
Open Key Manager to add/select a key
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Modal
|
|
||||||
visible={open}
|
|
||||||
transparent
|
|
||||||
animationType="slide"
|
|
||||||
onRequestClose={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<View style={styles.modalOverlay}>
|
|
||||||
<View style={styles.modalSheet}>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Text style={styles.title}>Select Key</Text>
|
|
||||||
<Pressable
|
|
||||||
style={styles.modalCloseButton}
|
|
||||||
onPress={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<Text style={styles.modalCloseText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<KeyList
|
|
||||||
mode="select"
|
|
||||||
onSelect={async (id) => {
|
|
||||||
field.handleChange(id);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviousConnectionsSection(props: {
|
|
||||||
onSelect: (connection: ConnectionDetails) => void;
|
|
||||||
}) {
|
|
||||||
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.listSection}>
|
|
||||||
<Text style={styles.listTitle}>Previous Connections</Text>
|
|
||||||
{listConnectionsQuery.isLoading ? (
|
|
||||||
<Text style={styles.mutedText}>Loading connections...</Text>
|
|
||||||
) : listConnectionsQuery.isError ? (
|
|
||||||
<Text style={styles.errorText}>Error loading connections</Text>
|
|
||||||
) : listConnectionsQuery.data?.length ? (
|
|
||||||
<View style={styles.listContainer}>
|
|
||||||
{listConnectionsQuery.data?.map((conn) => (
|
|
||||||
<ConnectionRow
|
|
||||||
key={conn.id}
|
|
||||||
id={conn.id}
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.mutedText}>No saved connections yet</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionRow(props: {
|
|
||||||
id: string;
|
|
||||||
onSelect: (connection: ConnectionDetails) => void;
|
|
||||||
}) {
|
|
||||||
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
|
||||||
const details = detailsQuery.data?.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={styles.row}
|
|
||||||
onPress={() => {
|
|
||||||
if (details) props.onSelect(details);
|
|
||||||
}}
|
|
||||||
disabled={!details}
|
|
||||||
>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowTitle}>
|
|
||||||
{details ? `${details.username}@${details.host}` : 'Loading...'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.rowSubtitle}>
|
|
||||||
{details ? `Port ${details.port} • ${details.security.type}` : ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.rowChevron}>›</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: 32,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
appName: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
appTagline: {
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#9AA0A6',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#111B34',
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 24,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 16,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
elevation: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#1E293B',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
marginBottom: 6,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#9AA0A6',
|
|
||||||
marginBottom: 24,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
inputGroup: {
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
marginTop: 6,
|
|
||||||
color: '#FCA5A5',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
mutedText: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
shadowColor: '#2563EB',
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 16,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
|
||||||
backgroundColor: '#3B82F6',
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
secondaryButtonText: {
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
listSection: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
listTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#E5E7EB',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
listContainer: {
|
|
||||||
// Intentionally empty for RN compatibility
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
rowTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
rowTitle: {
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
rowSubtitle: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
marginTop: 2,
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
rowChevron: {
|
|
||||||
color: '#9AA0A6',
|
|
||||||
fontSize: 22,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
modalOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
modalSheet: {
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
borderColor: '#1E293B',
|
|
||||||
borderWidth: 1,
|
|
||||||
maxHeight: '85%',
|
|
||||||
},
|
|
||||||
modalHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
modalCloseButton: {
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
},
|
|
||||||
modalCloseText: {
|
|
||||||
color: '#C6CBD3',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Link } from 'expo-router';
|
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
export default function Tab() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
|
||||||
<Link
|
|
||||||
href="/(tabs)/settings/key-manager"
|
|
||||||
style={{ color: '#60A5FA', marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
Manage Keys
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useTheme } from '../../../theme';
|
|
||||||
|
|
||||||
export default function ShellDetail() {
|
|
||||||
const { connectionId, channelId } = useLocalSearchParams<{
|
|
||||||
connectionId: string;
|
|
||||||
channelId: string;
|
|
||||||
}>();
|
|
||||||
const router = useRouter();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const channelIdNum = Number(channelId);
|
|
||||||
const connection = RnRussh.getSshConnection(connectionId);
|
|
||||||
const shell = RnRussh.getSshShell(connectionId, channelIdNum);
|
|
||||||
|
|
||||||
const [shellData, setShellData] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: 'SSH Shell',
|
|
||||||
headerRight: () => (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
try {
|
|
||||||
await connection?.disconnect();
|
|
||||||
} catch {}
|
|
||||||
router.replace('/shell');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
|
|
||||||
Disconnect
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [connection, navigation, router, theme.colors.primary]);
|
|
||||||
|
|
||||||
// Subscribe to data frames on the connection
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connection) return;
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
const channelListenerId = connection.addChannelListener(
|
|
||||||
(data: ArrayBuffer) => {
|
|
||||||
try {
|
|
||||||
const bytes = new Uint8Array(data);
|
|
||||||
const chunk = decoder.decode(bytes);
|
|
||||||
setShellData((prev) => prev + chunk);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to decode shell data', e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
connection.removeChannelListener(channelListenerId);
|
|
||||||
};
|
|
||||||
}, [connection]);
|
|
||||||
|
|
||||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
|
||||||
}, [shellData]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
||||||
<View
|
|
||||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
|
||||||
>
|
|
||||||
<View style={styles.terminal}>
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollViewRef}
|
|
||||||
contentContainerStyle={styles.terminalContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<Text selectable style={styles.terminalText}>
|
|
||||||
{shellData || 'Connected. Output will appear here...'}
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
<CommandInput
|
|
||||||
executeCommand={async (command) => {
|
|
||||||
await shell?.sendData(
|
|
||||||
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandInput(props: {
|
|
||||||
executeCommand: (command: string) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [command, setCommand] = useState('');
|
|
||||||
|
|
||||||
async function handleExecute() {
|
|
||||||
if (!command.trim()) return;
|
|
||||||
await props.executeCommand(command);
|
|
||||||
setCommand('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<TextInput
|
|
||||||
testID="command-input"
|
|
||||||
style={styles.commandInput}
|
|
||||||
value={command}
|
|
||||||
onChangeText={setCommand}
|
|
||||||
placeholder="Type a command and press Enter or Execute"
|
|
||||||
placeholderTextColor="#9AA0A6"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType="send"
|
|
||||||
onSubmitEditing={handleExecute}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.executeButton, { marginTop: 8 }]}
|
|
||||||
onPress={handleExecute}
|
|
||||||
testID="execute-button"
|
|
||||||
>
|
|
||||||
<Text style={styles.executeButtonText}>Execute</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0B1324',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
terminalContent: {
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
terminalText: {
|
|
||||||
color: '#D1D5DB',
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 18,
|
|
||||||
fontFamily: Platform.select({
|
|
||||||
ios: 'Menlo',
|
|
||||||
android: 'monospace',
|
|
||||||
default: 'monospace',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
commandInput: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 16,
|
|
||||||
fontFamily: Platform.select({
|
|
||||||
ios: 'Menlo',
|
|
||||||
android: 'monospace',
|
|
||||||
default: 'monospace',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
executeButton: {
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
executeButtonText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function ShellStackLayout() {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
screenOptions={{
|
|
||||||
headerBlurEffect: 'systemMaterial',
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen name="index" options={{ title: 'Shells' }} />
|
|
||||||
<Stack.Screen
|
|
||||||
name="[connectionId]/[channelId]"
|
|
||||||
options={{ title: 'SSH Shell' }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
RnRussh,
|
|
||||||
type SshConnection,
|
|
||||||
type SshShellSession,
|
|
||||||
} from '@fressh/react-native-uniffi-russh';
|
|
||||||
import { FlashList } from '@shopify/flash-list';
|
|
||||||
import { Link } from 'expo-router';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
|
||||||
|
|
||||||
type ShellWithConnection = SshShellSession & { connection: SshConnection };
|
|
||||||
|
|
||||||
export default function ShellList() {
|
|
||||||
const connectionsWithShells = RnRussh.listSshConnectionsWithShells();
|
|
||||||
const shellsFirstList = connectionsWithShells.reduce<ShellWithConnection[]>(
|
|
||||||
(acc, curr) => {
|
|
||||||
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
{shellsFirstList.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<FlashList
|
|
||||||
data={shellsFirstList}
|
|
||||||
renderItem={({ item }) => <ShellCard shell={item} />}
|
|
||||||
// maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 0.2 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
|
|
||||||
<Link href="/">Go to Host</Link>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShellCard({ shell }: { shell: ShellWithConnection }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.text}>{shell.connectionId}</Text>
|
|
||||||
<Text style={styles.text}>{shell.createdAtMs}</Text>
|
|
||||||
<Text style={styles.text}>{shell.pty}</Text>
|
|
||||||
<Text style={styles.text}>{shell.connection.connectionDetails.host}</Text>
|
|
||||||
<Text style={styles.text}>{shell.connection.connectionDetails.port}</Text>
|
|
||||||
<Text style={styles.text}>
|
|
||||||
{shell.connection.connectionDetails.username}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.text}>
|
|
||||||
{shell.connection.connectionDetails.security.type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
text: { color: '#E5E7EB', marginBottom: 8 },
|
|
||||||
});
|
|
||||||
68
apps/mobile/src/lib/query-fns.ts
Normal file
68
apps/mobile/src/lib/query-fns.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||||
|
import { queryOptions, useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { secretsManager, type ConnectionDetails } from './secrets-manager';
|
||||||
|
import { AbortSignalTimeout } from './utils';
|
||||||
|
|
||||||
|
export const useSshConnMutation = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (connectionDetails: ConnectionDetails) => {
|
||||||
|
try {
|
||||||
|
console.log('Connecting to SSH server...');
|
||||||
|
const sshConnection = await RnRussh.connect({
|
||||||
|
host: connectionDetails.host,
|
||||||
|
port: connectionDetails.port,
|
||||||
|
username: connectionDetails.username,
|
||||||
|
security:
|
||||||
|
connectionDetails.security.type === 'password'
|
||||||
|
? {
|
||||||
|
type: 'password',
|
||||||
|
password: connectionDetails.security.password,
|
||||||
|
}
|
||||||
|
: { type: 'key', privateKey: 'TODO' },
|
||||||
|
onStatusChange: (status) => {
|
||||||
|
console.log('SSH connection status', status);
|
||||||
|
},
|
||||||
|
abortSignal: AbortSignalTimeout(5_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await secretsManager.connections.utils.upsertConnection({
|
||||||
|
id: 'default',
|
||||||
|
details: connectionDetails,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
const shellInterface = await sshConnection.startShell({
|
||||||
|
pty: 'Xterm',
|
||||||
|
onStatusChange: (status) => {
|
||||||
|
console.log('SSH shell status', status);
|
||||||
|
},
|
||||||
|
abortSignal: AbortSignalTimeout(5_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelId = shellInterface.channelId as number;
|
||||||
|
const connectionId =
|
||||||
|
sshConnection.connectionId ??
|
||||||
|
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||||
|
console.log('Connected to SSH server', connectionId, channelId);
|
||||||
|
router.push({
|
||||||
|
pathname: '/shell/[connectionId]/[channelId]',
|
||||||
|
params: {
|
||||||
|
connectionId: connectionId,
|
||||||
|
channelId: String(channelId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to SSH server', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const listSshShellsQueryOptions = queryOptions({
|
||||||
|
queryKey: ['ssh-shells'],
|
||||||
|
queryFn: () => RnRussh.listSshConnectionsWithShells(),
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user