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