diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 061f430..9424ebd 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,3 +1,4 @@ +import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh'; import SegmentedControl from '@react-native-segmented-control/segmented-control'; import { useStore } from '@tanstack/react-form'; import { useQuery } from '@tanstack/react-query'; @@ -21,15 +22,6 @@ import { } from '@/lib/secrets-manager'; import { useTheme } from '@/lib/theme'; import { useBottomTabPadding } from '@/lib/useBottomTabPadding'; -// Map connection status literals to human-friendly labels -const SSH_STATUS_LABELS: Record = { - tcpConnecting: 'Connecting to host…', - tcpConnected: 'Network connected', - tcpDisconnected: 'Network disconnected', - shellConnecting: 'Starting shell…', - shellConnected: 'Connected', - shellDisconnected: 'Shell disconnected', -} as const; export default function TabsIndex() { return ; @@ -47,14 +39,11 @@ const defaultValues: InputConnectionDetails = { function Host() { const theme = useTheme(); - // const insets = useSafeAreaInsets(); - const [status, setStatus] = React.useState(null); + const [lastConnectionProgressEvent, setLastConnectionProgressEvent] = + React.useState(null); + const sshConnMutation = useSshConnMutation({ - onStatusChange: (s) => { - // Hide banner immediately after shell connects - if (s === 'shellConnected') setStatus(null); - else setStatus(s); - }, + onConnectionProgress: (s) => setLastConnectionProgressEvent(s), }); const { paddingBottom, onLayout } = useBottomTabPadding(12); const connectionForm = useAppForm({ @@ -62,7 +51,10 @@ function Host() { defaultValues, validators: { onChange: connectionDetailsSchema, - onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value), + onSubmitAsync: async ({ value }) => + sshConnMutation.mutateAsync(value).then(() => { + setLastConnectionProgressEvent(null); + }), }, }); @@ -81,6 +73,16 @@ function Host() { (state) => state.isSubmitting, ); + const buttonLabel = (() => { + if (!sshConnMutation.isPending) return 'Connect'; + if (lastConnectionProgressEvent === null) return 'TCP Connecting...'; + if (lastConnectionProgressEvent === 'tcpConnected') + return 'SSH Handshake...'; + if (lastConnectionProgressEvent === 'sshHandshake') + return 'Authenticating...'; + return 'Connected!'; + })(); + return ( { console.log('Connect button pressed', { isSubmitting }); diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index dc55a0c..19f82b0 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -5,7 +5,6 @@ import { type XtermWebViewHandle, } from '@fressh/react-native-xtermjs-webview'; -import { useQueryClient } from '@tanstack/react-query'; import { Stack, useLocalSearchParams, @@ -19,8 +18,7 @@ import { SafeAreaView, useSafeAreaInsets, } from 'react-native-safe-area-context'; -import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns'; -import { useSshStore, makeSessionKey } from '@/lib/ssh-store'; +import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; export default function TabsShellDetail() { @@ -30,10 +28,11 @@ export default function TabsShellDetail() { React.useCallback(() => { startTransition(() => { setTimeout(() => { - // TODO: This is gross + // TODO: This is gross. It would be much better to switch + // after the navigation animation completes. setReady(true); }, 50); - }); // React 19: non-urgent + }); return () => { setReady(false); @@ -58,36 +57,33 @@ const encoder = new TextEncoder(); function ShellDetail() { const xtermRef = useRef(null); const terminalReadyRef = useRef(false); - // Legacy buffer no longer used; relying on Rust ring for replay const listenerIdRef = useRef(null); - const { connectionId, channelId } = useLocalSearchParams<{ + const searchParams = useLocalSearchParams<{ connectionId?: string; channelId?: string; }>(); + + if (!searchParams.connectionId || !searchParams.channelId) + throw new Error('Missing connectionId or channelId'); + + const connectionId = searchParams.connectionId; + const channelId = parseInt(searchParams.channelId); + const router = useRouter(); const theme = useTheme(); - const channelIdNum = Number(channelId); - const sess = useSshStore((s) => - connectionId && channelId - ? s.getByKey(makeSessionKey(connectionId, channelIdNum)) - : undefined, + const shell = useSshStore( + (s) => s.shells[`${connectionId}-${channelId}` as const], ); - const connection = sess?.connection; - const shell = sess?.shell; + const connection = useSshStore((s) => s.connections[connectionId]); - // If the shell disconnects, leave this screen to the list view useEffect(() => { - if (!sess) return; - if (sess.status === 'disconnected') { - console.log('shell disconnected, replacing route with /shell'); - // Replace so the detail screen isn't on the stack anymore - router.replace('/shell'); - } - }, [router, sess]); + if (shell && connection) return; + console.log('shell or connection not found, replacing route with /shell'); + router.replace('/shell'); + }, [connection, router, shell]); - // SSH -> xterm: on initialized, replay ring head then attach live listener useEffect(() => { const xterm = xtermRef.current; return () => { @@ -98,7 +94,6 @@ function ShellDetail() { }; }, [shell]); - const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const estimatedTabBarHeight = Platform.select({ ios: 49, @@ -140,10 +135,7 @@ function ShellDetail() { onPress={async () => { if (!connection) return; try { - await disconnectSshConnectionAndInvalidateQuery({ - connectionId: connection.connectionId, - queryClient, - }); + await connection.disconnect(); } catch (e) { console.warn('Failed to disconnect', e); } @@ -174,55 +166,50 @@ function ShellDetail() { if (terminalReadyRef.current) return; terminalReadyRef.current = true; - // Replay from head, then attach live listener - if (shell) { - void (async () => { - const res = await 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); - 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; - if (xr3) xr3.write(chunk.bytes); - }, - { cursor: { mode: 'seq', seq: res.nextSeq } }, - ); - console.log('shell listener attached', id.toString()); - listenerIdRef.current = id; - })(); - } + 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; + } + 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(); - return; }} onData={(terminalMessage) => { if (!shell) return; const bytes = encoder.encode(terminalMessage); - if (shell) { - shell.sendData(bytes.buffer).catch((e: unknown) => { - console.warn('sendData failed', e); - router.back(); - }); - } - return; + shell.sendData(bytes.buffer).catch((e: unknown) => { + console.warn('sendData failed', e); + router.back(); + }); }} /> diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx index 25e33e0..4cd8bad 100644 --- a/apps/mobile/src/app/(tabs)/shell/index.tsx +++ b/apps/mobile/src/app/(tabs)/shell/index.tsx @@ -4,7 +4,6 @@ import { type SshConnection, } from '@fressh/react-native-uniffi-russh'; import { FlashList } from '@shopify/flash-list'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { Link, Stack, useRouter } from 'expo-router'; import React from 'react'; @@ -18,12 +17,8 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { preferences } from '@/lib/preferences'; -import { - closeSshShellAndInvalidateQuery, - disconnectSshConnectionAndInvalidateQuery, - listSshShellsQueryOptions, - type ShellWithConnection, -} from '@/lib/query-fns'; +import {} from '@/lib/query-fns'; +import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; export default function TabsShellList() { @@ -36,7 +31,7 @@ export default function TabsShellList() { } function ShellContent() { - const connectionsQuery = useQuery(listSshShellsQueryOptions); + const connections = useSshStore((s) => Object.values(s.connections)); return ( @@ -45,63 +40,32 @@ function ShellContent() { headerRight: () => , }} /> - {!connectionsQuery.data ? ( - - ) : connectionsQuery.data.length === 0 ? ( - - ) : ( - - )} - - ); -} - -function LoadingState() { - const theme = useTheme(); - return ( - - Loading... + {connections.length === 0 ? : } ); } type ActionTarget = | { - shell: ShellWithConnection; + shell: SshShell; } | { connection: SshConnection; }; -type ConnectionsList = (SshConnection & { shells: SshShell[] })[]; - -function LoadedState({ connections }: { connections: ConnectionsList }) { +function LoadedState() { const [actionTarget, setActionTarget] = React.useState( null, ); - const queryClient = useQueryClient(); const [shellListViewMode] = preferences.shellListViewMode.useShellListViewModePref(); return ( {shellListViewMode === 'flat' ? ( - + ) : ( - + )} { if (!actionTarget) return; if (!('shell' in actionTarget)) return; - void closeSshShellAndInvalidateQuery({ - channelId: actionTarget.shell.channelId, - connectionId: actionTarget.shell.connectionId, - queryClient: queryClient, - }); + void actionTarget.shell.close(); }} onDisconnect={() => { if (!actionTarget) return; - const connectionId = - 'connection' in actionTarget - ? actionTarget.connection.connectionId - : actionTarget.shell.connectionId; - void disconnectSshConnectionAndInvalidateQuery({ - connectionId: connectionId, - queryClient: queryClient, - }); + if (!('connection' in actionTarget)) return; + void actionTarget.connection.disconnect(); }} /> @@ -134,26 +88,15 @@ function LoadedState({ connections }: { connections: ConnectionsList }) { } function FlatView({ - connectionsWithShells, setActionTarget, }: { - connectionsWithShells: ConnectionsList; setActionTarget: (target: ActionTarget) => void; }) { - const flatShells = React.useMemo(() => { - return connectionsWithShells.reduce((acc, curr) => { - acc.push( - ...curr.shells.map((shell) => ({ - ...shell, - connection: curr, - })), - ); - return acc; - }, []); - }, [connectionsWithShells]); + const shells = useSshStore((s) => Object.values(s.shells)); + return ( - - data={flatShells} + + data={shells} keyExtractor={(item) => `${item.connectionId}:${item.channelId}`} renderItem={({ item }) => ( void; }) { const theme = useTheme(); const [expanded, setExpanded] = React.useState>({}); + const connections = useSshStore((s) => Object.values(s.connections)); + const shells = useSshStore((s) => Object.values(s.shells)); return ( - - data={connectionsWithShells} + + data={connections} // estimatedItemSize={80} keyExtractor={(item) => item.connectionId} - renderItem={({ item }) => ( - - { - setExpanded((prev) => ({ - ...prev, - [item.connectionId]: !prev[item.connectionId], - })); - }} - > - - - {item.connectionDetails.username}@{item.connectionDetails.host} + renderItem={({ item }) => { + const connectionShells = shells.filter( + (s) => s.connectionId === item.connectionId, + ); + return ( + + { + setExpanded((prev) => ({ + ...prev, + [item.connectionId]: !prev[item.connectionId], + })); + }} + > + + + {item.connectionDetails.username}@ + {item.connectionDetails.host} + + + Port {item.connectionDetails.port} • {connectionShells.length}{' '} + shell + {connectionShells.length === 1 ? '' : 's'} + + + + {expanded[item.connectionId] ? '▾' : '▸'} - - Port {item.connectionDetails.port} • {item.shells.length} shell - {item.shells.length === 1 ? '' : 's'} - - - - {expanded[item.connectionId] ? '▾' : '▸'} - - - {expanded[item.connectionId] && ( - - {item.shells.map((sh) => { - const shellWithConnection = { ...sh, connection: item }; - return ( - { - setActionTarget({ - shell: shellWithConnection, - }); - }} - /> - ); - })} - - )} - - )} + + {expanded[item.connectionId] && ( + + {connectionShells.map((sh) => { + const shellWithConnection = { ...sh, connection: item }; + return ( + { + setActionTarget({ + shell: shellWithConnection, + }); + }} + /> + ); + })} + + )} + + ); + }} ItemSeparatorComponent={() => } contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }} style={{ flex: 1 }} @@ -287,7 +237,7 @@ function ShellCard({ shell, onLongPress, }: { - shell: ShellWithConnection; + shell: SshShell; onLongPress?: () => void; }) { const theme = useTheme(); @@ -295,6 +245,8 @@ function ShellCard({ const since = formatDistanceToNow(new Date(shell.createdAtMs), { addSuffix: true, }); + const connection = useSshStore((s) => s.connections[shell.connectionId]); + if (!connection) return null; return ( - {shell.connection.connectionDetails.username}@ - {shell.connection.connectionDetails.host} + {connection.connectionDetails.username}@ + {connection.connectionDetails.host} - Port {shell.connection.connectionDetails.port} • {shell.pty} + Port {connection.connectionDetails.port} • {shell.pty} Started {since} diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 1e90c20..5b576da 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -1,20 +1,15 @@ -import { RnRussh } from '@fressh/react-native-uniffi-russh'; -import { - queryOptions, - useMutation, - useQueryClient, - type QueryClient, -} from '@tanstack/react-query'; +import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh'; +import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; import { secretsManager, type InputConnectionDetails } from './secrets-manager'; -import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store'; +import { useSshStore } from './ssh-store'; import { AbortSignalTimeout } from './utils'; export const useSshConnMutation = (opts?: { - onStatusChange?: (status: string) => void; + onConnectionProgress?: (progressEvent: SshConnectionProgress) => void; }) => { const router = useRouter(); - const queryClient = useQueryClient(); + const connect = useSshStore((s) => s.connect); return useMutation({ mutationFn: async (connectionDetails: InputConnectionDetails) => { @@ -34,53 +29,38 @@ export const useSshConnMutation = (opts?: { .then((e) => e.value), }; - const sshConnection = await RnRussh.connect({ + const sshConnection = await connect({ host: connectionDetails.host, port: connectionDetails.port, username: connectionDetails.username, security, - onStatusChange: (status) => { - console.log('SSH connection status', status); - opts?.onStatusChange?.(status); + onConnectionProgress: (progressEvent) => { + console.log('SSH connect progress event', progressEvent); + opts?.onConnectionProgress?.(progressEvent); }, abortSignal: AbortSignalTimeout(5_000), }); await secretsManager.connections.utils.upsertConnection({ - id: 'default', + id: sshConnection.connectionId, details: connectionDetails, priority: 0, }); - // Capture status events to Zustand after session is known. - let keyRef: SessionKey | null = null; - const shellInterface = await sshConnection.startShell({ - pty: 'Xterm', - onStatusChange: (status) => { - if (keyRef) - useSshStore.getState().setStatus(keyRef, toSessionStatus(status)); - console.log('SSH shell status', status); - opts?.onStatusChange?.(status); - }, + const shellHandle = await sshConnection.startShell({ + term: 'Xterm', abortSignal: AbortSignalTimeout(5_000), }); - const channelId = shellInterface.channelId; - const connectionId = `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`; - console.log('Connected to SSH server', connectionId, channelId); - - // Track in Zustand store - keyRef = useSshStore - .getState() - .addSession(sshConnection, shellInterface); - - await queryClient.invalidateQueries({ - queryKey: listSshShellsQueryOptions.queryKey, - }); + console.log( + 'Connected to SSH server', + sshConnection.connectionId, + shellHandle.channelId, + ); router.push({ pathname: '/shell/detail', params: { - connectionId: connectionId, - channelId: String(channelId), + connectionId: sshConnection.connectionId, + channelId: shellHandle.channelId, }, }); } catch (error) { @@ -90,57 +70,3 @@ export const useSshConnMutation = (opts?: { }, }); }; - -export const listSshShellsQueryOptions = queryOptions({ - queryKey: ['ssh-shells'], - queryFn: () => useSshStore.getState().listConnectionsWithShells(), -}); - -export type ShellWithConnection = (ReturnType< - typeof useSshStore.getState ->['listConnectionsWithShells'] extends () => infer R - ? R - : never)[number]['shells'][number] & { - connection: (ReturnType< - typeof useSshStore.getState - >['listConnectionsWithShells'] extends () => infer R - ? R - : never)[number]; -}; - -export const closeSshShellAndInvalidateQuery = async (params: { - channelId: number; - connectionId: string; - queryClient: QueryClient; -}) => { - const currentActiveShells = useSshStore - .getState() - .listConnectionsWithShells(); - const connection = currentActiveShells.find( - (c) => c.connectionId === params.connectionId, - ); - if (!connection) throw new Error('Connection not found'); - const shell = connection.shells.find((s) => s.channelId === params.channelId); - if (!shell) throw new Error('Shell not found'); - await shell.close(); - await params.queryClient.invalidateQueries({ - queryKey: listSshShellsQueryOptions.queryKey, - }); -}; - -export const disconnectSshConnectionAndInvalidateQuery = async (params: { - connectionId: string; - queryClient: QueryClient; -}) => { - const currentActiveShells = useSshStore - .getState() - .listConnectionsWithShells(); - const connection = currentActiveShells.find( - (c) => c.connectionId === params.connectionId, - ); - if (!connection) throw new Error('Connection not found'); - await connection.disconnect(); - await params.queryClient.invalidateQueries({ - queryKey: listSshShellsQueryOptions.queryKey, - }); -}; diff --git a/apps/mobile/src/lib/ssh-store.ts b/apps/mobile/src/lib/ssh-store.ts index c4486e3..d273fce 100644 --- a/apps/mobile/src/lib/ssh-store.ts +++ b/apps/mobile/src/lib/ssh-store.ts @@ -2,124 +2,58 @@ import { RnRussh, type SshConnection, type SshShell, - type SshConnectionStatus, } from '@fressh/react-native-uniffi-russh'; import { create } from 'zustand'; -// export type SessionKey = string; -// export const makeSessionKey = (connectionId: string, channelId: number) => -// `${connectionId}:${channelId}` as const; - -// export type SessionStatus = 'connecting' | 'connected' | 'disconnected'; - -// export interface StoredSession { -// connection: SshConnection; -// shell: SshShell; -// status: SessionStatus; -// } - -// interface SshStoreState { -// sessions: Record; -// addSession: (conn: SshConnection, shell: SshShell) => SessionKey; -// removeSession: (key: SessionKey) => void; -// setStatus: (key: SessionKey, status: SessionStatus) => void; -// getByKey: (key: SessionKey) => StoredSession | undefined; -// listConnectionsWithShells: () => (SshConnection & { shells: SshShell[] })[]; -// } - -// export const useSshStore = create((set, get) => ({ -// sessions: {}, -// addSession: (conn, shell) => { -// const key = makeSessionKey(conn.connectionId, shell.channelId); -// set((s) => ({ -// sessions: { -// ...s.sessions, -// [key]: { connection: conn, shell, status: 'connected' }, -// }, -// })); -// return key; -// }, -// removeSession: (key) => { -// set((s) => { -// const { [key]: _omit, ...rest } = s.sessions; -// return { sessions: rest }; -// }); -// }, -// setStatus: (key, status) => { -// set((s) => -// s.sessions[key] -// ? { sessions: { ...s.sessions, [key]: { ...s.sessions[key], status } } } -// : s, -// ); -// }, -// getByKey: (key) => get().sessions[key], -// listConnectionsWithShells: () => { -// const byConn = new Map< -// string, -// { conn: SshConnection; shells: SshShell[] } -// >(); -// for (const { connection, shell } of Object.values(get().sessions)) { -// const g = byConn.get(connection.connectionId) ?? { -// conn: connection, -// shells: [], -// }; -// g.shells.push(shell); -// byConn.set(connection.connectionId, g); -// } -// return Array.from(byConn.values()).map(({ conn, shells }) => ({ -// ...conn, -// shells, -// })); -// }, -// })); - -// export function toSessionStatus(status: SshConnectionStatus): SessionStatus { -// switch (status) { -// case 'shellConnecting': -// return 'connecting'; -// case 'shellConnected': -// return 'connected'; -// case 'shellDisconnected': -// return 'disconnected'; -// default: -// return 'connected'; -// } -// } - - - type SshRegistryStore = { - connections: Record, - status: - }>, - shells: Record<`${string}-${number}`, SshShell>, - addConnection: typeof RnRussh.connect, -} + connections: Record; + shells: Record<`${string}-${number}`, SshShell>; + connect: typeof RnRussh.connect; +}; -type SshRegistryService = { - connect: typeof RnRussh.connect, -} - -const useSshRegistryStore = create((set) => ({ +export const useSshStore = create((set) => ({ connections: {}, shells: {}, - addConnection: async (args) => { + connect: async (args) => { const connection = await RnRussh.connect({ ...args, - onStatusChange: (status) => { - args.onStatusChange?.(status); - if (status === 'tcpDisconnected') { - // remove all shell - } - } + onDisconnected: (connectionId) => { + args.onDisconnected?.(connectionId); + set((s) => { + const { [connectionId]: _omit, ...rest } = s.connections; + return { connections: rest }; + }); + }, }); - - } -})) - - -const sshRegistryService = { - connect -} \ No newline at end of file + const originalStartShellFn = connection.startShell; + const startShell: typeof connection.startShell = async (args) => { + const shell = await originalStartShellFn({ + ...args, + onClosed: (channelId) => { + args.onClosed?.(channelId); + const storeKey = `${connection.connectionId}-${channelId}` as const; + set((s) => { + const { [storeKey]: _omit, ...rest } = s.shells; + return { shells: rest }; + }); + }, + }); + const storeKey = `${connection.connectionId}-${shell.channelId}`; + set((s) => ({ + shells: { + ...s.shells, + [storeKey]: shell, + }, + })); + return shell; + }; + connection.startShell = startShell; + set((s) => ({ + connections: { + ...s.connections, + [connection.connectionId]: connection, + }, + })); + return connection; + }, +})); diff --git a/packages/react-native-uniffi-russh/.prettierignore b/packages/react-native-uniffi-russh/.prettierignore new file mode 100644 index 0000000..f045ad4 --- /dev/null +++ b/packages/react-native-uniffi-russh/.prettierignore @@ -0,0 +1,4 @@ +.turbo +lib/ +node_modules/ +rust/ \ No newline at end of file diff --git a/packages/react-native-uniffi-russh/README.md b/packages/react-native-uniffi-russh/README.md index 2bf73ab..a1147d2 100644 --- a/packages/react-native-uniffi-russh/README.md +++ b/packages/react-native-uniffi-russh/README.md @@ -4,15 +4,12 @@ Uniffi bindings for russh ## Installation - ```sh npm install react-native-uniffi-russh ``` - ## Usage - ```js import { multiply } from 'react-native-uniffi-russh'; @@ -21,7 +18,6 @@ import { multiply } from 'react-native-uniffi-russh'; const result = multiply(3, 7); ``` - ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/packages/react-native-uniffi-russh/eslint.config.mjs b/packages/react-native-uniffi-russh/eslint.config.mjs index dd6a418..c5e3be1 100644 --- a/packages/react-native-uniffi-russh/eslint.config.mjs +++ b/packages/react-native-uniffi-russh/eslint.config.mjs @@ -21,6 +21,6 @@ export default defineConfig([ }, }, { - ignores: ['node_modules/', 'lib/', 'src/generated/'], + ignores: ['node_modules/', 'lib/', 'src/generated/', 'eslint.config.mjs'], }, ]); diff --git a/packages/react-native-uniffi-russh/package.json b/packages/react-native-uniffi-russh/package.json index 3e6d523..b57b386 100644 --- a/packages/react-native-uniffi-russh/package.json +++ b/packages/react-native-uniffi-russh/package.json @@ -39,8 +39,8 @@ "typecheck": "tsc", "build:ios": "ubrn build ios --and-generate --release", "build:android": "ubrn build android --and-generate --release", + "build:native": "tsx scripts/native-build.ts", "build:bob": "bob build", - "build": "if [ \"$(uname)\" = \"Darwin\" ]; then turbo build:ios; else turbo build:android; fi", "lint:rust": "cd rust/uniffi-russh && just lint", "test": "jest", "release": "release-it --only-version" diff --git a/packages/react-native-uniffi-russh/scripts/native-build.ts b/packages/react-native-uniffi-russh/scripts/native-build.ts new file mode 100644 index 0000000..17ec0da --- /dev/null +++ b/packages/react-native-uniffi-russh/scripts/native-build.ts @@ -0,0 +1,23 @@ +import * as child from 'child_process'; +import * as os from 'os'; + +const targetOptions = ['ios', 'android'] as const; +type Target = (typeof targetOptions)[number]; + +const envTarget = process.env.MOBILE_TARGET as Target | undefined; +if (envTarget && !targetOptions.includes(envTarget)) + throw new Error(`Invalid target: ${envTarget}`); + +const target = + envTarget ?? + (() => { + const uname = os.platform(); + if (uname === 'darwin') return 'ios'; + return 'android'; + })(); + +console.log(`Building for ${target}`); + +child.execSync(`turbo run build:${target} --ui stream`, { + stdio: 'inherit', +}); diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts index cfd65dc..89b9ff4 100644 --- a/packages/react-native-uniffi-russh/src/api.ts +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -2,47 +2,50 @@ * We cannot make the generated code match this API exactly because uniffi * - Doesn't support ts literals for rust enums * - Doesn't support passing a js object with methods and properties to or from rust. - * + * * The second issue is much harder to get around than the first. * In practice it means that if you want to pass an object with callbacks and props to rust, it need to be in seperate args. * If you want to pass an object with callbacks and props from rust to js (like ssh handles), you need to instead only pass an object with callbacks * just make one of the callbacks a sync info() callback. - * + * * Then in this api wrapper we can smooth over those rough edges. * See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html */ import * as GeneratedRussh from './index'; - // #region Ideal API // ───────────────────────────────────────────────────────────────────────────── // Core types // ───────────────────────────────────────────────────────────────────────────── -export type PtyType = - | 'Vanilla' | 'Vt100' | 'Vt102' | 'Vt220' | 'Ansi' | 'Xterm' | 'Xterm256'; +export type TerminalType = + | 'Vanilla' + | 'Vt100' + | 'Vt102' + | 'Vt220' + | 'Ansi' + | 'Xterm' + | 'Xterm256'; export type ConnectionDetails = { host: string; port: number; username: string; security: - | { type: 'password'; password: string } - | { type: 'key'; privateKey: string }; + | { type: 'password'; password: string } + | { type: 'key'; privateKey: string }; }; /** * This status is only to provide updates for discrete events * during the connect() promise. - * + * * It is no longer relevant after the connect() promise is resolved. */ export type SshConnectionProgress = - | 'tcpConnected' // TCP established, starting SSH handshake - | 'sshHandshake' // SSH protocol negotiation complete - - + | 'tcpConnected' // TCP established, starting SSH handshake + | 'sshHandshake'; // SSH protocol negotiation complete export type ConnectOptions = ConnectionDetails & { onConnectionProgress?: (status: SshConnectionProgress) => void; @@ -51,31 +54,33 @@ export type ConnectOptions = ConnectionDetails & { }; export type StartShellOptions = { - pty: PtyType; - onClosed?: (shellId: string) => void; + term: TerminalType; + terminalMode?: GeneratedRussh.TerminalMode[]; + terminalPixelSize?: GeneratedRussh.TerminalPixelSize; + terminalSize?: GeneratedRussh.TerminalSize; + onClosed?: (shellId: number) => void; abortSignal?: AbortSignal; }; export type StreamKind = 'stdout' | 'stderr'; export type TerminalChunk = { - /** Monotonic sequence number from the shell start (Rust u64; JS uses number). */ - seq: number; + seq: bigint; /** Milliseconds since UNIX epoch (double). */ tMs: number; stream: StreamKind; - bytes: Uint8Array; + bytes: ArrayBuffer; }; -export type DropNotice = { kind: 'dropped'; fromSeq: number; toSeq: number }; +export type DropNotice = { kind: 'dropped'; fromSeq: bigint; toSeq: bigint }; export type ListenerEvent = TerminalChunk | DropNotice; export type Cursor = - | { mode: 'head' } // earliest available in ring - | { mode: 'tailBytes'; bytes: number } // last N bytes (best-effort) - | { mode: 'seq'; seq: number } // from a given sequence - | { mode: 'time'; tMs: number } // from timestamp - | { mode: 'live' }; // no replay, live only + | { mode: 'head' } // earliest available in ring + | { mode: 'tailBytes'; bytes: bigint } // last N bytes (best-effort) + | { mode: 'seq'; seq: bigint } // from a given sequence + | { mode: 'time'; tMs: number } // from timestamp + | { mode: 'live' }; // no replay, live only export type ListenerOptions = { cursor: Cursor; @@ -83,30 +88,27 @@ export type ListenerOptions = { coalesceMs?: number; }; -export type BufferStats = { - ringBytes: number; // configured capacity - usedBytes: number; // current usage - chunks: number; // chunks kept - headSeq: number; // oldest seq retained - tailSeq: number; // newest seq retained - droppedBytesTotal: number; // cumulative eviction -}; - export type BufferReadResult = { chunks: TerminalChunk[]; - nextSeq: number; - dropped?: { fromSeq: number; toSeq: number }; + nextSeq: bigint; + dropped?: { fromSeq: bigint; toSeq: bigint }; }; // ───────────────────────────────────────────────────────────────────────────── // Handles // ───────────────────────────────────────────────────────────────────────────── +type ProgressTimings = { + tcpEstablishedAtMs: number; + sshHandshakeAtMs: number; +}; + export type SshConnection = { readonly connectionId: string; readonly createdAtMs: number; - readonly tcpEstablishedAtMs: number; + readonly connectedAtMs: number; readonly connectionDetails: ConnectionDetails; + readonly progressTimings: ProgressTimings; startShell: (opts: StartShellOptions) => Promise; disconnect: (opts?: { signal?: AbortSignal }) => Promise; @@ -115,20 +117,26 @@ export type SshConnection = { export type SshShell = { readonly channelId: number; readonly createdAtMs: number; - readonly pty: PtyType; + readonly pty: TerminalType; readonly connectionId: string; // I/O - sendData: (data: ArrayBuffer, opts?: { signal?: AbortSignal }) => Promise; + sendData: ( + data: ArrayBuffer, + opts?: { signal?: AbortSignal } + ) => Promise; close: (opts?: { signal?: AbortSignal }) => Promise; // Buffer policy & stats - setBufferPolicy: (policy: { ringBytes?: number; coalesceMs?: number }) => Promise; - bufferStats: () => Promise; - currentSeq: () => Promise; + // setBufferPolicy: (policy: { + // ringBytes?: number; + // coalesceMs?: number; + // }) => Promise; + bufferStats: () => GeneratedRussh.BufferStats; + currentSeq: () => number; // Replay + live - readBuffer: (cursor: Cursor, maxBytes?: number) => Promise; + readBuffer: (cursor: Cursor, maxBytes?: bigint) => BufferReadResult; addListener: ( cb: (ev: ListenerEvent) => void, opts: ListenerOptions @@ -146,41 +154,55 @@ type RusshApi = { // #region Wrapper to match the ideal API -const ptyTypeLiteralToEnum = { - Vanilla: GeneratedRussh.PtyType.Vanilla, - Vt100: GeneratedRussh.PtyType.Vt100, - Vt102: GeneratedRussh.PtyType.Vt102, - Vt220: GeneratedRussh.PtyType.Vt220, - Ansi: GeneratedRussh.PtyType.Ansi, - Xterm: GeneratedRussh.PtyType.Xterm, - Xterm256: GeneratedRussh.PtyType.Xterm256, -} as const satisfies Record; +const terminalTypeLiteralToEnum = { + Vanilla: GeneratedRussh.TerminalType.Vanilla, + Vt100: GeneratedRussh.TerminalType.Vt100, + Vt102: GeneratedRussh.TerminalType.Vt102, + Vt220: GeneratedRussh.TerminalType.Vt220, + Ansi: GeneratedRussh.TerminalType.Ansi, + Xterm: GeneratedRussh.TerminalType.Xterm, + Xterm256: GeneratedRussh.TerminalType.Xterm256, +} as const satisfies Record; -const ptyEnumToLiteral: Record = { - [GeneratedRussh.PtyType.Vanilla]: 'Vanilla', - [GeneratedRussh.PtyType.Vt100]: 'Vt100', - [GeneratedRussh.PtyType.Vt102]: 'Vt102', - [GeneratedRussh.PtyType.Vt220]: 'Vt220', - [GeneratedRussh.PtyType.Ansi]: 'Ansi', - [GeneratedRussh.PtyType.Xterm]: 'Xterm', - [GeneratedRussh.PtyType.Xterm256]: 'Xterm256', +const terminalTypeEnumToLiteral: Record< + GeneratedRussh.TerminalType, + TerminalType +> = { + [GeneratedRussh.TerminalType.Vanilla]: 'Vanilla', + [GeneratedRussh.TerminalType.Vt100]: 'Vt100', + [GeneratedRussh.TerminalType.Vt102]: 'Vt102', + [GeneratedRussh.TerminalType.Vt220]: 'Vt220', + [GeneratedRussh.TerminalType.Ansi]: 'Ansi', + [GeneratedRussh.TerminalType.Xterm]: 'Xterm', + [GeneratedRussh.TerminalType.Xterm256]: 'Xterm256', }; -const sshConnStatusEnumToLiteral = { - [GeneratedRussh.SshConnectionStatus.TcpConnected]: 'tcpConnected', - [GeneratedRussh.SshConnectionStatus.SshHandshake]: 'sshHandshake', -} as const satisfies Record; +const sshConnProgressEnumToLiteral = { + [GeneratedRussh.SshConnectionProgressEvent.TcpConnected]: 'tcpConnected', + [GeneratedRussh.SshConnectionProgressEvent.SshHandshake]: 'sshHandshake', +} as const satisfies Record< + GeneratedRussh.SshConnectionProgressEvent, + SshConnectionProgress +>; const streamEnumToLiteral = { [GeneratedRussh.StreamKind.Stdout]: 'stdout', [GeneratedRussh.StreamKind.Stderr]: 'stderr', } as const satisfies Record; -function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails { - const security: ConnectionDetails['security'] = details.security instanceof GeneratedRussh.Security.Password - ? { type: 'password', password: details.security.inner.password } - : { type: 'key', privateKey: details.security.inner.keyId }; - return { host: details.host, port: details.port, username: details.username, security }; +function generatedConnDetailsToIdeal( + details: GeneratedRussh.ConnectionDetails +): ConnectionDetails { + const security: ConnectionDetails['security'] = + details.security instanceof GeneratedRussh.Security.Password + ? { type: 'password', password: details.security.inner.password } + : { type: 'key', privateKey: details.security.inner.privateKeyContent }; + return { + host: details.host, + port: details.port, + username: details.username, + security, + }; } function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor { @@ -188,9 +210,11 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor { case 'head': return new GeneratedRussh.Cursor.Head(); case 'tailBytes': - return new GeneratedRussh.Cursor.TailBytes({ bytes: BigInt(cursor.bytes) }); + return new GeneratedRussh.Cursor.TailBytes({ + bytes: cursor.bytes, + }); case 'seq': - return new GeneratedRussh.Cursor.Seq({ seq: BigInt(cursor.seq) }); + return new GeneratedRussh.Cursor.Seq({ seq: cursor.seq }); case 'time': return new GeneratedRussh.Cursor.TimeMs({ tMs: cursor.tMs }); case 'live': @@ -200,38 +224,24 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor { function toTerminalChunk(ch: GeneratedRussh.TerminalChunk): TerminalChunk { return { - seq: Number(ch.seq), + seq: ch.seq, tMs: ch.tMs, stream: streamEnumToLiteral[ch.stream], - bytes: new Uint8Array(ch.bytes as any), + bytes: ch.bytes, }; } -function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell { - const info = shell.info(); +function wrapShellSession( + shell: GeneratedRussh.ShellSessionInterface +): SshShell { + const info = shell.getInfo(); - const setBufferPolicy: SshShell['setBufferPolicy'] = async (policy) => { - await shell.setBufferPolicy(policy.ringBytes != null ? BigInt(policy.ringBytes) : undefined, policy.coalesceMs); - }; - - const bufferStats: SshShell['bufferStats'] = async () => { - const s = shell.bufferStats(); - return { - ringBytes: Number(s.ringBytes), - usedBytes: Number(s.usedBytes), - chunks: Number(s.chunks), - headSeq: Number(s.headSeq), - tailSeq: Number(s.tailSeq), - droppedBytesTotal: Number(s.droppedBytesTotal), - }; - }; - - const readBuffer: SshShell['readBuffer'] = async (cursor, maxBytes) => { - const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes != null ? BigInt(maxBytes) : undefined); + const readBuffer: SshShell['readBuffer'] = (cursor, maxBytes) => { + const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes); return { chunks: res.chunks.map(toTerminalChunk), - nextSeq: Number(res.nextSeq), - dropped: res.dropped ? { fromSeq: Number(res.dropped.fromSeq), toSeq: Number(res.dropped.toSeq) } : undefined, + nextSeq: res.nextSeq, + dropped: res.dropped, } satisfies BufferReadResult; }; @@ -241,87 +251,128 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell if (ev instanceof GeneratedRussh.ShellEvent.Chunk) { cb(toTerminalChunk(ev.inner[0]!)); } else if (ev instanceof GeneratedRussh.ShellEvent.Dropped) { - cb({ kind: 'dropped', fromSeq: Number(ev.inner.fromSeq), toSeq: Number(ev.inner.toSeq) }); + cb({ + kind: 'dropped', + fromSeq: ev.inner.fromSeq, + toSeq: ev.inner.toSeq, + }); } - } + }, } satisfies GeneratedRussh.ShellListener; try { - const id = shell.addListener(listener, { cursor: cursorToGenerated(opts.cursor), coalesceMs: opts.coalesceMs }); + const id = shell.addListener(listener, { + cursor: cursorToGenerated(opts.cursor), + coalesceMs: opts.coalesceMs, + }); if (id === 0n) { throw new Error('Failed to attach shell listener (id=0)'); } - return BigInt(id); + return id; } catch (e) { - throw new Error(`addListener failed: ${String((e as any)?.message ?? e)}`); + throw new Error( + `addListener failed: ${String((e as any)?.message ?? e)}` + ); } }; return { channelId: info.channelId, createdAtMs: info.createdAtMs, - pty: ptyEnumToLiteral[info.pty], + pty: terminalTypeEnumToLiteral[info.term], connectionId: info.connectionId, - sendData: (data, o) => shell.sendData(data, o?.signal ? { signal: o.signal } : undefined), + sendData: (data, o) => + shell.sendData(data, o?.signal ? { signal: o.signal } : undefined), close: (o) => shell.close(o?.signal ? { signal: o.signal } : undefined), - setBufferPolicy, - bufferStats, - currentSeq: async () => Number(shell.currentSeq()), + // setBufferPolicy, + bufferStats: shell.bufferStats, + currentSeq: () => Number(shell.currentSeq()), readBuffer, addListener, removeListener: (id) => shell.removeListener(id), }; } -function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection { - const inf = conn.info(); +function wrapConnection( + conn: GeneratedRussh.SshConnectionInterface +): SshConnection { + const info = conn.getInfo(); return { - connectionId: inf.connectionId, - connectionDetails: generatedConnDetailsToIdeal(inf.connectionDetails), - createdAtMs: inf.createdAtMs, - tcpEstablishedAtMs: inf.tcpEstablishedAtMs, + connectionId: info.connectionId, + connectionDetails: generatedConnDetailsToIdeal(info.connectionDetails), + createdAtMs: info.createdAtMs, + connectedAtMs: info.connectedAtMs, + progressTimings: { + tcpEstablishedAtMs: info.progressTimings.tcpEstablishedAtMs, + sshHandshakeAtMs: info.progressTimings.sshHandshakeAtMs, + }, startShell: async (params) => { const shell = await conn.startShell( { - pty: ptyTypeLiteralToEnum[params.pty], - onStatusChange: params.onStatusChange - ? { onChange: (statusEnum) => params.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) } + term: terminalTypeLiteralToEnum[params.term], + onClosedCallback: params.onClosed + ? { + onChange: (channelId) => params.onClosed!(channelId), + } : undefined, + terminalMode: params.terminalMode, + terminalPixelSize: params.terminalPixelSize, + terminalSize: params.terminalSize, }, - params.abortSignal ? { signal: params.abortSignal } : undefined, + params.abortSignal ? { signal: params.abortSignal } : undefined ); return wrapShellSession(shell); }, - disconnect: (opts) => conn.disconnect(opts?.signal ? { signal: opts.signal } : undefined), + disconnect: (opts) => + conn.disconnect(opts?.signal ? { signal: opts.signal } : undefined), }; } async function connect(options: ConnectOptions): Promise { const security = options.security.type === 'password' - ? new GeneratedRussh.Security.Password({ password: options.security.password }) - : new GeneratedRussh.Security.Key({ keyId: options.security.privateKey }); + ? new GeneratedRussh.Security.Password({ + password: options.security.password, + }) + : new GeneratedRussh.Security.Key({ + privateKeyContent: options.security.privateKey, + }); const sshConnection = await GeneratedRussh.connect( { - host: options.host, - port: options.port, - username: options.username, - security, - onStatusChange: options.onStatusChange ? { - onChange: (statusEnum) => options.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) - } : undefined, + connectionDetails: { + host: options.host, + port: options.port, + username: options.username, + security, + }, + onConnectionProgressCallback: options.onConnectionProgress + ? { + onChange: (statusEnum) => + options.onConnectionProgress!( + sshConnProgressEnumToLiteral[statusEnum] + ), + } + : undefined, + onDisconnectedCallback: options.onDisconnected + ? { + onChange: (connectionId) => options.onDisconnected!(connectionId), + } + : undefined, }, - options.abortSignal ? { signal: options.abortSignal } : undefined, + options.abortSignal ? { signal: options.abortSignal } : undefined ); return wrapConnection(sshConnection); } async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') { - const map = { rsa: GeneratedRussh.KeyType.Rsa, ecdsa: GeneratedRussh.KeyType.Ecdsa, ed25519: GeneratedRussh.KeyType.Ed25519 } as const; + const map = { + rsa: GeneratedRussh.KeyType.Rsa, + ecdsa: GeneratedRussh.KeyType.Ecdsa, + ed25519: GeneratedRussh.KeyType.Ed25519, + } as const; return GeneratedRussh.generateKeyPair(map[type]); } - // #endregion export const RnRussh = { diff --git a/packages/react-native-uniffi-russh/tsconfig.build.json b/packages/react-native-uniffi-russh/tsconfig.build.json index c1c061b..cdd8e8f 100644 --- a/packages/react-native-uniffi-russh/tsconfig.build.json +++ b/packages/react-native-uniffi-russh/tsconfig.build.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig", - "exclude": ["example", "lib"], + "exclude": ["example", "lib", "scripts"], "compilerOptions": { "noUnusedParameters": false, "noUnusedLocals": false diff --git a/packages/react-native-uniffi-russh/turbo.jsonc b/packages/react-native-uniffi-russh/turbo.jsonc index d8484e5..a8b0602 100644 --- a/packages/react-native-uniffi-russh/turbo.jsonc +++ b/packages/react-native-uniffi-russh/turbo.jsonc @@ -4,24 +4,47 @@ "tasks": { // Default overrides "lint": { - "dependsOn": ["fmt", "^build"], + "dependsOn": ["fmt", "^build", "build:bob"], "with": ["typecheck", "//#lint:root", "lint:rust"], }, "lint:check": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "build:bob"], "with": ["fmt:check", "typecheck", "//#lint:check:root", "lint:rust"], }, - "build:android": { - "outputs": ["dist/**"], - "dependsOn": ["^build", "build:bob"], + "build": { + "dependsOn": ["build:bob"], }, - "build:ios": { - "outputs": ["dist/**"], - "dependsOn": ["^build", "build:bob"], + "typecheck": { + "dependsOn": ["build:native"], }, // Special tasks "lint:rust": {}, - "build:bob": {}, + "build:bob": { + "dependsOn": ["build:native"], + "inputs": ["src/**"], + "outputs": ["lib/**"], + }, + "build:native": {}, + "build:android": { + "inputs": ["rust/**", "!rust/target"], + "outputs": [ + "android/**", + "cpp/**", + "src/generated/**", + "src/index.ts", + "src/NativeReactNativeUniffi*.ts", + ], + }, + "build:ios": { + "inputs": ["rust/**", "!rust/target"], + "outputs": [ + "ios/**", + "cpp/**", + "src/generated/**", + "src/index.ts", + "src/NativeReactNativeUniffi*.ts", + ], + }, }, } diff --git a/turbo.jsonc b/turbo.jsonc index fb38d4c..e86a534 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -17,12 +17,7 @@ "dependsOn": ["^build"], }, "build": { - "dependsOn": ["^build"], - }, - "build:ios": { - "dependsOn": ["^build"], - }, - "build:android": { + "outputs": ["dist/**"], "dependsOn": ["^build"], }, "test": {