mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-10 05:42:50 +00:00
changes
This commit is contained in:
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -6,6 +6,7 @@
|
||||
"astro-build.astro-vscode",
|
||||
"mhutchie.git-graph",
|
||||
"esbenp.prettier-vscode",
|
||||
"yoavbls.pretty-ts-errors"
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"ctf0.duplicated-code-new"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-file-system": "~19.0.14",
|
||||
"expo-font": "~14.0.8",
|
||||
"expo-glass-effect": "^0.1.3",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-linking": "~8.0.8",
|
||||
|
||||
13
apps/mobile/src/app/(modals)/_layout.tsx
Normal file
13
apps/mobile/src/app/(modals)/_layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ModalsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
presentation: 'modal',
|
||||
headerBlurEffect: 'systemMaterial',
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
apps/mobile/src/app/(modals)/key-manager.tsx
Normal file
33
apps/mobile/src/app/(modals)/key-manager.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Pressable, Text } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { KeyList } from '@/components/key-manager/KeyList';
|
||||
|
||||
export default function KeyManagerModalRoute() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ select?: string }>();
|
||||
const selectMode = params.select === '1';
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0B1324' }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: selectMode ? 'Select Key' : 'Manage Keys',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<KeyList
|
||||
mode={selectMode ? 'select' : 'manage'}
|
||||
onSelect={async () => router.back()}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// // styles kept for potential future local additions
|
||||
// const styles = StyleSheet.create({});
|
||||
13
apps/mobile/src/app/(shared)/key-manager.tsx
Normal file
13
apps/mobile/src/app/(shared)/key-manager.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { KeyList } from '@/components/key-manager/KeyList';
|
||||
|
||||
export default function SharedKeyManager() {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0B1324' }}>
|
||||
<Stack.Screen options={{ title: 'Manage Keys' }} />
|
||||
<KeyList mode="manage" />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||
import React from 'react';
|
||||
import { queryClient } from '../lib/utils';
|
||||
import { ThemeProvider } from '../theme';
|
||||
|
||||
console.log('Fressh App Init', {
|
||||
isLiquidGlassAvailable: isLiquidGlassAvailable(),
|
||||
});
|
||||
|
||||
// https://docs.expo.dev/versions/latest/sdk/navigation-bar/
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NativeTabs backgroundColor="red">
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Label>Host</Label>
|
||||
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="shell">
|
||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||
<Label>Shell</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||
<Label>Settings</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
<ThemeProvider>
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Label>Host</Label>
|
||||
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="shell">
|
||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||
<Label>Shell</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<Icon sf="gear" drawable="custom_settings_drawable" />
|
||||
<Label>Settings</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context';
|
||||
import { AbortSignalTimeout } from '@/lib/utils';
|
||||
import { useAppForm, useFieldContext } from '../components/form-components';
|
||||
import { KeyManagerModal } from '../components/key-manager-modal';
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
secretsManager,
|
||||
} from '../lib/secrets-manager';
|
||||
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
const defaultValues: ConnectionDetails = {
|
||||
host: 'test.rebex.net',
|
||||
@@ -69,11 +73,11 @@ const useSshConnMutation = () => {
|
||||
`${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',
|
||||
params: {
|
||||
connectionId,
|
||||
channelId: String(channelId),
|
||||
},
|
||||
pathname:
|
||||
'/shell/' +
|
||||
encodeURIComponent(connectionId) +
|
||||
'/' +
|
||||
String(channelId),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error connecting to SSH server', error);
|
||||
@@ -84,6 +88,8 @@ const useSshConnMutation = () => {
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const sshConnMutation = useSshConnMutation();
|
||||
const connectionForm = useAppForm({
|
||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||
@@ -105,121 +111,132 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.header}>
|
||||
<Text style={styles.appName}>fressh</Text>
|
||||
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
||||
</SafeAreaView>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Connect to SSH Server</Text>
|
||||
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
||||
<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">
|
||||
<connectionForm.AppForm>
|
||||
<connectionForm.AppField name="host">
|
||||
{(field) => (
|
||||
<field.TextField
|
||||
label="Password"
|
||||
testID="password"
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
label="Host"
|
||||
testID="host"
|
||||
placeholder="example.com or 192.168.0.10"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
)}
|
||||
</connectionForm.AppField>
|
||||
) : (
|
||||
<connectionForm.AppField name="security.keyId">
|
||||
{() => <KeyIdPicker />}
|
||||
<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">
|
||||
{() => <KeyIdPicker />}
|
||||
</connectionForm.AppField>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<connectionForm.SubmitButton
|
||||
title="Connect"
|
||||
testID="connect"
|
||||
onPress={() => {
|
||||
if (isSubmitting) return;
|
||||
void connectionForm.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</connectionForm.AppForm>
|
||||
<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>
|
||||
<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>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
export default function Tab() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Tab [Home|Settings]</Text>
|
||||
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
|
||||
<Link
|
||||
href="/(shared)/key-manager"
|
||||
style={{ color: '#60A5FA', marginBottom: 8 }}
|
||||
>
|
||||
Manage Keys
|
||||
</Link>
|
||||
<Link href="/(modals)/key-manager?select=1" style={{ color: '#60A5FA' }}>
|
||||
Open Key Picker (modal)
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -13,5 +23,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#0B1324',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* This is the page that is shown after an ssh connection
|
||||
*/
|
||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
@@ -14,13 +11,16 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
export default function Shell() {
|
||||
// https://docs.expo.dev/router/reference/url-parameters/
|
||||
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);
|
||||
@@ -28,6 +28,26 @@ export default function Shell() {
|
||||
|
||||
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;
|
||||
@@ -37,7 +57,6 @@ export default function Shell() {
|
||||
try {
|
||||
const bytes = new Uint8Array(data);
|
||||
const chunk = decoder.decode(bytes);
|
||||
console.log('Received data (on Shell):', chunk.length, 'chars');
|
||||
setShellData((prev) => prev + chunk);
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode shell data', e);
|
||||
@@ -59,14 +78,14 @@ export default function Shell() {
|
||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-scroll to bottom when new data arrives
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, [shellData]);
|
||||
|
||||
return (
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text style={styles.title}>SSH Shell</Text>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<View
|
||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||
>
|
||||
<View style={styles.terminal}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
@@ -80,14 +99,13 @@ export default function Shell() {
|
||||
</View>
|
||||
<CommandInput
|
||||
executeCommand={async (command) => {
|
||||
console.log('Executing command:', command);
|
||||
await shell?.sendData(
|
||||
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +121,7 @@ function CommandInput(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.commandBar}>
|
||||
<View>
|
||||
<TextInput
|
||||
testID="command-input"
|
||||
style={styles.commandInput}
|
||||
@@ -117,7 +135,7 @@ function CommandInput(props: {
|
||||
onSubmitEditing={handleExecute}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.executeButton}
|
||||
style={[styles.executeButton, { marginTop: 8 }]}
|
||||
onPress={handleExecute}
|
||||
testID="execute-button"
|
||||
>
|
||||
@@ -133,12 +151,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#0B1324',
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
color: '#E5E7EB',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
terminal: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0E172B',
|
||||
@@ -161,11 +173,6 @@ const styles = StyleSheet.create({
|
||||
default: 'monospace',
|
||||
}),
|
||||
},
|
||||
commandBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
commandInput: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0E172B',
|
||||
18
apps/mobile/src/app/shell/_layout.tsx
Normal file
18
apps/mobile/src/app/shell/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
16
apps/mobile/src/app/shell/index.tsx
Normal file
16
apps/mobile/src/app/shell/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function ShellList() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
|
||||
<Link href="/">Go to Host</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
text: { color: '#E5E7EB', marginBottom: 8 },
|
||||
});
|
||||
254
apps/mobile/src/components/key-manager/KeyList.tsx
Normal file
254
apps/mobile/src/components/key-manager/KeyList.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { secretsManager } from '@/lib/secrets-manager';
|
||||
|
||||
export type KeyListMode = 'manage' | 'select';
|
||||
|
||||
export function KeyList(props: {
|
||||
mode: KeyListMode;
|
||||
onSelect?: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
const listKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const id = `key_${Date.now()}`;
|
||||
const pair = await secretsManager.keys.utils.generateKeyPair({
|
||||
type: 'rsa',
|
||||
keySize: 4096,
|
||||
});
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: id,
|
||||
metadata: { priority: 0, label: 'New Key', isDefault: false },
|
||||
value: pair,
|
||||
});
|
||||
},
|
||||
onSuccess: () => listKeysQuery.refetch(),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
generateMutation.isPending && { opacity: 0.7 },
|
||||
]}
|
||||
disabled={generateMutation.isPending}
|
||||
onPress={() => generateMutation.mutate()}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{generateMutation.isPending
|
||||
? 'Generating…'
|
||||
: 'Generate New RSA 4096 Key'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{listKeysQuery.isLoading ? (
|
||||
<Text style={styles.muted}>Loading keys…</Text>
|
||||
) : listKeysQuery.isError ? (
|
||||
<Text style={styles.error}>Error loading keys</Text>
|
||||
) : listKeysQuery.data?.length ? (
|
||||
<View>
|
||||
{listKeysQuery.data.map((k) => (
|
||||
<KeyRow
|
||||
key={k.id}
|
||||
entryId={k.id}
|
||||
mode={props.mode}
|
||||
onSelected={props.onSelect}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.muted}>No keys yet</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow(props: {
|
||||
entryId: string;
|
||||
mode: KeyListMode;
|
||||
onSelected?: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
|
||||
const entry = entryQuery.data;
|
||||
const [label, setLabel] = React.useState(
|
||||
entry?.manifestEntry.metadata?.label ?? '',
|
||||
);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (newLabel: string) => {
|
||||
if (!entry) return;
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: entry.manifestEntry.id,
|
||||
value: entry.value,
|
||||
metadata: {
|
||||
priority: entry.manifestEntry.metadata.priority,
|
||||
label: newLabel,
|
||||
isDefault: entry.manifestEntry.metadata.isDefault,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => entryQuery.refetch(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await secretsManager.keys.utils.deletePrivateKey(props.entryId);
|
||||
},
|
||||
onSuccess: () => entryQuery.refetch(),
|
||||
});
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const entries = await secretsManager.keys.utils.listEntriesWithValues();
|
||||
await Promise.all(
|
||||
entries.map((e) =>
|
||||
secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: e.id,
|
||||
value: e.value,
|
||||
metadata: {
|
||||
priority: e.metadata.priority,
|
||||
label: e.metadata.label,
|
||||
isDefault: e.id === props.entryId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await entryQuery.refetch();
|
||||
if (props.mode === 'select' && props.onSelected) {
|
||||
await props.onSelected(props.entryId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={styles.rowTitle}>
|
||||
{entry.manifestEntry.metadata?.label ?? entry.manifestEntry.id}
|
||||
{entry.manifestEntry.metadata?.isDefault ? ' • Default' : ''}
|
||||
</Text>
|
||||
<Text style={styles.rowSub}>ID: {entry.manifestEntry.id}</Text>
|
||||
{props.mode === 'manage' ? (
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor="#9AA0A6"
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
{props.mode === 'select' ? (
|
||||
<Pressable
|
||||
onPress={() => setDefaultMutation.mutate()}
|
||||
style={styles.primaryButton}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Select</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{props.mode === 'manage' ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.secondaryButton,
|
||||
renameMutation.isPending && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={() => renameMutation.mutate(label)}
|
||||
disabled={renameMutation.isPending}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
{renameMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!entry.manifestEntry.metadata?.isDefault ? (
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => setDefaultMutation.mutate()}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Set Default</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={styles.dangerButton}
|
||||
onPress={() => deleteMutation.mutate()}
|
||||
>
|
||||
<Text style={styles.dangerButtonText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
primaryButton: {
|
||||
backgroundColor: '#2563EB',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
primaryButtonText: { color: '#FFFFFF', fontWeight: '700', fontSize: 14 },
|
||||
muted: { color: '#9AA0A6' },
|
||||
error: { color: '#FCA5A5' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#0E172B',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 10,
|
||||
},
|
||||
rowTitle: { color: '#E5E7EB', fontSize: 15, fontWeight: '600' },
|
||||
rowSub: { color: '#9AA0A6', fontSize: 12, marginTop: 2 },
|
||||
rowActions: { gap: 6, alignItems: 'flex-end' },
|
||||
secondaryButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: { color: '#C6CBD3', fontWeight: '600', fontSize: 12 },
|
||||
dangerButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#7F1D1D',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dangerButtonText: { color: '#FCA5A5', fontWeight: '700', fontSize: 12 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
backgroundColor: '#0E172B',
|
||||
color: '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
58
apps/mobile/src/theme/index.tsx
Normal file
58
apps/mobile/src/theme/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
export type AppTheme = {
|
||||
colors: {
|
||||
background: string;
|
||||
surface: string;
|
||||
terminalBackground: string;
|
||||
border: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
muted: string;
|
||||
primary: string;
|
||||
buttonTextOnPrimary: string;
|
||||
inputBackground: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const darkTheme: AppTheme = {
|
||||
colors: {
|
||||
background: '#0B1324',
|
||||
surface: '#111B34',
|
||||
terminalBackground: '#0E172B',
|
||||
border: '#2A3655',
|
||||
textPrimary: '#E5E7EB',
|
||||
textSecondary: '#C6CBD3',
|
||||
muted: '#9AA0A6',
|
||||
primary: '#2563EB',
|
||||
buttonTextOnPrimary: '#FFFFFF',
|
||||
inputBackground: '#0E172B',
|
||||
},
|
||||
};
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: AppTheme;
|
||||
setTheme: (theme: AppTheme) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function ThemeProvider(props: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = React.useState<AppTheme>(darkTheme);
|
||||
|
||||
const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{props.children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = React.useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx.theme;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import Analytics from '@vercel/analytics/astro';
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Fressh - SSH Client</title>
|
||||
<!-- Page-level head content injection -->
|
||||
<slot name="head" />
|
||||
<Analytics />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import iosDarkAppIcon from '@fressh/assets/ios-dark-2.png';
|
||||
|
||||
const title = 'Fressh — Mobile SSH Client';
|
||||
const description =
|
||||
'A clean, powerful mobile SSH client. Built with React Native, powered by Russh (Rust-based SSH), and planned to be open source.';
|
||||
const image = iosDarkAppIcon.src;
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Fragment slot="head">
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
</Fragment>
|
||||
<section
|
||||
class="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-gray-50 to-white px-6 dark:from-gray-900 dark:to-black"
|
||||
>
|
||||
@@ -24,7 +40,9 @@ import iosDarkAppIcon from '@fressh/assets/ios-dark-2.png';
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-full border border-dashed border-gray-300 px-3 py-1 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300"
|
||||
>Coming soon</span
|
||||
>
|
||||
<div class="mt-10 grid w-full max-w-3xl gap-6 text-left sm:grid-cols-2">
|
||||
<div
|
||||
class="mt-10 grid w-full max-w-3xl gap-6 text-left sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div class="rounded-xl border border-gray-200 p-6 dark:border-gray-800">
|
||||
<h2
|
||||
class="text-base font-semibold tracking-wide text-gray-900 dark:text-gray-100"
|
||||
@@ -70,6 +88,38 @@ import iosDarkAppIcon from '@fressh/assets/ios-dark-2.png';
|
||||
<ul></ul>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 p-6 dark:border-gray-800">
|
||||
<h2
|
||||
class="text-base font-semibold tracking-wide text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Technical specs
|
||||
</h2>
|
||||
<ul class="mt-3 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-blue-500">•</span>
|
||||
<span>UI built with React Native</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-blue-500">•</span>
|
||||
<span>
|
||||
SSH core powered by
|
||||
<a
|
||||
href="https://github.com/Eugeny/russh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 underline decoration-dotted hover:decoration-solid dark:text-blue-400"
|
||||
>
|
||||
Russh
|
||||
</a>
|
||||
(Rust-based SSH library)
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-blue-500">•</span>
|
||||
<span>Planned to be open source</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
32
docs/ui-overhaul.md
Normal file
32
docs/ui-overhaul.md
Normal file
@@ -0,0 +1,32 @@
|
||||
Right now the styling for my app is very bad. @apps/mobile/src/app/\_layout.tsx
|
||||
@apps/mobile/src/app/index.tsx @apps/mobile/src/app/ shell.tsx First off, on IOS
|
||||
if I do not use the SafeAreaView, the app header title renders underneath the
|
||||
notch. https:// docs.expo.dev/develop/user-interface/safe-areas/ and underneath
|
||||
the system bars https://docs.expo.dev/develop/user-interface/system- bars/ I
|
||||
tried to implement it but I am not sure if the safearea should go above the
|
||||
scrollview or below? I see no examples of using safe area with a scroll view in
|
||||
the docs. Right now if I like over drage in either direction, there is a white
|
||||
background around everything. EI my chosen background color is not edge to edge
|
||||
when overscrolling. I was kind of hoping to use liquid glass like described the
|
||||
images show in this guide https://docs.expo.dev/router/advanced/native-tabs/ but
|
||||
the ones that show up on my ios simulator are not liquid glass. Maybe I need an
|
||||
ios simulator with a different IOS version? (mine is 18) maybe I need to enable
|
||||
it somewhere? https://docs.expo.dev/versions/latest/sdk/glass-effect/ Also I
|
||||
eventually want users to pick their own theme. That will live in the settings
|
||||
page but all the colors should come from a single theme file (currently doesn't
|
||||
exist). Also when I did the layout for the index screen, I was not planning on
|
||||
having a bottom tab bar, now that I do it should probably change. I really hate
|
||||
everything about the private key modal, I would rather it be its own shared
|
||||
route https://docs.expo.dev/router/advanced/shared- routes/ or maybe it should
|
||||
be a modal? https://docs.expo.dev/router/advanced/modals/? not sure but I know I
|
||||
want to be able to bring up the same private key management Ui from the settings
|
||||
screen and the index screen. I imagine they should be pushed to the top of the
|
||||
stack of whatever bottom tab you are currently on. We will also need the shell
|
||||
screen to start out on a list shell screen. The shell detail (what is currently
|
||||
shell.tsx) will need to be renamed and moved. The placeholder text in the
|
||||
command input box is truncated on the shell screen. I do not think we should put
|
||||
the execute button on the same line as the command input text. It makes the
|
||||
command input textbox too small. We also need to add a disconnect button to the
|
||||
screen for ios users because they do not have a back button. (Maybe we do this
|
||||
in the header bar?)
|
||||
https://docs.expo.dev/router/advanced/stack/#configure-header-bar
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -94,6 +94,9 @@ importers:
|
||||
expo-font:
|
||||
specifier: ~14.0.8
|
||||
version: 14.0.8(expo@54.0.7)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
|
||||
expo-glass-effect:
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3(expo@54.0.7)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
|
||||
expo-haptics:
|
||||
specifier: ~15.0.7
|
||||
version: 15.0.7(expo@54.0.7)
|
||||
@@ -4734,6 +4737,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-glass-effect@0.1.3:
|
||||
resolution: {integrity: sha512-wGWS8DdenyqwBHpVKwFCishtB08HD4SW6SZjIx9BXw92q/9b9fiygBypFob9dT0Mct6d05g7XRBRZ8Ryw5rYIg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-haptics@15.0.7:
|
||||
resolution: {integrity: sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==}
|
||||
peerDependencies:
|
||||
@@ -14192,6 +14202,12 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
|
||||
|
||||
expo-glass-effect@0.1.3(expo@54.0.7)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
expo: 54.0.7(@babel/core@7.28.3)(@expo/metro-runtime@6.1.1)(expo-router@6.0.4)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
|
||||
|
||||
expo-haptics@15.0.7(expo@54.0.7):
|
||||
dependencies:
|
||||
expo: 54.0.7(@babel/core@7.28.3)(@expo/metro-runtime@6.1.1)(expo-router@6.0.4)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
Reference in New Issue
Block a user