diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4848514..1c7abcc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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" ] } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fe057fb..ef5383c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/app/(modals)/_layout.tsx b/apps/mobile/src/app/(modals)/_layout.tsx new file mode 100644 index 0000000..9256273 --- /dev/null +++ b/apps/mobile/src/app/(modals)/_layout.tsx @@ -0,0 +1,13 @@ +import { Stack } from 'expo-router'; + +export default function ModalsLayout() { + return ( + + ); +} diff --git a/apps/mobile/src/app/(modals)/key-manager.tsx b/apps/mobile/src/app/(modals)/key-manager.tsx new file mode 100644 index 0000000..def48b8 --- /dev/null +++ b/apps/mobile/src/app/(modals)/key-manager.tsx @@ -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 ( + + ( + router.back()}> + Close + + ), + }} + /> + router.back()} + /> + + ); +} + +// // styles kept for potential future local additions +// const styles = StyleSheet.create({}); diff --git a/apps/mobile/src/app/(shared)/key-manager.tsx b/apps/mobile/src/app/(shared)/key-manager.tsx new file mode 100644 index 0000000..e80a0ea --- /dev/null +++ b/apps/mobile/src/app/(shared)/key-manager.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index e3b4862..2a2afc8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -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 ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 71d49b9..e0c58fd 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -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 ( - - - - fressh - A fast, friendly SSH client - - - Connect to SSH Server - Enter your server credentials + + + + + fressh + A fast, friendly SSH client + + + Connect to SSH Server + Enter your server credentials - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - {(field) => ( - - { - field.handleChange( - event.nativeEvent.selectedSegmentIndex === 0 - ? 'password' - : 'key', - ); - }} - /> - - )} - - {securityType === 'password' ? ( - + + {(field) => ( )} - ) : ( - - {() => } + + {(field) => ( + + )} - )} + + {(field) => ( + + )} + + + {(field) => ( + + { + field.handleChange( + event.nativeEvent.selectedSegmentIndex === 0 + ? 'password' + : 'key', + ); + }} + /> + + )} + + {securityType === 'password' ? ( + + {(field) => ( + + )} + + ) : ( + + {() => } + + )} - - { - if (isSubmitting) return; - void connectionForm.handleSubmit(); - }} - /> - - + + { + if (isSubmitting) return; + void connectionForm.handleSubmit(); + }} + /> + + + + { + 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, + ); + } + }} + /> - { - 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, - ); - } - }} - /> - - + + ); } diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx index 4a90ffe..2d43feb 100644 --- a/apps/mobile/src/app/settings.tsx +++ b/apps/mobile/src/app/settings.tsx @@ -1,9 +1,19 @@ +import { Link } from 'expo-router'; import { View, Text, StyleSheet } from 'react-native'; export default function Tab() { return ( - Tab [Home|Settings] + Settings + + Manage Keys + + + Open Key Picker (modal) + ); } @@ -13,5 +23,6 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', + backgroundColor: '#0B1324', }, }); diff --git a/apps/mobile/src/app/shell.tsx b/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx similarity index 80% rename from apps/mobile/src/app/shell.tsx rename to apps/mobile/src/app/shell/[connectionId]/[channelId].tsx index 118648c..6c37837 100644 --- a/apps/mobile/src/app/shell.tsx +++ b/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx @@ -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: () => ( + { + try { + await connection?.disconnect(); + } catch {} + router.replace('/shell'); + }} + > + + Disconnect + + + ), + }); + }, [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(null); useEffect(() => { - // Auto-scroll to bottom when new data arrives scrollViewRef.current?.scrollToEnd({ animated: true }); }, [shellData]); return ( - - - SSH Shell + + { - console.log('Executing command:', command); await shell?.sendData( Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer, ); }} /> - - + + ); } @@ -103,7 +121,7 @@ function CommandInput(props: { } return ( - + @@ -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', diff --git a/apps/mobile/src/app/shell/_layout.tsx b/apps/mobile/src/app/shell/_layout.tsx new file mode 100644 index 0000000..802c6ed --- /dev/null +++ b/apps/mobile/src/app/shell/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from 'expo-router'; + +export default function ShellStackLayout() { + return ( + + + + + ); +} diff --git a/apps/mobile/src/app/shell/index.tsx b/apps/mobile/src/app/shell/index.tsx new file mode 100644 index 0000000..491f47d --- /dev/null +++ b/apps/mobile/src/app/shell/index.tsx @@ -0,0 +1,16 @@ +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function ShellList() { + return ( + + No active shells. Connect from Host tab. + Go to Host + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + text: { color: '#E5E7EB', marginBottom: 8 }, +}); diff --git a/apps/mobile/src/components/key-manager/KeyList.tsx b/apps/mobile/src/components/key-manager/KeyList.tsx new file mode 100644 index 0000000..e322b3c --- /dev/null +++ b/apps/mobile/src/components/key-manager/KeyList.tsx @@ -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; +}) { + 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 ( + + generateMutation.mutate()} + > + + {generateMutation.isPending + ? 'Generating…' + : 'Generate New RSA 4096 Key'} + + + + {listKeysQuery.isLoading ? ( + Loading keys… + ) : listKeysQuery.isError ? ( + Error loading keys + ) : listKeysQuery.data?.length ? ( + + {listKeysQuery.data.map((k) => ( + + ))} + + ) : ( + No keys yet + )} + + ); +} + +function KeyRow(props: { + entryId: string; + mode: KeyListMode; + onSelected?: (id: string) => void | Promise; +}) { + 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 ( + + + + {entry.manifestEntry.metadata?.label ?? entry.manifestEntry.id} + {entry.manifestEntry.metadata?.isDefault ? ' • Default' : ''} + + ID: {entry.manifestEntry.id} + {props.mode === 'manage' ? ( + + ) : null} + + + {props.mode === 'select' ? ( + setDefaultMutation.mutate()} + style={styles.primaryButton} + > + Select + + ) : null} + {props.mode === 'manage' ? ( + renameMutation.mutate(label)} + disabled={renameMutation.isPending} + > + + {renameMutation.isPending ? 'Saving…' : 'Save'} + + + ) : null} + {!entry.manifestEntry.metadata?.isDefault ? ( + setDefaultMutation.mutate()} + > + Set Default + + ) : null} + deleteMutation.mutate()} + > + Delete + + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/theme/index.tsx b/apps/mobile/src/theme/index.tsx new file mode 100644 index 0000000..893512c --- /dev/null +++ b/apps/mobile/src/theme/index.tsx @@ -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( + undefined, +); + +export function ThemeProvider(props: { children: React.ReactNode }) { + const [theme, setTheme] = React.useState(darkTheme); + + const value = React.useMemo(() => ({ theme, setTheme }), [theme]); + + return ( + + {props.children} + + ); +} + +export function useTheme() { + const ctx = React.useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx.theme; +} diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index c8cef3b..59ab0db 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -11,6 +11,8 @@ import Analytics from '@vercel/analytics/astro'; Fressh - SSH Client + + diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 73c9b20..561aaca 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -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; --- + + + + + + + + + + +
@@ -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 -
+

+
+

+ Technical specs +

+
    +
  • + + UI built with React Native +
  • +
  • + + + SSH core powered by + + Russh + + (Rust-based SSH library) + +
  • +
  • + + Planned to be open source +
  • +
+
diff --git a/docs/ui-overhaul.md b/docs/ui-overhaul.md new file mode 100644 index 0000000..c672d48 --- /dev/null +++ b/docs/ui-overhaul.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12f5188..20dab69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)