This commit is contained in:
EthanShoeDev
2025-09-15 03:29:49 -04:00
parent 66f72376b8
commit a9fc8dee46
17 changed files with 709 additions and 156 deletions

View File

@@ -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"
]
}

View File

@@ -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",

View File

@@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
export default function ModalsLayout() {
return (
<Stack
screenOptions={{
presentation: 'modal',
headerBlurEffect: 'systemMaterial',
headerTransparent: true,
}}
/>
);
}

View 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({});

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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',
},
});

View File

@@ -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',

View 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>
);
}

View 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 },
});

View 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,
},
});

View 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;
}

View File

@@ -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>

View File

@@ -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
View 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
View File

@@ -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)