mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +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",
|
"astro-build.astro-vscode",
|
||||||
"mhutchie.git-graph",
|
"mhutchie.git-graph",
|
||||||
"esbenp.prettier-vscode",
|
"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-document-picker": "~14.0.7",
|
||||||
"expo-file-system": "~19.0.14",
|
"expo-file-system": "~19.0.14",
|
||||||
"expo-font": "~14.0.8",
|
"expo-font": "~14.0.8",
|
||||||
|
"expo-glass-effect": "^0.1.3",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.8",
|
"expo-image": "~3.0.8",
|
||||||
"expo-linking": "~8.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,11 +1,21 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||||
|
import React from 'react';
|
||||||
import { queryClient } from '../lib/utils';
|
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() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<NativeTabs backgroundColor="red">
|
<ThemeProvider>
|
||||||
|
<NativeTabs>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
<Label>Host</Label>
|
<Label>Host</Label>
|
||||||
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
<Icon sf="house.fill" drawable="custom_android_drawable" />
|
||||||
@@ -19,6 +29,7 @@ export default function RootLayout() {
|
|||||||
<Label>Settings</Label>
|
<Label>Settings</Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
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 { AbortSignalTimeout } from '@/lib/utils';
|
||||||
import { useAppForm, useFieldContext } from '../components/form-components';
|
import { useAppForm, useFieldContext } from '../components/form-components';
|
||||||
import { KeyManagerModal } from '../components/key-manager-modal';
|
import { KeyManagerModal } from '../components/key-manager-modal';
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
secretsManager,
|
secretsManager,
|
||||||
} from '../lib/secrets-manager';
|
} from '../lib/secrets-manager';
|
||||||
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
const defaultValues: ConnectionDetails = {
|
const defaultValues: ConnectionDetails = {
|
||||||
host: 'test.rebex.net',
|
host: 'test.rebex.net',
|
||||||
@@ -69,11 +73,11 @@ const useSshConnMutation = () => {
|
|||||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||||
console.log('Connected to SSH server', connectionId, channelId);
|
console.log('Connected to SSH server', connectionId, channelId);
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/shell',
|
pathname:
|
||||||
params: {
|
'/shell/' +
|
||||||
connectionId,
|
encodeURIComponent(connectionId) +
|
||||||
channelId: String(channelId),
|
'/' +
|
||||||
},
|
String(channelId),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error connecting to SSH server', error);
|
console.error('Error connecting to SSH server', error);
|
||||||
@@ -84,6 +88,8 @@ const useSshConnMutation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const sshConnMutation = useSshConnMutation();
|
const sshConnMutation = useSshConnMutation();
|
||||||
const connectionForm = useAppForm({
|
const connectionForm = useAppForm({
|
||||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||||
@@ -105,15 +111,25 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: Math.max(32, insets.bottom + 24) },
|
||||||
|
]}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
>
|
>
|
||||||
<View style={styles.container}>
|
<View
|
||||||
<SafeAreaView style={styles.header}>
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: theme.colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
<Text style={styles.appName}>fressh</Text>
|
<Text style={styles.appName}>fressh</Text>
|
||||||
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.title}>Connect to SSH Server</Text>
|
<Text style={styles.title}>Connect to SSH Server</Text>
|
||||||
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
<Text style={styles.subtitle}>Enter your server credentials</Text>
|
||||||
@@ -220,6 +236,7 @@ export default function Index() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
export default function Tab() {
|
export default function Tab() {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,5 +23,6 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: '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 { 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 { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
@@ -14,13 +11,16 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useTheme } from '../../../theme';
|
||||||
|
|
||||||
export default function Shell() {
|
export default function ShellDetail() {
|
||||||
// https://docs.expo.dev/router/reference/url-parameters/
|
|
||||||
const { connectionId, channelId } = useLocalSearchParams<{
|
const { connectionId, channelId } = useLocalSearchParams<{
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
}>();
|
}>();
|
||||||
|
const router = useRouter();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const channelIdNum = Number(channelId);
|
const channelIdNum = Number(channelId);
|
||||||
const connection = RnRussh.getSshConnection(connectionId);
|
const connection = RnRussh.getSshConnection(connectionId);
|
||||||
@@ -28,6 +28,26 @@ export default function Shell() {
|
|||||||
|
|
||||||
const [shellData, setShellData] = useState('');
|
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
|
// Subscribe to data frames on the connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connection) return;
|
if (!connection) return;
|
||||||
@@ -37,7 +57,6 @@ export default function Shell() {
|
|||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(data);
|
const bytes = new Uint8Array(data);
|
||||||
const chunk = decoder.decode(bytes);
|
const chunk = decoder.decode(bytes);
|
||||||
console.log('Received data (on Shell):', chunk.length, 'chars');
|
|
||||||
setShellData((prev) => prev + chunk);
|
setShellData((prev) => prev + chunk);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to decode shell data', e);
|
console.warn('Failed to decode shell data', e);
|
||||||
@@ -59,14 +78,14 @@ export default function Shell() {
|
|||||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-scroll to bottom when new data arrives
|
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
}, [shellData]);
|
}, [shellData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView keyboardShouldPersistTaps="handled">
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
<SafeAreaView style={styles.container}>
|
<View
|
||||||
<Text style={styles.title}>SSH Shell</Text>
|
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||||
|
>
|
||||||
<View style={styles.terminal}>
|
<View style={styles.terminal}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
@@ -80,14 +99,13 @@ export default function Shell() {
|
|||||||
</View>
|
</View>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
executeCommand={async (command) => {
|
executeCommand={async (command) => {
|
||||||
console.log('Executing command:', command);
|
|
||||||
await shell?.sendData(
|
await shell?.sendData(
|
||||||
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +121,7 @@ function CommandInput(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.commandBar}>
|
<View>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="command-input"
|
testID="command-input"
|
||||||
style={styles.commandInput}
|
style={styles.commandInput}
|
||||||
@@ -117,7 +135,7 @@ function CommandInput(props: {
|
|||||||
onSubmitEditing={handleExecute}
|
onSubmitEditing={handleExecute}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.executeButton}
|
style={[styles.executeButton, { marginTop: 8 }]}
|
||||||
onPress={handleExecute}
|
onPress={handleExecute}
|
||||||
testID="execute-button"
|
testID="execute-button"
|
||||||
>
|
>
|
||||||
@@ -133,12 +151,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#0B1324',
|
backgroundColor: '#0B1324',
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
terminal: {
|
terminal: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#0E172B',
|
backgroundColor: '#0E172B',
|
||||||
@@ -161,11 +173,6 @@ const styles = StyleSheet.create({
|
|||||||
default: 'monospace',
|
default: 'monospace',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
commandBar: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
commandInput: {
|
commandInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#0E172B',
|
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" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>Fressh - SSH Client</title>
|
<title>Fressh - SSH Client</title>
|
||||||
|
<!-- Page-level head content injection -->
|
||||||
|
<slot name="head" />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
import iosDarkAppIcon from '@fressh/assets/ios-dark-2.png';
|
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>
|
<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
|
<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"
|
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"
|
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
|
>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">
|
<div class="rounded-xl border border-gray-200 p-6 dark:border-gray-800">
|
||||||
<h2
|
<h2
|
||||||
class="text-base font-semibold tracking-wide text-gray-900 dark:text-gray-100"
|
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></ul>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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:
|
expo-font:
|
||||||
specifier: ~14.0.8
|
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)
|
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:
|
expo-haptics:
|
||||||
specifier: ~15.0.7
|
specifier: ~15.0.7
|
||||||
version: 15.0.7(expo@54.0.7)
|
version: 15.0.7(expo@54.0.7)
|
||||||
@@ -4734,6 +4737,13 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-glass-effect@0.1.3:
|
||||||
|
resolution: {integrity: sha512-wGWS8DdenyqwBHpVKwFCishtB08HD4SW6SZjIx9BXw92q/9b9fiygBypFob9dT0Mct6d05g7XRBRZ8Ryw5rYIg==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
react: '*'
|
||||||
|
react-native: '*'
|
||||||
|
|
||||||
expo-haptics@15.0.7:
|
expo-haptics@15.0.7:
|
||||||
resolution: {integrity: sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==}
|
resolution: {integrity: sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -14192,6 +14202,12 @@ snapshots:
|
|||||||
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)
|
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):
|
expo-haptics@15.0.7(expo@54.0.7):
|
||||||
dependencies:
|
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)
|
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