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

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