This commit is contained in:
EthanShoeDev
2025-10-04 16:28:36 -04:00
parent adbd87d102
commit d3f492facb
10 changed files with 331 additions and 188 deletions

View File

@@ -61,6 +61,7 @@
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5", "react-native-keyboard-controller": "1.18.5",
"react-native-logs": "^5.5.0",
"react-native-mmkv": "^3.3.1", "react-native-mmkv": "^3.3.1",
"react-native-reanimated": "~4.1.2", "react-native-reanimated": "~4.1.2",
"react-native-safe-area-context": "~5.6.1", "react-native-safe-area-context": "~5.6.1",

View File

@@ -14,6 +14,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppForm, useFieldContext } from '@/components/form-components'; import { useAppForm, useFieldContext } from '@/components/form-components';
import { KeyList } from '@/components/key-manager/KeyList'; import { KeyList } from '@/components/key-manager/KeyList';
import { rootLogger } from '@/lib/logger';
import { useSshConnMutation } from '@/lib/query-fns'; import { useSshConnMutation } from '@/lib/query-fns';
import { import {
connectionDetailsSchema, connectionDetailsSchema,
@@ -23,6 +24,8 @@ import {
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
const logger = rootLogger.extend('TabsIndex');
export default function TabsIndex() { export default function TabsIndex() {
return <Host />; return <Host />;
} }
@@ -65,7 +68,7 @@ function Host() {
const formErrors = useStore(connectionForm.store, (state) => state.errorMap); const formErrors = useStore(connectionForm.store, (state) => state.errorMap);
useEffect(() => { useEffect(() => {
if (!formErrors || Object.keys(formErrors).length === 0) return; 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]); }, [formErrors]);
const isSubmitting = useStore( const isSubmitting = useStore(
@@ -207,7 +210,7 @@ function Host() {
submittingTitle={buttonLabel} submittingTitle={buttonLabel}
testID="connect" testID="connect"
onPress={() => { onPress={() => {
console.log('Connect button pressed', { isSubmitting }); logger.info('Connect button pressed', { isSubmitting });
if (isSubmitting) return; if (isSubmitting) return;
void connectionForm.handleSubmit(); void connectionForm.handleSubmit();
}} }}

View File

@@ -14,11 +14,14 @@ import {
import React, { import React, {
createContext, createContext,
startTransition, startTransition,
useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'; import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native';
import { rootLogger } from '@/lib/logger';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
@@ -26,6 +29,8 @@ import { useContextSafe } from '@/lib/utils';
type IconName = keyof typeof Ionicons.glyphMap; type IconName = keyof typeof Ionicons.glyphMap;
const logger = rootLogger.extend('TabsShellDetail');
export default function TabsShellDetail() { export default function TabsShellDetail() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -71,7 +76,6 @@ const encoder = new TextEncoder();
function ShellDetail() { function ShellDetail() {
const xtermRef = useRef<XtermWebViewHandle>(null); const xtermRef = useRef<XtermWebViewHandle>(null);
const terminalReadyRef = useRef(false);
const listenerIdRef = useRef<bigint | null>(null); const listenerIdRef = useRef<bigint | null>(null);
const searchParams = useLocalSearchParams<{ const searchParams = useLocalSearchParams<{
@@ -95,7 +99,7 @@ function ShellDetail() {
useEffect(() => { useEffect(() => {
if (shell && connection) return; 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(); router.back();
}, [connection, router, shell]); }, [connection, router, shell]);
@@ -111,6 +115,37 @@ function ShellDetail() {
const marginBottom = useBottomTabSpacing(); const marginBottom = useBottomTabSpacing();
const [modifierKeysActive, setModifierKeysActive] = useState<
KeyboardToolbarModifierButtonProps[]
>([]);
const sendBytes = useCallback(
(bytes: Uint8Array<ArrayBuffer>) => {
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 ( return (
<> <>
<View <View
@@ -133,11 +168,12 @@ function ShellDetail() {
accessibilityLabel="Disconnect" accessibilityLabel="Disconnect"
hitSlop={10} hitSlop={10}
onPress={async () => { onPress={async () => {
logger.info('Disconnect button pressed');
if (!connection) return; if (!connection) return;
try { try {
await connection.disconnect(); await connection.disconnect();
} catch (e) { } catch (e) {
console.warn('Failed to disconnect', e); logger.warn('Failed to disconnect', e);
} }
}} }}
> >
@@ -151,93 +187,80 @@ function ShellDetail() {
keyboardVerticalOffset={120} keyboardVerticalOffset={120}
style={{ flex: 1, gap: 4 }} style={{ flex: 1, gap: 4 }}
> >
<View <KeyboardToolBarContext value={toolbarContext}>
style={{ <View
flex: 1, style={{
borderWidth: 2, flex: 1,
borderColor: theme.colors.border, borderWidth: 2,
}} borderColor: theme.colors.border,
>
<XtermJsWebView
ref={xtermRef}
style={{ flex: 1 }}
webViewOptions={{
// Prevent iOS from adding automatic top inset inside WebView
contentInsetAdjustmentBehavior: 'never',
}} }}
logger={{ >
log: console.log, <XtermJsWebView
// debug: console.log, ref={xtermRef}
warn: console.warn, style={{ flex: 1 }}
error: console.error, webViewOptions={{
}} // Prevent iOS from adding automatic top inset inside WebView
// xterm options contentInsetAdjustmentBehavior: 'never',
xtermOptions={{ }}
theme: { logger={{
background: theme.colors.background, log: logger.info,
foreground: theme.colors.textPrimary, // debug: logger.debug,
}, warn: logger.warn,
}} error: logger.error,
onInitialized={() => { }}
if (terminalReadyRef.current) return; xtermOptions={{
terminalReadyRef.current = true; theme: {
background: theme.colors.background,
foreground: theme.colors.textPrimary,
},
}}
onInitialized={() => {
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 () => {
// Replay from head, then attach live listener const res = shell.readBuffer({ mode: 'head' });
void (async () => { logger.info('readBuffer(head)', {
const res = shell.readBuffer({ mode: 'head' }); chunks: res.chunks.length,
console.log('readBuffer(head)', { nextSeq: res.nextSeq,
chunks: res.chunks.length, dropped: res.dropped,
nextSeq: res.nextSeq, });
dropped: res.dropped, if (res.chunks.length) {
}); const chunks = res.chunks.map((c) => c.bytes);
if (res.chunks.length) { const xr = xtermRef.current;
const chunks = res.chunks.map((c) => c.bytes); if (xr) {
const xr = xtermRef.current; xr.writeMany(chunks.map((c) => new Uint8Array(c)));
if (xr) { xr.flush();
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;
} }
const chunk = ev; }
const xr3 = xtermRef.current; const id = shell.addListener(
if (xr3) xr3.write(new Uint8Array(chunk.bytes)); (ev: ListenerEvent) => {
}, if ('kind' in ev) {
{ cursor: { mode: 'seq', seq: res.nextSeq } }, logger.warn('listener.dropped', ev);
); return;
console.log('shell listener attached', id.toString()); }
listenerIdRef.current = id; const chunk = ev;
})(); const xr3 = xtermRef.current;
// Focus to pop the keyboard (iOS needs the prop we set) if (xr3) xr3.write(new Uint8Array(chunk.bytes));
const xr2 = xtermRef.current; },
if (xr2) xr2.focus(); { cursor: { mode: 'seq', seq: res.nextSeq } },
}} );
onData={(terminalMessage) => { logger.info('shell listener attached', id.toString());
if (!shell) return; listenerIdRef.current = id;
const bytes = encoder.encode(terminalMessage); })();
shell.sendData(bytes.buffer).catch((e: unknown) => { // Focus to pop the keyboard (iOS needs the prop we set)
console.warn('sendData failed', e); const xr2 = xtermRef.current;
router.back(); if (xr2) xr2.focus();
}); }}
}} onData={(terminalMessage) => {
/> if (!shell) return;
</View> const bytes = encoder.encode(terminalMessage);
<KeyboardToolbar sendBytes(bytes);
sendBytes={(bytes) => { }}
if (!shell) return; />
shell.sendData(bytes.buffer).catch((e: unknown) => { </View>
console.warn('sendData failed', e); <KeyboardToolbar />
router.back(); </KeyboardToolBarContext>
});
}}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View> </View>
{/* <KeyboardToolbar {/* <KeyboardToolbar
@@ -249,39 +272,43 @@ function ShellDetail() {
); );
} }
type KeyboardToolbarProps = { type KeyboardToolbarContextType = {
modifierKeysActive: KeyboardToolbarModifierButtonProps[];
setModifierKeysActive: React.Dispatch<
React.SetStateAction<KeyboardToolbarModifierButtonProps[]>
>;
sendBytes: (bytes: Uint8Array<ArrayBuffer>) => void; sendBytes: (bytes: Uint8Array<ArrayBuffer>) => void;
}; };
const KeyboardToolBarContext = createContext<KeyboardToolbarProps | null>(null); const KeyboardToolBarContext = createContext<KeyboardToolbarContextType | null>(
null,
);
function KeyboardToolbar(props: KeyboardToolbarProps) { function KeyboardToolbar() {
return ( return (
<KeyboardToolBarContext value={props}> <View
<View style={{
style={{ height: 100,
height: 100, }}
}} >
> <KeyboardToolbarRow>
<KeyboardToolbarRow> <KeyboardToolbarButtonPreset preset="esc" />
<KeyboardToolbarButtonPreset preset="esc" /> <KeyboardToolbarButtonPreset preset="/" />
<KeyboardToolbarButtonPreset preset="/" /> <KeyboardToolbarButtonPreset preset="|" />
<KeyboardToolbarButtonPreset preset="|" /> <KeyboardToolbarButtonPreset preset="home" />
<KeyboardToolbarButtonPreset preset="home" /> <KeyboardToolbarButtonPreset preset="up" />
<KeyboardToolbarButtonPreset preset="up" /> <KeyboardToolbarButtonPreset preset="end" />
<KeyboardToolbarButtonPreset preset="end" /> <KeyboardToolbarButtonPreset preset="pgup" />
<KeyboardToolbarButtonPreset preset="pgup" /> </KeyboardToolbarRow>
</KeyboardToolbarRow> <KeyboardToolbarRow>
<KeyboardToolbarRow> <KeyboardToolbarButtonPreset preset="tab" />
<KeyboardToolbarButtonPreset preset="tab" /> <KeyboardToolbarButtonPreset preset="ctrl" />
<KeyboardToolbarButtonPreset preset="ctrl" /> <KeyboardToolbarButtonPreset preset="alt" />
<KeyboardToolbarButtonPreset preset="alt" /> <KeyboardToolbarButtonPreset preset="left" />
<KeyboardToolbarButtonPreset preset="left" /> <KeyboardToolbarButtonPreset preset="down" />
<KeyboardToolbarButtonPreset preset="down" /> <KeyboardToolbarButtonPreset preset="right" />
<KeyboardToolbarButtonPreset preset="right" /> <KeyboardToolbarButtonPreset preset="pgdn" />
<KeyboardToolbarButtonPreset preset="pgdn" /> </KeyboardToolbarRow>
</KeyboardToolbarRow> </View>
</View>
</KeyboardToolBarContext>
); );
} }
@@ -321,6 +348,61 @@ function KeyboardToolbarButtonPreset({
); );
} }
type ModifierContract = {
canApplyModifierToBytes: (bytes: Uint8Array<ArrayBuffer>) => boolean;
applyModifierToBytes: (
bytes: Uint8Array<ArrayBuffer>,
) => Uint8Array<ArrayBuffer>;
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< const keyboardToolbarButtonPresetToProps: Record<
KeyboardToolbarButtonPresetType, KeyboardToolbarButtonPresetType,
KeyboardToolbarButtonProps KeyboardToolbarButtonProps
@@ -332,10 +414,7 @@ const keyboardToolbarButtonPresetToProps: Record<
end: { label: 'END', sendBytes: new Uint8Array([27, 91, 70]) }, end: { label: 'END', sendBytes: new Uint8Array([27, 91, 70]) },
pgup: { label: 'PGUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pgup: { label: 'PGUP', sendBytes: new Uint8Array([27, 91, 53, 126]) },
pgdn: { label: 'PGDN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, pgdn: { label: 'PGDN', sendBytes: new Uint8Array([27, 91, 54, 126]) },
fn: { label: 'FN', isModifier: true },
tab: { label: 'TAB', sendBytes: new Uint8Array([9]) }, 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]) }, left: { iconName: 'arrow-back', sendBytes: new Uint8Array([27, 91, 68]) },
up: { iconName: 'arrow-up', sendBytes: new Uint8Array([27, 91, 65]) }, up: { iconName: 'arrow-up', sendBytes: new Uint8Array([27, 91, 65]) },
down: { iconName: 'arrow-down', sendBytes: new Uint8Array([27, 91, 66]) }, 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]) }, delete: { label: 'DELETE', sendBytes: new Uint8Array([27, 91, 51, 126]) },
pageup: { label: 'PAGEUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pageup: { label: 'PAGEUP', sendBytes: new Uint8Array([27, 91, 53, 126]) },
pagedown: { label: 'PAGEDOWN', sendBytes: new Uint8Array([27, 91, 54, 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; iconName: IconName;
} };
) &
( type KeyboardToolbarModifierButtonProps = {
| { type: 'modifier';
label: string; } & ModifierContract &
} KeyboardToolbarButtonViewProps;
| { type KeyboardToolbarInstantButtonProps = {
iconName: IconName; type?: 'sendBytes';
} sendBytes: Uint8Array<ArrayBuffer>;
); } & KeyboardToolbarButtonViewProps;
type KeyboardToolbarButtonProps =
| KeyboardToolbarModifierButtonProps
| KeyboardToolbarInstantButtonProps;
function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
const theme = useTheme(); const theme = useTheme();
const [modifierActive, setModifierActive] = useState(false); const { sendBytes, modifierKeysActive, setModifierKeysActive } =
const { sendBytes } = useContextSafe(KeyboardToolBarContext); useContextSafe(KeyboardToolBarContext);
const isTextLabel = 'label' in props; const isTextLabel = 'label' in props;
const children = isTextLabel ? ( const children = isTextLabel ? (
@@ -382,16 +472,26 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
/> />
); );
const modifierActive =
props.type === 'modifier' && modifierKeysActive.includes(props);
return ( return (
<Pressable <Pressable
onPress={() => { onPress={() => {
console.log('button pressed'); if (props.type === 'modifier') {
if ('isModifier' in props && props.isModifier) { setModifierKeysActive((modifierKeysActive) =>
setModifierActive((active) => !active); modifierKeysActive.includes(props)
} else if ('sendBytes' in props) { ? modifierKeysActive.filter((m) => m !== props)
// todo: send key press : [...modifierKeysActive, props],
sendBytes(new Uint8Array(props.sendBytes)); );
return;
} }
if ('sendBytes' in props) {
sendBytes(new Uint8Array(props.sendBytes));
return;
}
throw new Error('Invalid button type');
}} }}
style={[ style={[
{ {

View File

@@ -17,11 +17,14 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { rootLogger } from '@/lib/logger';
import { preferences } from '@/lib/preferences'; import { preferences } from '@/lib/preferences';
import {} from '@/lib/query-fns'; import {} from '@/lib/query-fns';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
const logger = rootLogger.extend('TabsShellList');
export default function TabsShellList() { export default function TabsShellList() {
const theme = useTheme(); const theme = useTheme();
return ( return (
@@ -35,7 +38,7 @@ function ShellContent() {
const connections = useSshStore( const connections = useSshStore(
useShallow((s) => Object.values(s.connections)), useShallow((s) => Object.values(s.connections)),
); );
console.log('DEBUG list view connections', connections.length); logger.debug('list view connections', connections.length);
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>

View File

@@ -4,17 +4,18 @@ import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import React from 'react'; import React from 'react';
import { KeyboardProvider } from 'react-native-keyboard-controller'; import { KeyboardProvider } from 'react-native-keyboard-controller';
import { rootLogger } from '@/lib/logger';
import { ThemeProvider } from '../lib/theme'; import { ThemeProvider } from '../lib/theme';
import { queryClient } from '../lib/utils'; import { queryClient } from '../lib/utils';
console.log('Fressh App Init', { rootLogger.info('Fressh App Init', {
isLiquidGlassAvailable: isLiquidGlassAvailable(), isLiquidGlassAvailable: isLiquidGlassAvailable(),
}); });
void DevClient.registerDevMenuItems([ void DevClient.registerDevMenuItems([
{ {
callback: () => { callback: () => {
console.log('Hello from dev menu'); rootLogger.info('Hello from dev menu');
}, },
name: 'Hello from dev menu', name: 'Hello from dev menu',
}, },

View File

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

View File

@@ -1,10 +1,13 @@
import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh'; import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { rootLogger } from './logger';
import { secretsManager, type InputConnectionDetails } from './secrets-manager'; import { secretsManager, type InputConnectionDetails } from './secrets-manager';
import { useSshStore } from './ssh-store'; import { useSshStore } from './ssh-store';
import { AbortSignalTimeout } from './utils'; import { AbortSignalTimeout } from './utils';
const logger = rootLogger.extend('QueryFns');
export const useSshConnMutation = (opts?: { export const useSshConnMutation = (opts?: {
onConnectionProgress?: (progressEvent: SshConnectionProgress) => void; onConnectionProgress?: (progressEvent: SshConnectionProgress) => void;
}) => { }) => {
@@ -14,20 +17,20 @@ export const useSshConnMutation = (opts?: {
return useMutation({ return useMutation({
mutationFn: async (connectionDetails: InputConnectionDetails) => { mutationFn: async (connectionDetails: InputConnectionDetails) => {
try { try {
console.log('Connecting to SSH server...'); logger.info('Connecting to SSH server...');
// Resolve security into the RN bridge shape // Resolve security into the RN bridge shape
const security = const security =
connectionDetails.security.type === 'password' connectionDetails.security.type === 'password'
? { ? {
type: 'password' as const, type: 'password' as const,
password: connectionDetails.security.password, password: connectionDetails.security.password,
} }
: { : {
type: 'key' as const, type: 'key' as const,
privateKey: await secretsManager.keys.utils privateKey: await secretsManager.keys.utils
.getPrivateKey(connectionDetails.security.keyId) .getPrivateKey(connectionDetails.security.keyId)
.then((e) => e.value), .then((e) => e.value),
}; };
const sshConnection = await connect({ const sshConnection = await connect({
host: connectionDetails.host, host: connectionDetails.host,
@@ -35,11 +38,11 @@ export const useSshConnMutation = (opts?: {
username: connectionDetails.username, username: connectionDetails.username,
security, security,
onConnectionProgress: (progressEvent) => { onConnectionProgress: (progressEvent) => {
console.log('SSH connect progress event', progressEvent); logger.info('SSH connect progress event', progressEvent);
opts?.onConnectionProgress?.(progressEvent); opts?.onConnectionProgress?.(progressEvent);
}, },
onServerKey: async (serverKeyInfo) => { onServerKey: async (serverKeyInfo) => {
console.log('SSH server key', serverKeyInfo); logger.info('SSH server key', serverKeyInfo);
return true; return true;
}, },
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
@@ -55,7 +58,7 @@ export const useSshConnMutation = (opts?: {
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });
console.log( logger.info(
'Connected to SSH server', 'Connected to SSH server',
sshConnection.connectionId, sshConnection.connectionId,
shellHandle.channelId, shellHandle.channelId,
@@ -68,7 +71,7 @@ export const useSshConnMutation = (opts?: {
}, },
}); });
} catch (error) { } catch (error) {
console.error('Error connecting to SSH server', error); logger.error('Error connecting to SSH server', error);
throw error; throw error;
} }
}, },

View File

@@ -3,14 +3,10 @@ import { queryOptions } from '@tanstack/react-query';
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as z from 'zod'; import * as z from 'zod';
import { rootLogger } from './logger';
import { queryClient, type StrictOmit } from './utils'; import { queryClient, type StrictOmit } from './utils';
const shouldLog = false as boolean; const logger = rootLogger.extend('SecretsManager');
const log = (...args: Parameters<typeof console.log>) => {
if (shouldLog) {
console.log(...args);
}
};
function splitIntoChunks(data: string, chunkSize: number): string[] { function splitIntoChunks(data: string, chunkSize: number): string[] {
const chunks: string[] = []; const chunks: string[] = [];
@@ -74,17 +70,17 @@ function makeBetterSecureStore<
const rawRootManifestString = const rawRootManifestString =
await SecureStore.getItemAsync(rootManifestKey); await SecureStore.getItemAsync(rootManifestKey);
log('DEBUG rawRootManifestString', rawRootManifestString); logger.debug('rawRootManifestString', rawRootManifestString);
log( logger.info(
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`, `Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
); );
const unsafedRootManifest: unknown = rawRootManifestString const unsafedRootManifest: unknown = rawRootManifestString
? JSON.parse(rawRootManifestString) ? JSON.parse(rawRootManifestString)
: { : {
manifestVersion: rootManifestVersion, manifestVersion: rootManifestVersion,
manifestChunksIds: [], manifestChunksIds: [],
}; };
const rootManifest = rootManifestSchema.parse(unsafedRootManifest); const rootManifest = rootManifestSchema.parse(unsafedRootManifest);
const manifestChunks = await Promise.all( const manifestChunks = await Promise.all(
rootManifest.manifestChunksIds.map(async (manifestChunkId) => { rootManifest.manifestChunksIds.map(async (manifestChunkId) => {
@@ -94,7 +90,7 @@ function makeBetterSecureStore<
); );
if (!rawManifestChunkString) if (!rawManifestChunkString)
throw new Error('Manifest chunk not found'); throw new Error('Manifest chunk not found');
log( logger.info(
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`, `Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`,
); );
const unsafedManifestChunk: unknown = JSON.parse( const unsafedManifestChunk: unknown = JSON.parse(
@@ -120,7 +116,7 @@ function makeBetterSecureStore<
Array.from({ length: manifestEntry.chunkCount }, async (_, chunkIdx) => { Array.from({ length: manifestEntry.chunkCount }, async (_, chunkIdx) => {
const entryKeyString = entryKey(manifestEntry.id, chunkIdx); const entryKeyString = entryKey(manifestEntry.id, chunkIdx);
const rawEntryChunk = await SecureStore.getItemAsync(entryKeyString); const rawEntryChunk = await SecureStore.getItemAsync(entryKeyString);
log( logger.info(
`Entry chunk for ${entryKeyString} is ${rawEntryChunk?.length} bytes`, `Entry chunk for ${entryKeyString} is ${rawEntryChunk?.length} bytes`,
); );
if (!rawEntryChunk) throw new Error('Entry chunk not found'); if (!rawEntryChunk) throw new Error('Entry chunk not found');
@@ -206,7 +202,7 @@ function makeBetterSecureStore<
(mChunk) => mChunk.manifestChunk.entries.length === 0, (mChunk) => mChunk.manifestChunk.entries.length === 0,
); );
if (emptyManifestChunks.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 =
manifest.rootManifest.manifestChunksIds.filter( manifest.rootManifest.manifestChunksIds.filter(
(mChunkId) => (mChunkId) =>
@@ -234,7 +230,7 @@ function makeBetterSecureStore<
value: string; value: string;
}) { }) {
await deleteEntry(params.id).catch(() => { 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); const valueChunks = splitIntoChunks(params.value, sizeLimit);
@@ -251,7 +247,7 @@ function makeBetterSecureStore<
const existingManifestChunkWithRoom = manifest.manifestChunks.find( const existingManifestChunkWithRoom = manifest.manifestChunks.find(
(mChunk) => sizeLimit > mChunk.manifestChunkSize + newManifestEntrySize, (mChunk) => sizeLimit > mChunk.manifestChunkSize + newManifestEntrySize,
); );
log('DEBUG existingManifestChunkWithRoom', existingManifestChunkWithRoom); logger.debug('existingManifestChunkWithRoom', existingManifestChunkWithRoom);
const manifestChunkWithRoom = const manifestChunkWithRoom =
existingManifestChunkWithRoom ?? existingManifestChunkWithRoom ??
(await (async () => { (await (async () => {
@@ -263,7 +259,7 @@ function makeBetterSecureStore<
manifestChunkId: Crypto.randomUUID(), manifestChunkId: Crypto.randomUUID(),
manifestChunkSize: 0, manifestChunkSize: 0,
} satisfies NonNullable<(typeof manifest.manifestChunks)[number]>; } 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( manifest.rootManifest.manifestChunksIds.push(
newManifestChunk.manifestChunkId, newManifestChunk.manifestChunkId,
); );
@@ -271,7 +267,7 @@ function makeBetterSecureStore<
rootManifestKey, rootManifestKey,
JSON.stringify(manifest.rootManifest), JSON.stringify(manifest.rootManifest),
); );
log('DEBUG: newRootManifest', manifest.rootManifest); logger.debug('newRootManifest', manifest.rootManifest);
return newManifestChunk; return newManifestChunk;
})()); })());
@@ -284,15 +280,15 @@ function makeBetterSecureStore<
manifestChunkKeyString, manifestChunkKeyString,
JSON.stringify(manifestChunkWithRoom.manifestChunk), JSON.stringify(manifestChunkWithRoom.manifestChunk),
).then(() => { ).then(() => {
log( logger.info(
`Set manifest chunk for ${manifestChunkKeyString} to ${JSON.stringify(manifestChunkWithRoom.manifestChunk).length} bytes`, `Set manifest chunk for ${manifestChunkKeyString} to ${JSON.stringify(manifestChunkWithRoom.manifestChunk).length} bytes`,
); );
}), }),
...valueChunks.map(async (vChunk, chunkIdx) => { ...valueChunks.map(async (vChunk, chunkIdx) => {
const entryKeyString = entryKey(newManifestEntry.id, 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); await SecureStore.setItemAsync(entryKeyString, vChunk);
log( logger.info(
`Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`, `Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`,
); );
}), }),
@@ -332,15 +328,15 @@ async function upsertPrivateKey(params: {
}) { }) {
const validateKeyResult = RnRussh.validatePrivateKey(params.value); const validateKeyResult = RnRussh.validatePrivateKey(params.value);
if (!validateKeyResult.valid) { if (!validateKeyResult.valid) {
console.log('Invalid private key', validateKeyResult.error); logger.info('Invalid private key', validateKeyResult.error);
if (validateKeyResult.error.tag === SshError_Tags.RusshKeys) { if (validateKeyResult.error.tag === SshError_Tags.RusshKeys) {
console.log('Invalid private key inner', validateKeyResult.error.inner); logger.info('Invalid private key inner', validateKeyResult.error.inner);
console.log('Invalid private key content', params.value); logger.info('Invalid private key content', params.value);
} }
throw new Error('Invalid private key', { cause: validateKeyResult.error }); throw new Error('Invalid private key', { cause: validateKeyResult.error });
} }
const keyId = params.keyId ?? `key_${Crypto.randomUUID()}`; 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 // Preserve createdAtMs if the entry already exists
const existing = await betterKeyStorage const existing = await betterKeyStorage
.getEntry(keyId) .getEntry(keyId)
@@ -356,7 +352,7 @@ async function upsertPrivateKey(params: {
}, },
value: params.value, value: params.value,
}); });
log('DEBUG: invalidating key query'); logger.debug('invalidating key query');
await queryClient.invalidateQueries({ queryKey: [keyQueryKey] }); await queryClient.invalidateQueries({ queryKey: [keyQueryKey] });
} }
@@ -371,7 +367,7 @@ const listKeysQueryOptions = queryOptions({
queryKey: [keyQueryKey], queryKey: [keyQueryKey],
queryFn: async () => { queryFn: async () => {
const results = await betterKeyStorage.listEntriesWithValues(); const results = await betterKeyStorage.listEntriesWithValues();
log(`Listed ${results.length} private keys`); logger.info(`Listed ${results.length} private keys`);
return results; return results;
}, },
}); });
@@ -431,7 +427,7 @@ async function upsertConnection(params: {
}, },
value: JSON.stringify(params.details), value: JSON.stringify(params.details),
}); });
log('DEBUG: invalidating connection query'); logger.debug('invalidating connection query');
await queryClient.invalidateQueries({ queryKey: [connectionQueryKey] }); await queryClient.invalidateQueries({ queryKey: [connectionQueryKey] });
return params.details; return params.details;
} }

View File

@@ -4,6 +4,9 @@ import {
type SshShell, type SshShell,
} from '@fressh/react-native-uniffi-russh'; } from '@fressh/react-native-uniffi-russh';
import { create } from 'zustand'; import { create } from 'zustand';
import { rootLogger } from './logger';
const logger = rootLogger.extend('SshStore');
type SshRegistryStore = { type SshRegistryStore = {
connections: Record<string, SshConnection>; connections: Record<string, SshConnection>;
@@ -19,7 +22,7 @@ export const useSshStore = create<SshRegistryStore>((set) => ({
...args, ...args,
onDisconnected: (connectionId) => { onDisconnected: (connectionId) => {
args.onDisconnected?.(connectionId); args.onDisconnected?.(connectionId);
console.log('DEBUG connection disconnected', connectionId); logger.debug('connection disconnected', connectionId);
set((s) => { set((s) => {
const { [connectionId]: _omit, ...rest } = s.connections; const { [connectionId]: _omit, ...rest } = s.connections;
return { connections: rest }; return { connections: rest };
@@ -33,7 +36,7 @@ export const useSshStore = create<SshRegistryStore>((set) => ({
onClosed: (channelId) => { onClosed: (channelId) => {
args.onClosed?.(channelId); args.onClosed?.(channelId);
const storeKey = `${connection.connectionId}-${channelId}` as const; const storeKey = `${connection.connectionId}-${channelId}` as const;
console.log('DEBUG shell closed', storeKey); logger.debug('shell closed', storeKey);
set((s) => { set((s) => {
const { [storeKey]: _omit, ...rest } = s.shells; const { [storeKey]: _omit, ...rest } = s.shells;
if (Object.keys(rest).length === 0) { if (Object.keys(rest).length === 0) {

8
pnpm-lock.yaml generated
View File

@@ -151,6 +151,9 @@ importers:
react-native-keyboard-controller: react-native-keyboard-controller:
specifier: 1.18.5 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) 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: react-native-mmkv:
specifier: ^3.3.1 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) 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: '*'
react-native-reanimated: '>=3.0.0' react-native-reanimated: '>=3.0.0'
react-native-logs@5.5.0:
resolution: {integrity: sha512-H3Jc1pNTzNhYb9yHuk1drHdyGHwRvt4IERSz3EUul8vVTey6999fzGRFLK6ugrxYnmw7P+5fo/mRzDXeByhA8g==}
react-native-mmkv@3.3.1: react-native-mmkv@3.3.1:
resolution: {integrity: sha512-LYamDWQirPTUJZ9Re+BkCD+zLRGNr+EVJDeIeblvoJXGatWy9PXnChtajDSLqwjX3EXVeUyjgrembs7wlBw9ug==} resolution: {integrity: sha512-LYamDWQirPTUJZ9Re+BkCD+zLRGNr+EVJDeIeblvoJXGatWy9PXnChtajDSLqwjX3EXVeUyjgrembs7wlBw9ug==}
peerDependencies: 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-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-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): 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: dependencies:
react: 19.1.0 react: 19.1.0