diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2739c56..e8a7607 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -61,6 +61,7 @@ "react-native": "0.81.4", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "1.18.5", + "react-native-logs": "^5.5.0", "react-native-mmkv": "^3.3.1", "react-native-reanimated": "~4.1.2", "react-native-safe-area-context": "~5.6.1", diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 4e3fb78..cabeb32 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -14,6 +14,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useAppForm, useFieldContext } from '@/components/form-components'; import { KeyList } from '@/components/key-manager/KeyList'; +import { rootLogger } from '@/lib/logger'; import { useSshConnMutation } from '@/lib/query-fns'; import { connectionDetailsSchema, @@ -23,6 +24,8 @@ import { import { useTheme } from '@/lib/theme'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; +const logger = rootLogger.extend('TabsIndex'); + export default function TabsIndex() { return ; } @@ -65,7 +68,7 @@ function Host() { const formErrors = useStore(connectionForm.store, (state) => state.errorMap); useEffect(() => { if (!formErrors || Object.keys(formErrors).length === 0) return; - console.log('formErrors', JSON.stringify(formErrors, null, 2)); + logger.info('formErrors', JSON.stringify(formErrors, null, 2)); }, [formErrors]); const isSubmitting = useStore( @@ -207,7 +210,7 @@ function Host() { submittingTitle={buttonLabel} testID="connect" onPress={() => { - console.log('Connect button pressed', { isSubmitting }); + logger.info('Connect button pressed', { isSubmitting }); if (isSubmitting) return; void connectionForm.handleSubmit(); }} diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 2017cc9..16380b1 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -14,11 +14,14 @@ import { import React, { createContext, startTransition, + useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'; +import { rootLogger } from '@/lib/logger'; import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; @@ -26,6 +29,8 @@ import { useContextSafe } from '@/lib/utils'; type IconName = keyof typeof Ionicons.glyphMap; +const logger = rootLogger.extend('TabsShellDetail'); + export default function TabsShellDetail() { const [ready, setReady] = useState(false); @@ -71,7 +76,6 @@ const encoder = new TextEncoder(); function ShellDetail() { const xtermRef = useRef(null); - const terminalReadyRef = useRef(false); const listenerIdRef = useRef(null); const searchParams = useLocalSearchParams<{ @@ -95,7 +99,7 @@ function ShellDetail() { useEffect(() => { if (shell && connection) return; - console.log('shell or connection not found, replacing route with /shell'); + logger.info('shell or connection not found, replacing route with /shell'); router.back(); }, [connection, router, shell]); @@ -111,6 +115,37 @@ function ShellDetail() { const marginBottom = useBottomTabSpacing(); + const [modifierKeysActive, setModifierKeysActive] = useState< + KeyboardToolbarModifierButtonProps[] + >([]); + + const sendBytes = useCallback( + (bytes: Uint8Array) => { + if (!shell) return; + + modifierKeysActive + .sort((a, b) => a.orderPreference - b.orderPreference) + .forEach((m) => { + if (!m.canApplyModifierToBytes(bytes)) return; + bytes = m.applyModifierToBytes(bytes); + }); + + shell.sendData(bytes.buffer).catch((e: unknown) => { + logger.warn('sendData failed', e); + router.back(); + }); + }, + [shell, router, modifierKeysActive], + ); + const toolbarContext: KeyboardToolbarContextType = useMemo( + () => ({ + modifierKeysActive, + setModifierKeysActive, + sendBytes, + }), + [sendBytes, modifierKeysActive], + ); + return ( <> { + logger.info('Disconnect button pressed'); if (!connection) return; try { await connection.disconnect(); } catch (e) { - console.warn('Failed to disconnect', e); + logger.warn('Failed to disconnect', e); } }} > @@ -151,93 +187,80 @@ function ShellDetail() { keyboardVerticalOffset={120} style={{ flex: 1, gap: 4 }} > - - + { - if (terminalReadyRef.current) return; - terminalReadyRef.current = true; + > + { + if (!shell) throw new Error('Shell not found'); - if (!shell) throw new Error('Shell not found'); - - // Replay from head, then attach live listener - void (async () => { - const res = shell.readBuffer({ mode: 'head' }); - console.log('readBuffer(head)', { - chunks: res.chunks.length, - nextSeq: res.nextSeq, - dropped: res.dropped, - }); - if (res.chunks.length) { - const chunks = res.chunks.map((c) => c.bytes); - const xr = xtermRef.current; - if (xr) { - xr.writeMany(chunks.map((c) => new Uint8Array(c))); - xr.flush(); - } - } - const id = shell.addListener( - (ev: ListenerEvent) => { - if ('kind' in ev) { - console.log('listener.dropped', ev); - return; + // Replay from head, then attach live listener + void (async () => { + const res = shell.readBuffer({ mode: 'head' }); + logger.info('readBuffer(head)', { + chunks: res.chunks.length, + nextSeq: res.nextSeq, + dropped: res.dropped, + }); + if (res.chunks.length) { + const chunks = res.chunks.map((c) => c.bytes); + const xr = xtermRef.current; + if (xr) { + xr.writeMany(chunks.map((c) => new Uint8Array(c))); + xr.flush(); } - const chunk = ev; - const xr3 = xtermRef.current; - if (xr3) xr3.write(new Uint8Array(chunk.bytes)); - }, - { cursor: { mode: 'seq', seq: res.nextSeq } }, - ); - console.log('shell listener attached', id.toString()); - listenerIdRef.current = id; - })(); - // Focus to pop the keyboard (iOS needs the prop we set) - const xr2 = xtermRef.current; - if (xr2) xr2.focus(); - }} - onData={(terminalMessage) => { - if (!shell) return; - const bytes = encoder.encode(terminalMessage); - shell.sendData(bytes.buffer).catch((e: unknown) => { - console.warn('sendData failed', e); - router.back(); - }); - }} - /> - - { - if (!shell) return; - shell.sendData(bytes.buffer).catch((e: unknown) => { - console.warn('sendData failed', e); - router.back(); - }); - }} - /> + } + const id = shell.addListener( + (ev: ListenerEvent) => { + if ('kind' in ev) { + logger.warn('listener.dropped', ev); + return; + } + const chunk = ev; + const xr3 = xtermRef.current; + if (xr3) xr3.write(new Uint8Array(chunk.bytes)); + }, + { cursor: { mode: 'seq', seq: res.nextSeq } }, + ); + logger.info('shell listener attached', id.toString()); + listenerIdRef.current = id; + })(); + // Focus to pop the keyboard (iOS needs the prop we set) + const xr2 = xtermRef.current; + if (xr2) xr2.focus(); + }} + onData={(terminalMessage) => { + if (!shell) return; + const bytes = encoder.encode(terminalMessage); + sendBytes(bytes); + }} + /> + + + {/* + >; sendBytes: (bytes: Uint8Array) => void; }; -const KeyboardToolBarContext = createContext(null); +const KeyboardToolBarContext = createContext( + null, +); -function KeyboardToolbar(props: KeyboardToolbarProps) { +function KeyboardToolbar() { return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); } @@ -321,6 +348,61 @@ function KeyboardToolbarButtonPreset({ ); } +type ModifierContract = { + canApplyModifierToBytes: (bytes: Uint8Array) => boolean; + applyModifierToBytes: ( + bytes: Uint8Array, + ) => Uint8Array; + orderPreference: number; +}; + +const noOpModifier: ModifierContract = { + canApplyModifierToBytes: (_) => false, + applyModifierToBytes: (bytes) => bytes, + orderPreference: 0, +}; + +const escapeByte = 27; + +const ctrlModifier: ModifierContract = { + orderPreference: 10, + canApplyModifierToBytes: (bytes) => { + const firstByte = bytes[0]; + if (firstByte === undefined) return false; + return mapByteToCtrl(firstByte) != null; + }, + applyModifierToBytes: (bytes) => { + const firstByte = bytes[0]; + if (firstByte === undefined) return bytes; + const ctrlByte = mapByteToCtrl(firstByte); + if (ctrlByte == null) return bytes; + return new Uint8Array([ctrlByte]); + }, +}; + +const altModifier: ModifierContract = { + orderPreference: 20, + canApplyModifierToBytes: (bytes) => { + return bytes.length > 0 && bytes[0] !== escapeByte; + }, + applyModifierToBytes: (bytes) => { + const result = new Uint8Array(bytes.length + 1); + result[0] = escapeByte; + result.set(bytes, 1); + return result; + }, +}; + +function mapByteToCtrl(byte: number): number | null { + if (byte === 32) return 0; // Ctrl+Space + const uppercase = byte & 0b1101_1111; // Fold to uppercase / control range + if (uppercase >= 64 && uppercase <= 95) { + return uppercase & 0x1f; + } + if (byte === 63) return 127; // Ctrl+? + return null; +} + const keyboardToolbarButtonPresetToProps: Record< KeyboardToolbarButtonPresetType, KeyboardToolbarButtonProps @@ -332,10 +414,7 @@ const keyboardToolbarButtonPresetToProps: Record< end: { label: 'END', sendBytes: new Uint8Array([27, 91, 70]) }, pgup: { label: 'PGUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pgdn: { label: 'PGDN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, - fn: { label: 'FN', isModifier: true }, tab: { label: 'TAB', sendBytes: new Uint8Array([9]) }, - ctrl: { label: 'CTRL', isModifier: true }, - alt: { label: 'ALT', isModifier: true }, left: { iconName: 'arrow-back', sendBytes: new Uint8Array([27, 91, 68]) }, up: { iconName: 'arrow-up', sendBytes: new Uint8Array([27, 91, 65]) }, down: { iconName: 'arrow-down', sendBytes: new Uint8Array([27, 91, 66]) }, @@ -347,29 +426,40 @@ const keyboardToolbarButtonPresetToProps: Record< delete: { label: 'DELETE', sendBytes: new Uint8Array([27, 91, 51, 126]) }, pageup: { label: 'PAGEUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pagedown: { label: 'PAGEDOWN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, + fn: { + label: 'FN', + type: 'modifier', + ...noOpModifier, + }, + ctrl: { label: 'CTRL', type: 'modifier', ...ctrlModifier }, + alt: { label: 'ALT', type: 'modifier', ...altModifier }, }; -type KeyboardToolbarButtonProps = ( +type KeyboardToolbarButtonViewProps = | { - isModifier: true; + label: string; } | { - sendBytes: Uint8Array; - } -) & - ( - | { - label: string; - } - | { - iconName: IconName; - } - ); + iconName: IconName; + }; + +type KeyboardToolbarModifierButtonProps = { + type: 'modifier'; +} & ModifierContract & + KeyboardToolbarButtonViewProps; +type KeyboardToolbarInstantButtonProps = { + type?: 'sendBytes'; + sendBytes: Uint8Array; +} & KeyboardToolbarButtonViewProps; + +type KeyboardToolbarButtonProps = + | KeyboardToolbarModifierButtonProps + | KeyboardToolbarInstantButtonProps; function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { const theme = useTheme(); - const [modifierActive, setModifierActive] = useState(false); - const { sendBytes } = useContextSafe(KeyboardToolBarContext); + const { sendBytes, modifierKeysActive, setModifierKeysActive } = + useContextSafe(KeyboardToolBarContext); const isTextLabel = 'label' in props; const children = isTextLabel ? ( @@ -382,16 +472,26 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { /> ); + const modifierActive = + props.type === 'modifier' && modifierKeysActive.includes(props); + return ( { - console.log('button pressed'); - if ('isModifier' in props && props.isModifier) { - setModifierActive((active) => !active); - } else if ('sendBytes' in props) { - // todo: send key press - sendBytes(new Uint8Array(props.sendBytes)); + if (props.type === 'modifier') { + setModifierKeysActive((modifierKeysActive) => + modifierKeysActive.includes(props) + ? modifierKeysActive.filter((m) => m !== props) + : [...modifierKeysActive, props], + ); + return; } + + if ('sendBytes' in props) { + sendBytes(new Uint8Array(props.sendBytes)); + return; + } + throw new Error('Invalid button type'); }} style={[ { diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx index 93b7d09..5b394cd 100644 --- a/apps/mobile/src/app/(tabs)/shell/index.tsx +++ b/apps/mobile/src/app/(tabs)/shell/index.tsx @@ -17,11 +17,14 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useShallow } from 'zustand/react/shallow'; +import { rootLogger } from '@/lib/logger'; import { preferences } from '@/lib/preferences'; import {} from '@/lib/query-fns'; import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; +const logger = rootLogger.extend('TabsShellList'); + export default function TabsShellList() { const theme = useTheme(); return ( @@ -35,7 +38,7 @@ function ShellContent() { const connections = useSshStore( useShallow((s) => Object.values(s.connections)), ); - console.log('DEBUG list view connections', connections.length); + logger.debug('list view connections', connections.length); return ( diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index b821b86..cf89a1e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -4,17 +4,18 @@ import { isLiquidGlassAvailable } from 'expo-glass-effect'; import { Stack } from 'expo-router'; import React from 'react'; import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { rootLogger } from '@/lib/logger'; import { ThemeProvider } from '../lib/theme'; import { queryClient } from '../lib/utils'; -console.log('Fressh App Init', { +rootLogger.info('Fressh App Init', { isLiquidGlassAvailable: isLiquidGlassAvailable(), }); void DevClient.registerDevMenuItems([ { callback: () => { - console.log('Hello from dev menu'); + rootLogger.info('Hello from dev menu'); }, name: 'Hello from dev menu', }, diff --git a/apps/mobile/src/lib/logger.ts b/apps/mobile/src/lib/logger.ts new file mode 100644 index 0000000..66ef4d7 --- /dev/null +++ b/apps/mobile/src/lib/logger.ts @@ -0,0 +1,25 @@ +import { logger, consoleTransport } from "react-native-logs"; + +export const rootLogger = logger.createLogger({ + levels: { + debug: 0, + info: 1, + warn: 2, + error: 3, + }, + severity: "debug", + transport: consoleTransport, + transportOptions: { + colors: { + info: "blueBright", + warn: "yellowBright", + error: "redBright", + }, + }, + async: true, + dateFormat: "time", + printLevel: true, + printDate: true, + fixedExtLvlLength: false, + enabled: true, +}); diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 182a687..90c2bea 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -1,10 +1,13 @@ import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; +import { rootLogger } from './logger'; import { secretsManager, type InputConnectionDetails } from './secrets-manager'; import { useSshStore } from './ssh-store'; import { AbortSignalTimeout } from './utils'; +const logger = rootLogger.extend('QueryFns'); + export const useSshConnMutation = (opts?: { onConnectionProgress?: (progressEvent: SshConnectionProgress) => void; }) => { @@ -14,20 +17,20 @@ export const useSshConnMutation = (opts?: { return useMutation({ mutationFn: async (connectionDetails: InputConnectionDetails) => { try { - console.log('Connecting to SSH server...'); + logger.info('Connecting to SSH server...'); // Resolve security into the RN bridge shape const security = connectionDetails.security.type === 'password' ? { - type: 'password' as const, - password: connectionDetails.security.password, - } + type: 'password' as const, + password: connectionDetails.security.password, + } : { - type: 'key' as const, - privateKey: await secretsManager.keys.utils - .getPrivateKey(connectionDetails.security.keyId) - .then((e) => e.value), - }; + type: 'key' as const, + privateKey: await secretsManager.keys.utils + .getPrivateKey(connectionDetails.security.keyId) + .then((e) => e.value), + }; const sshConnection = await connect({ host: connectionDetails.host, @@ -35,11 +38,11 @@ export const useSshConnMutation = (opts?: { username: connectionDetails.username, security, onConnectionProgress: (progressEvent) => { - console.log('SSH connect progress event', progressEvent); + logger.info('SSH connect progress event', progressEvent); opts?.onConnectionProgress?.(progressEvent); }, onServerKey: async (serverKeyInfo) => { - console.log('SSH server key', serverKeyInfo); + logger.info('SSH server key', serverKeyInfo); return true; }, abortSignal: AbortSignalTimeout(5_000), @@ -55,7 +58,7 @@ export const useSshConnMutation = (opts?: { abortSignal: AbortSignalTimeout(5_000), }); - console.log( + logger.info( 'Connected to SSH server', sshConnection.connectionId, shellHandle.channelId, @@ -68,7 +71,7 @@ export const useSshConnMutation = (opts?: { }, }); } catch (error) { - console.error('Error connecting to SSH server', error); + logger.error('Error connecting to SSH server', error); throw error; } }, diff --git a/apps/mobile/src/lib/secrets-manager.ts b/apps/mobile/src/lib/secrets-manager.ts index 0337c42..5a688a7 100644 --- a/apps/mobile/src/lib/secrets-manager.ts +++ b/apps/mobile/src/lib/secrets-manager.ts @@ -3,14 +3,10 @@ import { queryOptions } from '@tanstack/react-query'; import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; import * as z from 'zod'; +import { rootLogger } from './logger'; import { queryClient, type StrictOmit } from './utils'; -const shouldLog = false as boolean; -const log = (...args: Parameters) => { - if (shouldLog) { - console.log(...args); - } -}; +const logger = rootLogger.extend('SecretsManager'); function splitIntoChunks(data: string, chunkSize: number): string[] { const chunks: string[] = []; @@ -74,17 +70,17 @@ function makeBetterSecureStore< const rawRootManifestString = await SecureStore.getItemAsync(rootManifestKey); - log('DEBUG rawRootManifestString', rawRootManifestString); + logger.debug('rawRootManifestString', rawRootManifestString); - log( + logger.info( `Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`, ); const unsafedRootManifest: unknown = rawRootManifestString ? JSON.parse(rawRootManifestString) : { - manifestVersion: rootManifestVersion, - manifestChunksIds: [], - }; + manifestVersion: rootManifestVersion, + manifestChunksIds: [], + }; const rootManifest = rootManifestSchema.parse(unsafedRootManifest); const manifestChunks = await Promise.all( rootManifest.manifestChunksIds.map(async (manifestChunkId) => { @@ -94,7 +90,7 @@ function makeBetterSecureStore< ); if (!rawManifestChunkString) throw new Error('Manifest chunk not found'); - log( + logger.info( `Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`, ); const unsafedManifestChunk: unknown = JSON.parse( @@ -120,7 +116,7 @@ function makeBetterSecureStore< Array.from({ length: manifestEntry.chunkCount }, async (_, chunkIdx) => { const entryKeyString = entryKey(manifestEntry.id, chunkIdx); const rawEntryChunk = await SecureStore.getItemAsync(entryKeyString); - log( + logger.info( `Entry chunk for ${entryKeyString} is ${rawEntryChunk?.length} bytes`, ); if (!rawEntryChunk) throw new Error('Entry chunk not found'); @@ -206,7 +202,7 @@ function makeBetterSecureStore< (mChunk) => mChunk.manifestChunk.entries.length === 0, ); if (emptyManifestChunks.length > 0) { - log('DEBUG: removing empty manifest chunks', emptyManifestChunks.length); + logger.debug('removing empty manifest chunks', emptyManifestChunks.length); manifest.rootManifest.manifestChunksIds = manifest.rootManifest.manifestChunksIds.filter( (mChunkId) => @@ -234,7 +230,7 @@ function makeBetterSecureStore< value: string; }) { await deleteEntry(params.id).catch(() => { - log(`Entry ${params.id} not found, creating new one`); + logger.info(`Entry ${params.id} not found, creating new one`); }); const valueChunks = splitIntoChunks(params.value, sizeLimit); @@ -251,7 +247,7 @@ function makeBetterSecureStore< const existingManifestChunkWithRoom = manifest.manifestChunks.find( (mChunk) => sizeLimit > mChunk.manifestChunkSize + newManifestEntrySize, ); - log('DEBUG existingManifestChunkWithRoom', existingManifestChunkWithRoom); + logger.debug('existingManifestChunkWithRoom', existingManifestChunkWithRoom); const manifestChunkWithRoom = existingManifestChunkWithRoom ?? (await (async () => { @@ -263,7 +259,7 @@ function makeBetterSecureStore< manifestChunkId: Crypto.randomUUID(), manifestChunkSize: 0, } satisfies NonNullable<(typeof manifest.manifestChunks)[number]>; - log(`Adding new manifest chunk ${newManifestChunk.manifestChunkId}`); + logger.info(`Adding new manifest chunk ${newManifestChunk.manifestChunkId}`); manifest.rootManifest.manifestChunksIds.push( newManifestChunk.manifestChunkId, ); @@ -271,7 +267,7 @@ function makeBetterSecureStore< rootManifestKey, JSON.stringify(manifest.rootManifest), ); - log('DEBUG: newRootManifest', manifest.rootManifest); + logger.debug('newRootManifest', manifest.rootManifest); return newManifestChunk; })()); @@ -284,15 +280,15 @@ function makeBetterSecureStore< manifestChunkKeyString, JSON.stringify(manifestChunkWithRoom.manifestChunk), ).then(() => { - log( + logger.info( `Set manifest chunk for ${manifestChunkKeyString} to ${JSON.stringify(manifestChunkWithRoom.manifestChunk).length} bytes`, ); }), ...valueChunks.map(async (vChunk, chunkIdx) => { const entryKeyString = entryKey(newManifestEntry.id, chunkIdx); - console.log('DEBUG: setting entry chunk', entryKeyString); + logger.debug('setting entry chunk', entryKeyString); await SecureStore.setItemAsync(entryKeyString, vChunk); - log( + logger.info( `Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`, ); }), @@ -332,15 +328,15 @@ async function upsertPrivateKey(params: { }) { const validateKeyResult = RnRussh.validatePrivateKey(params.value); if (!validateKeyResult.valid) { - console.log('Invalid private key', validateKeyResult.error); + logger.info('Invalid private key', validateKeyResult.error); if (validateKeyResult.error.tag === SshError_Tags.RusshKeys) { - console.log('Invalid private key inner', validateKeyResult.error.inner); - console.log('Invalid private key content', params.value); + logger.info('Invalid private key inner', validateKeyResult.error.inner); + logger.info('Invalid private key content', params.value); } throw new Error('Invalid private key', { cause: validateKeyResult.error }); } const keyId = params.keyId ?? `key_${Crypto.randomUUID()}`; - log(`${params.keyId ? 'Upserting' : 'Creating'} private key ${keyId}`); + logger.info(`${params.keyId ? 'Upserting' : 'Creating'} private key ${keyId}`); // Preserve createdAtMs if the entry already exists const existing = await betterKeyStorage .getEntry(keyId) @@ -356,7 +352,7 @@ async function upsertPrivateKey(params: { }, value: params.value, }); - log('DEBUG: invalidating key query'); + logger.debug('invalidating key query'); await queryClient.invalidateQueries({ queryKey: [keyQueryKey] }); } @@ -371,7 +367,7 @@ const listKeysQueryOptions = queryOptions({ queryKey: [keyQueryKey], queryFn: async () => { const results = await betterKeyStorage.listEntriesWithValues(); - log(`Listed ${results.length} private keys`); + logger.info(`Listed ${results.length} private keys`); return results; }, }); @@ -431,7 +427,7 @@ async function upsertConnection(params: { }, value: JSON.stringify(params.details), }); - log('DEBUG: invalidating connection query'); + logger.debug('invalidating connection query'); await queryClient.invalidateQueries({ queryKey: [connectionQueryKey] }); return params.details; } diff --git a/apps/mobile/src/lib/ssh-store.ts b/apps/mobile/src/lib/ssh-store.ts index 41abcb1..af1257a 100644 --- a/apps/mobile/src/lib/ssh-store.ts +++ b/apps/mobile/src/lib/ssh-store.ts @@ -4,6 +4,9 @@ import { type SshShell, } from '@fressh/react-native-uniffi-russh'; import { create } from 'zustand'; +import { rootLogger } from './logger'; + +const logger = rootLogger.extend('SshStore'); type SshRegistryStore = { connections: Record; @@ -19,7 +22,7 @@ export const useSshStore = create((set) => ({ ...args, onDisconnected: (connectionId) => { args.onDisconnected?.(connectionId); - console.log('DEBUG connection disconnected', connectionId); + logger.debug('connection disconnected', connectionId); set((s) => { const { [connectionId]: _omit, ...rest } = s.connections; return { connections: rest }; @@ -33,7 +36,7 @@ export const useSshStore = create((set) => ({ onClosed: (channelId) => { args.onClosed?.(channelId); const storeKey = `${connection.connectionId}-${channelId}` as const; - console.log('DEBUG shell closed', storeKey); + logger.debug('shell closed', storeKey); set((s) => { const { [storeKey]: _omit, ...rest } = s.shells; if (Object.keys(rest).length === 0) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 115ca1a..bd26e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: react-native-keyboard-controller: specifier: 1.18.5 version: 1.18.5(react-native-reanimated@4.1.2(@babel/core@7.28.3)(react-native-worklets@0.5.1(@babel/core@7.28.3)(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-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-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-native-logs: + specifier: ^5.5.0 + version: 5.5.0 react-native-mmkv: specifier: ^3.3.1 version: 3.3.1(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) @@ -7697,6 +7700,9 @@ packages: react-native: '*' react-native-reanimated: '>=3.0.0' + react-native-logs@5.5.0: + resolution: {integrity: sha512-H3Jc1pNTzNhYb9yHuk1drHdyGHwRvt4IERSz3EUul8vVTey6999fzGRFLK6ugrxYnmw7P+5fo/mRzDXeByhA8g==} + react-native-mmkv@3.3.1: resolution: {integrity: sha512-LYamDWQirPTUJZ9Re+BkCD+zLRGNr+EVJDeIeblvoJXGatWy9PXnChtajDSLqwjX3EXVeUyjgrembs7wlBw9ug==} peerDependencies: @@ -18495,6 +18501,8 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(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-native-reanimated: 4.1.2(@babel/core@7.28.3)(react-native-worklets@0.5.1(@babel/core@7.28.3)(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-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-native-logs@5.5.0: {} + react-native-mmkv@3.3.1(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: react: 19.1.0