passing lint

This commit is contained in:
EthanShoeDev
2025-09-19 22:31:46 -04:00
parent dd0bb7636d
commit fc681f942d
14 changed files with 477 additions and 586 deletions

View File

@@ -1,3 +1,4 @@
import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
import SegmentedControl from '@react-native-segmented-control/segmented-control'; import SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form'; import { useStore } from '@tanstack/react-form';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -21,15 +22,6 @@ import {
} from '@/lib/secrets-manager'; } from '@/lib/secrets-manager';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabPadding } from '@/lib/useBottomTabPadding'; import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
// Map connection status literals to human-friendly labels
const SSH_STATUS_LABELS: Record<string, string> = {
tcpConnecting: 'Connecting to host…',
tcpConnected: 'Network connected',
tcpDisconnected: 'Network disconnected',
shellConnecting: 'Starting shell…',
shellConnected: 'Connected',
shellDisconnected: 'Shell disconnected',
} as const;
export default function TabsIndex() { export default function TabsIndex() {
return <Host />; return <Host />;
@@ -47,14 +39,11 @@ const defaultValues: InputConnectionDetails = {
function Host() { function Host() {
const theme = useTheme(); const theme = useTheme();
// const insets = useSafeAreaInsets(); const [lastConnectionProgressEvent, setLastConnectionProgressEvent] =
const [status, setStatus] = React.useState<string | null>(null); React.useState<SshConnectionProgress | null>(null);
const sshConnMutation = useSshConnMutation({ const sshConnMutation = useSshConnMutation({
onStatusChange: (s) => { onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
// Hide banner immediately after shell connects
if (s === 'shellConnected') setStatus(null);
else setStatus(s);
},
}); });
const { paddingBottom, onLayout } = useBottomTabPadding(12); const { paddingBottom, onLayout } = useBottomTabPadding(12);
const connectionForm = useAppForm({ const connectionForm = useAppForm({
@@ -62,7 +51,10 @@ function Host() {
defaultValues, defaultValues,
validators: { validators: {
onChange: connectionDetailsSchema, 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, (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 ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<ScrollView <ScrollView
@@ -203,9 +205,7 @@ function Host() {
<View style={{ marginTop: 20 }}> <View style={{ marginTop: 20 }}>
<connectionForm.SubmitButton <connectionForm.SubmitButton
title="Connect" title="Connect"
submittingTitle={ submittingTitle={buttonLabel}
SSH_STATUS_LABELS[status ?? ''] ?? 'Connecting…'
}
testID="connect" testID="connect"
onPress={() => { onPress={() => {
console.log('Connect button pressed', { isSubmitting }); console.log('Connect button pressed', { isSubmitting });

View File

@@ -5,7 +5,6 @@ import {
type XtermWebViewHandle, type XtermWebViewHandle,
} from '@fressh/react-native-xtermjs-webview'; } from '@fressh/react-native-xtermjs-webview';
import { useQueryClient } from '@tanstack/react-query';
import { import {
Stack, Stack,
useLocalSearchParams, useLocalSearchParams,
@@ -19,8 +18,7 @@ import {
SafeAreaView, SafeAreaView,
useSafeAreaInsets, useSafeAreaInsets,
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns'; import { useSshStore } from '@/lib/ssh-store';
import { useSshStore, makeSessionKey } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
export default function TabsShellDetail() { export default function TabsShellDetail() {
@@ -30,10 +28,11 @@ export default function TabsShellDetail() {
React.useCallback(() => { React.useCallback(() => {
startTransition(() => { startTransition(() => {
setTimeout(() => { setTimeout(() => {
// TODO: This is gross // TODO: This is gross. It would be much better to switch
// after the navigation animation completes.
setReady(true); setReady(true);
}, 50); }, 50);
}); // React 19: non-urgent });
return () => { return () => {
setReady(false); setReady(false);
@@ -58,36 +57,33 @@ const encoder = new TextEncoder();
function ShellDetail() { function ShellDetail() {
const xtermRef = useRef<XtermWebViewHandle>(null); const xtermRef = useRef<XtermWebViewHandle>(null);
const terminalReadyRef = useRef(false); const terminalReadyRef = useRef(false);
// Legacy buffer no longer used; relying on Rust ring for replay
const listenerIdRef = useRef<bigint | null>(null); const listenerIdRef = useRef<bigint | null>(null);
const { connectionId, channelId } = useLocalSearchParams<{ const searchParams = useLocalSearchParams<{
connectionId?: string; connectionId?: string;
channelId?: 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 router = useRouter();
const theme = useTheme(); const theme = useTheme();
const channelIdNum = Number(channelId); const shell = useSshStore(
const sess = useSshStore((s) => (s) => s.shells[`${connectionId}-${channelId}` as const],
connectionId && channelId
? s.getByKey(makeSessionKey(connectionId, channelIdNum))
: undefined,
); );
const connection = sess?.connection; const connection = useSshStore((s) => s.connections[connectionId]);
const shell = sess?.shell;
// If the shell disconnects, leave this screen to the list view
useEffect(() => { useEffect(() => {
if (!sess) return; if (shell && connection) return;
if (sess.status === 'disconnected') { console.log('shell or connection not found, replacing route with /shell');
console.log('shell disconnected, replacing route with /shell'); router.replace('/shell');
// Replace so the detail screen isn't on the stack anymore }, [connection, router, shell]);
router.replace('/shell');
}
}, [router, sess]);
// SSH -> xterm: on initialized, replay ring head then attach live listener
useEffect(() => { useEffect(() => {
const xterm = xtermRef.current; const xterm = xtermRef.current;
return () => { return () => {
@@ -98,7 +94,6 @@ function ShellDetail() {
}; };
}, [shell]); }, [shell]);
const queryClient = useQueryClient();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const estimatedTabBarHeight = Platform.select({ const estimatedTabBarHeight = Platform.select({
ios: 49, ios: 49,
@@ -140,10 +135,7 @@ function ShellDetail() {
onPress={async () => { onPress={async () => {
if (!connection) return; if (!connection) return;
try { try {
await disconnectSshConnectionAndInvalidateQuery({ await connection.disconnect();
connectionId: connection.connectionId,
queryClient,
});
} catch (e) { } catch (e) {
console.warn('Failed to disconnect', e); console.warn('Failed to disconnect', e);
} }
@@ -174,55 +166,50 @@ function ShellDetail() {
if (terminalReadyRef.current) return; if (terminalReadyRef.current) return;
terminalReadyRef.current = true; terminalReadyRef.current = true;
// Replay from head, then attach live listener if (!shell) throw new Error('Shell not found');
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;
})();
}
// 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) // Focus to pop the keyboard (iOS needs the prop we set)
const xr2 = xtermRef.current; const xr2 = xtermRef.current;
if (xr2) xr2.focus(); if (xr2) xr2.focus();
return;
}} }}
onData={(terminalMessage) => { onData={(terminalMessage) => {
if (!shell) return; if (!shell) return;
const bytes = encoder.encode(terminalMessage); const bytes = encoder.encode(terminalMessage);
if (shell) { shell.sendData(bytes.buffer).catch((e: unknown) => {
shell.sendData(bytes.buffer).catch((e: unknown) => { console.warn('sendData failed', e);
console.warn('sendData failed', e); router.back();
router.back(); });
});
}
return;
}} }}
/> />
</SafeAreaView> </SafeAreaView>

View File

@@ -4,7 +4,6 @@ import {
type SshConnection, type SshConnection,
} from '@fressh/react-native-uniffi-russh'; } from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Link, Stack, useRouter } from 'expo-router'; import { Link, Stack, useRouter } from 'expo-router';
import React from 'react'; import React from 'react';
@@ -18,12 +17,8 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { preferences } from '@/lib/preferences'; import { preferences } from '@/lib/preferences';
import { import {} from '@/lib/query-fns';
closeSshShellAndInvalidateQuery, import { useSshStore } from '@/lib/ssh-store';
disconnectSshConnectionAndInvalidateQuery,
listSshShellsQueryOptions,
type ShellWithConnection,
} from '@/lib/query-fns';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
export default function TabsShellList() { export default function TabsShellList() {
@@ -36,7 +31,7 @@ export default function TabsShellList() {
} }
function ShellContent() { function ShellContent() {
const connectionsQuery = useQuery(listSshShellsQueryOptions); const connections = useSshStore((s) => Object.values(s.connections));
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -45,63 +40,32 @@ function ShellContent() {
headerRight: () => <HeaderViewModeButton />, headerRight: () => <HeaderViewModeButton />,
}} }}
/> />
{!connectionsQuery.data ? ( {connections.length === 0 ? <EmptyState /> : <LoadedState />}
<LoadingState />
) : connectionsQuery.data.length === 0 ? (
<EmptyState />
) : (
<LoadedState connections={connectionsQuery.data} />
)}
</View>
);
}
function LoadingState() {
const theme = useTheme();
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
}}
>
<Text style={{ color: theme.colors.muted }}>Loading...</Text>
</View> </View>
); );
} }
type ActionTarget = type ActionTarget =
| { | {
shell: ShellWithConnection; shell: SshShell;
} }
| { | {
connection: SshConnection; connection: SshConnection;
}; };
type ConnectionsList = (SshConnection & { shells: SshShell[] })[]; function LoadedState() {
function LoadedState({ connections }: { connections: ConnectionsList }) {
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>( const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
null, null,
); );
const queryClient = useQueryClient();
const [shellListViewMode] = const [shellListViewMode] =
preferences.shellListViewMode.useShellListViewModePref(); preferences.shellListViewMode.useShellListViewModePref();
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{shellListViewMode === 'flat' ? ( {shellListViewMode === 'flat' ? (
<FlatView <FlatView setActionTarget={setActionTarget} />
connectionsWithShells={connections}
setActionTarget={setActionTarget}
/>
) : ( ) : (
<GroupedView <GroupedView setActionTarget={setActionTarget} />
connectionsWithShells={connections}
setActionTarget={setActionTarget}
/>
)} )}
<ActionsSheet <ActionsSheet
target={actionTarget} target={actionTarget}
@@ -111,22 +75,12 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
onCloseShell={() => { onCloseShell={() => {
if (!actionTarget) return; if (!actionTarget) return;
if (!('shell' in actionTarget)) return; if (!('shell' in actionTarget)) return;
void closeSshShellAndInvalidateQuery({ void actionTarget.shell.close();
channelId: actionTarget.shell.channelId,
connectionId: actionTarget.shell.connectionId,
queryClient: queryClient,
});
}} }}
onDisconnect={() => { onDisconnect={() => {
if (!actionTarget) return; if (!actionTarget) return;
const connectionId = if (!('connection' in actionTarget)) return;
'connection' in actionTarget void actionTarget.connection.disconnect();
? actionTarget.connection.connectionId
: actionTarget.shell.connectionId;
void disconnectSshConnectionAndInvalidateQuery({
connectionId: connectionId,
queryClient: queryClient,
});
}} }}
/> />
</View> </View>
@@ -134,26 +88,15 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
} }
function FlatView({ function FlatView({
connectionsWithShells,
setActionTarget, setActionTarget,
}: { }: {
connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void; setActionTarget: (target: ActionTarget) => void;
}) { }) {
const flatShells = React.useMemo(() => { const shells = useSshStore((s) => Object.values(s.shells));
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
acc.push(
...curr.shells.map((shell) => ({
...shell,
connection: curr,
})),
);
return acc;
}, []);
}, [connectionsWithShells]);
return ( return (
<FlashList<ShellWithConnection> <FlashList<SshShell>
data={flatShells} data={shells}
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`} keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
renderItem={({ item }) => ( renderItem={({ item }) => (
<ShellCard <ShellCard
@@ -176,85 +119,92 @@ function FlatView({
} }
function GroupedView({ function GroupedView({
connectionsWithShells,
setActionTarget, setActionTarget,
}: { }: {
connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void; setActionTarget: (target: ActionTarget) => void;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({}); const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const connections = useSshStore((s) => Object.values(s.connections));
const shells = useSshStore((s) => Object.values(s.shells));
return ( return (
<FlashList<ConnectionsList[number]> <FlashList<SshConnection>
data={connectionsWithShells} data={connections}
// estimatedItemSize={80} // estimatedItemSize={80}
keyExtractor={(item) => item.connectionId} keyExtractor={(item) => item.connectionId}
renderItem={({ item }) => ( renderItem={({ item }) => {
<View style={{ gap: 12 }}> const connectionShells = shells.filter(
<Pressable (s) => s.connectionId === item.connectionId,
style={{ );
backgroundColor: theme.colors.surface, return (
borderWidth: 1, <View style={{ gap: 12 }}>
borderColor: theme.colors.border, <Pressable
borderRadius: 12, style={{
paddingHorizontal: 12, backgroundColor: theme.colors.surface,
paddingVertical: 12, borderWidth: 1,
flexDirection: 'row', borderColor: theme.colors.border,
alignItems: 'center', borderRadius: 12,
justifyContent: 'space-between', paddingHorizontal: 12,
}} paddingVertical: 12,
onPress={() => { flexDirection: 'row',
setExpanded((prev) => ({ alignItems: 'center',
...prev, justifyContent: 'space-between',
[item.connectionId]: !prev[item.connectionId], }}
})); onPress={() => {
}} setExpanded((prev) => ({
> ...prev,
<View> [item.connectionId]: !prev[item.connectionId],
<Text }));
style={{ }}
color: theme.colors.textPrimary, >
fontSize: 16, <View>
fontWeight: '700', <Text
}} style={{
> color: theme.colors.textPrimary,
{item.connectionDetails.username}@{item.connectionDetails.host} fontSize: 16,
fontWeight: '700',
}}
>
{item.connectionDetails.username}@
{item.connectionDetails.host}
</Text>
<Text
style={{
color: theme.colors.muted,
fontSize: 12,
marginTop: 2,
}}
>
Port {item.connectionDetails.port} {connectionShells.length}{' '}
shell
{connectionShells.length === 1 ? '' : 's'}
</Text>
</View>
<Text style={{ color: theme.colors.muted, fontSize: 18 }}>
{expanded[item.connectionId] ? '▾' : '▸'}
</Text> </Text>
<Text </Pressable>
style={{ {expanded[item.connectionId] && (
color: theme.colors.muted, <View style={{ gap: 12 }}>
fontSize: 12, {connectionShells.map((sh) => {
marginTop: 2, const shellWithConnection = { ...sh, connection: item };
}} return (
> <ShellCard
Port {item.connectionDetails.port} {item.shells.length} shell key={`${sh.connectionId}:${sh.channelId}`}
{item.shells.length === 1 ? '' : 's'} shell={shellWithConnection}
</Text> onLongPress={() => {
</View> setActionTarget({
<Text style={{ color: theme.colors.muted, fontSize: 18 }}> shell: shellWithConnection,
{expanded[item.connectionId] ? '▾' : '▸'} });
</Text> }}
</Pressable> />
{expanded[item.connectionId] && ( );
<View style={{ gap: 12 }}> })}
{item.shells.map((sh) => { </View>
const shellWithConnection = { ...sh, connection: item }; )}
return ( </View>
<ShellCard );
key={`${sh.connectionId}:${sh.channelId}`} }}
shell={shellWithConnection}
onLongPress={() => {
setActionTarget({
shell: shellWithConnection,
});
}}
/>
);
})}
</View>
)}
</View>
)}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />} ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }} contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -287,7 +237,7 @@ function ShellCard({
shell, shell,
onLongPress, onLongPress,
}: { }: {
shell: ShellWithConnection; shell: SshShell;
onLongPress?: () => void; onLongPress?: () => void;
}) { }) {
const theme = useTheme(); const theme = useTheme();
@@ -295,6 +245,8 @@ function ShellCard({
const since = formatDistanceToNow(new Date(shell.createdAtMs), { const since = formatDistanceToNow(new Date(shell.createdAtMs), {
addSuffix: true, addSuffix: true,
}); });
const connection = useSshStore((s) => s.connections[shell.connectionId]);
if (!connection) return null;
return ( return (
<Pressable <Pressable
style={{ style={{
@@ -328,8 +280,8 @@ function ShellCard({
}} }}
numberOfLines={1} numberOfLines={1}
> >
{shell.connection.connectionDetails.username}@ {connection.connectionDetails.username}@
{shell.connection.connectionDetails.host} {connection.connectionDetails.host}
</Text> </Text>
<Text <Text
style={{ style={{
@@ -339,7 +291,7 @@ function ShellCard({
}} }}
numberOfLines={1} numberOfLines={1}
> >
Port {shell.connection.connectionDetails.port} {shell.pty} Port {connection.connectionDetails.port} {shell.pty}
</Text> </Text>
<Text style={{ color: theme.colors.muted, fontSize: 12, marginTop: 6 }}> <Text style={{ color: theme.colors.muted, fontSize: 12, marginTop: 6 }}>
Started {since} Started {since}

View File

@@ -1,20 +1,15 @@
import { RnRussh } from '@fressh/react-native-uniffi-russh'; import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
import { import { useMutation } from '@tanstack/react-query';
queryOptions,
useMutation,
useQueryClient,
type QueryClient,
} from '@tanstack/react-query';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { secretsManager, type InputConnectionDetails } from './secrets-manager'; import { secretsManager, type InputConnectionDetails } from './secrets-manager';
import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store'; import { useSshStore } from './ssh-store';
import { AbortSignalTimeout } from './utils'; import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = (opts?: { export const useSshConnMutation = (opts?: {
onStatusChange?: (status: string) => void; onConnectionProgress?: (progressEvent: SshConnectionProgress) => void;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const connect = useSshStore((s) => s.connect);
return useMutation({ return useMutation({
mutationFn: async (connectionDetails: InputConnectionDetails) => { mutationFn: async (connectionDetails: InputConnectionDetails) => {
@@ -34,53 +29,38 @@ export const useSshConnMutation = (opts?: {
.then((e) => e.value), .then((e) => e.value),
}; };
const sshConnection = await RnRussh.connect({ const sshConnection = await connect({
host: connectionDetails.host, host: connectionDetails.host,
port: connectionDetails.port, port: connectionDetails.port,
username: connectionDetails.username, username: connectionDetails.username,
security, security,
onStatusChange: (status) => { onConnectionProgress: (progressEvent) => {
console.log('SSH connection status', status); console.log('SSH connect progress event', progressEvent);
opts?.onStatusChange?.(status); opts?.onConnectionProgress?.(progressEvent);
}, },
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });
await secretsManager.connections.utils.upsertConnection({ await secretsManager.connections.utils.upsertConnection({
id: 'default', id: sshConnection.connectionId,
details: connectionDetails, details: connectionDetails,
priority: 0, priority: 0,
}); });
// Capture status events to Zustand after session is known. const shellHandle = await sshConnection.startShell({
let keyRef: SessionKey | null = null; term: 'Xterm',
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);
},
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });
const channelId = shellInterface.channelId; console.log(
const connectionId = `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`; 'Connected to SSH server',
console.log('Connected to SSH server', connectionId, channelId); sshConnection.connectionId,
shellHandle.channelId,
// Track in Zustand store );
keyRef = useSshStore
.getState()
.addSession(sshConnection, shellInterface);
await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
router.push({ router.push({
pathname: '/shell/detail', pathname: '/shell/detail',
params: { params: {
connectionId: connectionId, connectionId: sshConnection.connectionId,
channelId: String(channelId), channelId: shellHandle.channelId,
}, },
}); });
} catch (error) { } 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,
});
};

View File

@@ -2,124 +2,58 @@ import {
RnRussh, RnRussh,
type SshConnection, type SshConnection,
type SshShell, type SshShell,
type SshConnectionStatus,
} from '@fressh/react-native-uniffi-russh'; } from '@fressh/react-native-uniffi-russh';
import { create } from 'zustand'; 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<SessionKey, StoredSession>;
// 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<SshStoreState>((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 = { type SshRegistryStore = {
connections: Record<string, { connections: Record<string, SshConnection>;
connection: SshConnection, shells: Record<`${string}-${number}`, SshShell>;
shells: Record<number, SshShell>, connect: typeof RnRussh.connect;
status: };
}>,
shells: Record<`${string}-${number}`, SshShell>,
addConnection: typeof RnRussh.connect,
}
type SshRegistryService = { export const useSshStore = create<SshRegistryStore>((set) => ({
connect: typeof RnRussh.connect,
}
const useSshRegistryStore = create<SshRegistryStore>((set) => ({
connections: {}, connections: {},
shells: {}, shells: {},
addConnection: async (args) => { connect: async (args) => {
const connection = await RnRussh.connect({ const connection = await RnRussh.connect({
...args, ...args,
onStatusChange: (status) => { onDisconnected: (connectionId) => {
args.onStatusChange?.(status); args.onDisconnected?.(connectionId);
if (status === 'tcpDisconnected') { set((s) => {
// remove all shell const { [connectionId]: _omit, ...rest } = s.connections;
} return { connections: rest };
} });
},
}); });
const originalStartShellFn = connection.startShell;
} const startShell: typeof connection.startShell = async (args) => {
})) const shell = await originalStartShellFn({
...args,
onClosed: (channelId) => {
const sshRegistryService = { args.onClosed?.(channelId);
connect 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;
},
}));

View File

@@ -0,0 +1,4 @@
.turbo
lib/
node_modules/
rust/

View File

@@ -4,15 +4,12 @@ Uniffi bindings for russh
## Installation ## Installation
```sh ```sh
npm install react-native-uniffi-russh npm install react-native-uniffi-russh
``` ```
## Usage ## Usage
```js ```js
import { multiply } from 'react-native-uniffi-russh'; import { multiply } from 'react-native-uniffi-russh';
@@ -21,7 +18,6 @@ import { multiply } from 'react-native-uniffi-russh';
const result = multiply(3, 7); const result = multiply(3, 7);
``` ```
## Contributing ## Contributing
- [Development workflow](CONTRIBUTING.md#development-workflow) - [Development workflow](CONTRIBUTING.md#development-workflow)

View File

@@ -21,6 +21,6 @@ export default defineConfig([
}, },
}, },
{ {
ignores: ['node_modules/', 'lib/', 'src/generated/'], ignores: ['node_modules/', 'lib/', 'src/generated/', 'eslint.config.mjs'],
}, },
]); ]);

View File

@@ -39,8 +39,8 @@
"typecheck": "tsc", "typecheck": "tsc",
"build:ios": "ubrn build ios --and-generate --release", "build:ios": "ubrn build ios --and-generate --release",
"build:android": "ubrn build android --and-generate --release", "build:android": "ubrn build android --and-generate --release",
"build:native": "tsx scripts/native-build.ts",
"build:bob": "bob build", "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", "lint:rust": "cd rust/uniffi-russh && just lint",
"test": "jest", "test": "jest",
"release": "release-it --only-version" "release": "release-it --only-version"

View File

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

View File

@@ -2,47 +2,50 @@
* We cannot make the generated code match this API exactly because uniffi * We cannot make the generated code match this API exactly because uniffi
* - Doesn't support ts literals for rust enums * - Doesn't support ts literals for rust enums
* - Doesn't support passing a js object with methods and properties to or from rust. * - 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. * 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. * 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 * 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. * just make one of the callbacks a sync info() callback.
* *
* Then in this api wrapper we can smooth over those rough edges. * 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 * See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html
*/ */
import * as GeneratedRussh from './index'; import * as GeneratedRussh from './index';
// #region Ideal API // #region Ideal API
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Core types // Core types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export type PtyType = export type TerminalType =
| 'Vanilla' | 'Vt100' | 'Vt102' | 'Vt220' | 'Ansi' | 'Xterm' | 'Xterm256'; | 'Vanilla'
| 'Vt100'
| 'Vt102'
| 'Vt220'
| 'Ansi'
| 'Xterm'
| 'Xterm256';
export type ConnectionDetails = { export type ConnectionDetails = {
host: string; host: string;
port: number; port: number;
username: string; username: string;
security: security:
| { type: 'password'; password: string } | { type: 'password'; password: string }
| { type: 'key'; privateKey: string }; | { type: 'key'; privateKey: string };
}; };
/** /**
* This status is only to provide updates for discrete events * This status is only to provide updates for discrete events
* during the connect() promise. * during the connect() promise.
* *
* It is no longer relevant after the connect() promise is resolved. * It is no longer relevant after the connect() promise is resolved.
*/ */
export type SshConnectionProgress = export type SshConnectionProgress =
| 'tcpConnected' // TCP established, starting SSH handshake | 'tcpConnected' // TCP established, starting SSH handshake
| 'sshHandshake' // SSH protocol negotiation complete | 'sshHandshake'; // SSH protocol negotiation complete
export type ConnectOptions = ConnectionDetails & { export type ConnectOptions = ConnectionDetails & {
onConnectionProgress?: (status: SshConnectionProgress) => void; onConnectionProgress?: (status: SshConnectionProgress) => void;
@@ -51,31 +54,33 @@ export type ConnectOptions = ConnectionDetails & {
}; };
export type StartShellOptions = { export type StartShellOptions = {
pty: PtyType; term: TerminalType;
onClosed?: (shellId: string) => void; terminalMode?: GeneratedRussh.TerminalMode[];
terminalPixelSize?: GeneratedRussh.TerminalPixelSize;
terminalSize?: GeneratedRussh.TerminalSize;
onClosed?: (shellId: number) => void;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}; };
export type StreamKind = 'stdout' | 'stderr'; export type StreamKind = 'stdout' | 'stderr';
export type TerminalChunk = { export type TerminalChunk = {
/** Monotonic sequence number from the shell start (Rust u64; JS uses number). */ seq: bigint;
seq: number;
/** Milliseconds since UNIX epoch (double). */ /** Milliseconds since UNIX epoch (double). */
tMs: number; tMs: number;
stream: StreamKind; 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 ListenerEvent = TerminalChunk | DropNotice;
export type Cursor = export type Cursor =
| { mode: 'head' } // earliest available in ring | { mode: 'head' } // earliest available in ring
| { mode: 'tailBytes'; bytes: number } // last N bytes (best-effort) | { mode: 'tailBytes'; bytes: bigint } // last N bytes (best-effort)
| { mode: 'seq'; seq: number } // from a given sequence | { mode: 'seq'; seq: bigint } // from a given sequence
| { mode: 'time'; tMs: number } // from timestamp | { mode: 'time'; tMs: number } // from timestamp
| { mode: 'live' }; // no replay, live only | { mode: 'live' }; // no replay, live only
export type ListenerOptions = { export type ListenerOptions = {
cursor: Cursor; cursor: Cursor;
@@ -83,30 +88,27 @@ export type ListenerOptions = {
coalesceMs?: number; 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 = { export type BufferReadResult = {
chunks: TerminalChunk[]; chunks: TerminalChunk[];
nextSeq: number; nextSeq: bigint;
dropped?: { fromSeq: number; toSeq: number }; dropped?: { fromSeq: bigint; toSeq: bigint };
}; };
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Handles // Handles
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
type ProgressTimings = {
tcpEstablishedAtMs: number;
sshHandshakeAtMs: number;
};
export type SshConnection = { export type SshConnection = {
readonly connectionId: string; readonly connectionId: string;
readonly createdAtMs: number; readonly createdAtMs: number;
readonly tcpEstablishedAtMs: number; readonly connectedAtMs: number;
readonly connectionDetails: ConnectionDetails; readonly connectionDetails: ConnectionDetails;
readonly progressTimings: ProgressTimings;
startShell: (opts: StartShellOptions) => Promise<SshShell>; startShell: (opts: StartShellOptions) => Promise<SshShell>;
disconnect: (opts?: { signal?: AbortSignal }) => Promise<void>; disconnect: (opts?: { signal?: AbortSignal }) => Promise<void>;
@@ -115,20 +117,26 @@ export type SshConnection = {
export type SshShell = { export type SshShell = {
readonly channelId: number; readonly channelId: number;
readonly createdAtMs: number; readonly createdAtMs: number;
readonly pty: PtyType; readonly pty: TerminalType;
readonly connectionId: string; readonly connectionId: string;
// I/O // I/O
sendData: (data: ArrayBuffer, opts?: { signal?: AbortSignal }) => Promise<void>; sendData: (
data: ArrayBuffer,
opts?: { signal?: AbortSignal }
) => Promise<void>;
close: (opts?: { signal?: AbortSignal }) => Promise<void>; close: (opts?: { signal?: AbortSignal }) => Promise<void>;
// Buffer policy & stats // Buffer policy & stats
setBufferPolicy: (policy: { ringBytes?: number; coalesceMs?: number }) => Promise<void>; // setBufferPolicy: (policy: {
bufferStats: () => Promise<BufferStats>; // ringBytes?: number;
currentSeq: () => Promise<number>; // coalesceMs?: number;
// }) => Promise<void>;
bufferStats: () => GeneratedRussh.BufferStats;
currentSeq: () => number;
// Replay + live // Replay + live
readBuffer: (cursor: Cursor, maxBytes?: number) => Promise<BufferReadResult>; readBuffer: (cursor: Cursor, maxBytes?: bigint) => BufferReadResult;
addListener: ( addListener: (
cb: (ev: ListenerEvent) => void, cb: (ev: ListenerEvent) => void,
opts: ListenerOptions opts: ListenerOptions
@@ -146,41 +154,55 @@ type RusshApi = {
// #region Wrapper to match the ideal API // #region Wrapper to match the ideal API
const ptyTypeLiteralToEnum = { const terminalTypeLiteralToEnum = {
Vanilla: GeneratedRussh.PtyType.Vanilla, Vanilla: GeneratedRussh.TerminalType.Vanilla,
Vt100: GeneratedRussh.PtyType.Vt100, Vt100: GeneratedRussh.TerminalType.Vt100,
Vt102: GeneratedRussh.PtyType.Vt102, Vt102: GeneratedRussh.TerminalType.Vt102,
Vt220: GeneratedRussh.PtyType.Vt220, Vt220: GeneratedRussh.TerminalType.Vt220,
Ansi: GeneratedRussh.PtyType.Ansi, Ansi: GeneratedRussh.TerminalType.Ansi,
Xterm: GeneratedRussh.PtyType.Xterm, Xterm: GeneratedRussh.TerminalType.Xterm,
Xterm256: GeneratedRussh.PtyType.Xterm256, Xterm256: GeneratedRussh.TerminalType.Xterm256,
} as const satisfies Record<string, GeneratedRussh.PtyType>; } as const satisfies Record<string, GeneratedRussh.TerminalType>;
const ptyEnumToLiteral: Record<GeneratedRussh.PtyType, PtyType> = { const terminalTypeEnumToLiteral: Record<
[GeneratedRussh.PtyType.Vanilla]: 'Vanilla', GeneratedRussh.TerminalType,
[GeneratedRussh.PtyType.Vt100]: 'Vt100', TerminalType
[GeneratedRussh.PtyType.Vt102]: 'Vt102', > = {
[GeneratedRussh.PtyType.Vt220]: 'Vt220', [GeneratedRussh.TerminalType.Vanilla]: 'Vanilla',
[GeneratedRussh.PtyType.Ansi]: 'Ansi', [GeneratedRussh.TerminalType.Vt100]: 'Vt100',
[GeneratedRussh.PtyType.Xterm]: 'Xterm', [GeneratedRussh.TerminalType.Vt102]: 'Vt102',
[GeneratedRussh.PtyType.Xterm256]: 'Xterm256', [GeneratedRussh.TerminalType.Vt220]: 'Vt220',
[GeneratedRussh.TerminalType.Ansi]: 'Ansi',
[GeneratedRussh.TerminalType.Xterm]: 'Xterm',
[GeneratedRussh.TerminalType.Xterm256]: 'Xterm256',
}; };
const sshConnStatusEnumToLiteral = { const sshConnProgressEnumToLiteral = {
[GeneratedRussh.SshConnectionStatus.TcpConnected]: 'tcpConnected', [GeneratedRussh.SshConnectionProgressEvent.TcpConnected]: 'tcpConnected',
[GeneratedRussh.SshConnectionStatus.SshHandshake]: 'sshHandshake', [GeneratedRussh.SshConnectionProgressEvent.SshHandshake]: 'sshHandshake',
} as const satisfies Record<GeneratedRussh.SshConnectionStatus, SshConnectionProgress>; } as const satisfies Record<
GeneratedRussh.SshConnectionProgressEvent,
SshConnectionProgress
>;
const streamEnumToLiteral = { const streamEnumToLiteral = {
[GeneratedRussh.StreamKind.Stdout]: 'stdout', [GeneratedRussh.StreamKind.Stdout]: 'stdout',
[GeneratedRussh.StreamKind.Stderr]: 'stderr', [GeneratedRussh.StreamKind.Stderr]: 'stderr',
} as const satisfies Record<GeneratedRussh.StreamKind, StreamKind>; } as const satisfies Record<GeneratedRussh.StreamKind, StreamKind>;
function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails { function generatedConnDetailsToIdeal(
const security: ConnectionDetails['security'] = details.security instanceof GeneratedRussh.Security.Password details: GeneratedRussh.ConnectionDetails
? { type: 'password', password: details.security.inner.password } ): ConnectionDetails {
: { type: 'key', privateKey: details.security.inner.keyId }; const security: ConnectionDetails['security'] =
return { host: details.host, port: details.port, username: details.username, 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 { function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
@@ -188,9 +210,11 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
case 'head': case 'head':
return new GeneratedRussh.Cursor.Head(); return new GeneratedRussh.Cursor.Head();
case 'tailBytes': case 'tailBytes':
return new GeneratedRussh.Cursor.TailBytes({ bytes: BigInt(cursor.bytes) }); return new GeneratedRussh.Cursor.TailBytes({
bytes: cursor.bytes,
});
case 'seq': case 'seq':
return new GeneratedRussh.Cursor.Seq({ seq: BigInt(cursor.seq) }); return new GeneratedRussh.Cursor.Seq({ seq: cursor.seq });
case 'time': case 'time':
return new GeneratedRussh.Cursor.TimeMs({ tMs: cursor.tMs }); return new GeneratedRussh.Cursor.TimeMs({ tMs: cursor.tMs });
case 'live': case 'live':
@@ -200,38 +224,24 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
function toTerminalChunk(ch: GeneratedRussh.TerminalChunk): TerminalChunk { function toTerminalChunk(ch: GeneratedRussh.TerminalChunk): TerminalChunk {
return { return {
seq: Number(ch.seq), seq: ch.seq,
tMs: ch.tMs, tMs: ch.tMs,
stream: streamEnumToLiteral[ch.stream], stream: streamEnumToLiteral[ch.stream],
bytes: new Uint8Array(ch.bytes as any), bytes: ch.bytes,
}; };
} }
function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell { function wrapShellSession(
const info = shell.info(); shell: GeneratedRussh.ShellSessionInterface
): SshShell {
const info = shell.getInfo();
const setBufferPolicy: SshShell['setBufferPolicy'] = async (policy) => { const readBuffer: SshShell['readBuffer'] = (cursor, maxBytes) => {
await shell.setBufferPolicy(policy.ringBytes != null ? BigInt(policy.ringBytes) : undefined, policy.coalesceMs); const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes);
};
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);
return { return {
chunks: res.chunks.map(toTerminalChunk), chunks: res.chunks.map(toTerminalChunk),
nextSeq: Number(res.nextSeq), nextSeq: res.nextSeq,
dropped: res.dropped ? { fromSeq: Number(res.dropped.fromSeq), toSeq: Number(res.dropped.toSeq) } : undefined, dropped: res.dropped,
} satisfies BufferReadResult; } satisfies BufferReadResult;
}; };
@@ -241,87 +251,128 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell
if (ev instanceof GeneratedRussh.ShellEvent.Chunk) { if (ev instanceof GeneratedRussh.ShellEvent.Chunk) {
cb(toTerminalChunk(ev.inner[0]!)); cb(toTerminalChunk(ev.inner[0]!));
} else if (ev instanceof GeneratedRussh.ShellEvent.Dropped) { } 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; } satisfies GeneratedRussh.ShellListener;
try { 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) { if (id === 0n) {
throw new Error('Failed to attach shell listener (id=0)'); throw new Error('Failed to attach shell listener (id=0)');
} }
return BigInt(id); return id;
} catch (e) { } 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 { return {
channelId: info.channelId, channelId: info.channelId,
createdAtMs: info.createdAtMs, createdAtMs: info.createdAtMs,
pty: ptyEnumToLiteral[info.pty], pty: terminalTypeEnumToLiteral[info.term],
connectionId: info.connectionId, 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), close: (o) => shell.close(o?.signal ? { signal: o.signal } : undefined),
setBufferPolicy, // setBufferPolicy,
bufferStats, bufferStats: shell.bufferStats,
currentSeq: async () => Number(shell.currentSeq()), currentSeq: () => Number(shell.currentSeq()),
readBuffer, readBuffer,
addListener, addListener,
removeListener: (id) => shell.removeListener(id), removeListener: (id) => shell.removeListener(id),
}; };
} }
function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection { function wrapConnection(
const inf = conn.info(); conn: GeneratedRussh.SshConnectionInterface
): SshConnection {
const info = conn.getInfo();
return { return {
connectionId: inf.connectionId, connectionId: info.connectionId,
connectionDetails: generatedConnDetailsToIdeal(inf.connectionDetails), connectionDetails: generatedConnDetailsToIdeal(info.connectionDetails),
createdAtMs: inf.createdAtMs, createdAtMs: info.createdAtMs,
tcpEstablishedAtMs: inf.tcpEstablishedAtMs, connectedAtMs: info.connectedAtMs,
progressTimings: {
tcpEstablishedAtMs: info.progressTimings.tcpEstablishedAtMs,
sshHandshakeAtMs: info.progressTimings.sshHandshakeAtMs,
},
startShell: async (params) => { startShell: async (params) => {
const shell = await conn.startShell( const shell = await conn.startShell(
{ {
pty: ptyTypeLiteralToEnum[params.pty], term: terminalTypeLiteralToEnum[params.term],
onStatusChange: params.onStatusChange onClosedCallback: params.onClosed
? { onChange: (statusEnum) => params.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) } ? {
onChange: (channelId) => params.onClosed!(channelId),
}
: undefined, : 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); 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<SshConnection> { async function connect(options: ConnectOptions): Promise<SshConnection> {
const security = const security =
options.security.type === 'password' options.security.type === 'password'
? new GeneratedRussh.Security.Password({ password: options.security.password }) ? new GeneratedRussh.Security.Password({
: new GeneratedRussh.Security.Key({ keyId: options.security.privateKey }); password: options.security.password,
})
: new GeneratedRussh.Security.Key({
privateKeyContent: options.security.privateKey,
});
const sshConnection = await GeneratedRussh.connect( const sshConnection = await GeneratedRussh.connect(
{ {
host: options.host, connectionDetails: {
port: options.port, host: options.host,
username: options.username, port: options.port,
security, username: options.username,
onStatusChange: options.onStatusChange ? { security,
onChange: (statusEnum) => options.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) },
} : undefined, 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); return wrapConnection(sshConnection);
} }
async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') { 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]); return GeneratedRussh.generateKeyPair(map[type]);
} }
// #endregion // #endregion
export const RnRussh = { export const RnRussh = {

View File

@@ -1,6 +1,6 @@
{ {
"extends": "./tsconfig", "extends": "./tsconfig",
"exclude": ["example", "lib"], "exclude": ["example", "lib", "scripts"],
"compilerOptions": { "compilerOptions": {
"noUnusedParameters": false, "noUnusedParameters": false,
"noUnusedLocals": false "noUnusedLocals": false

View File

@@ -4,24 +4,47 @@
"tasks": { "tasks": {
// Default overrides // Default overrides
"lint": { "lint": {
"dependsOn": ["fmt", "^build"], "dependsOn": ["fmt", "^build", "build:bob"],
"with": ["typecheck", "//#lint:root", "lint:rust"], "with": ["typecheck", "//#lint:root", "lint:rust"],
}, },
"lint:check": { "lint:check": {
"dependsOn": ["^build"], "dependsOn": ["^build", "build:bob"],
"with": ["fmt:check", "typecheck", "//#lint:check:root", "lint:rust"], "with": ["fmt:check", "typecheck", "//#lint:check:root", "lint:rust"],
}, },
"build:android": { "build": {
"outputs": ["dist/**"], "dependsOn": ["build:bob"],
"dependsOn": ["^build", "build:bob"],
}, },
"build:ios": { "typecheck": {
"outputs": ["dist/**"], "dependsOn": ["build:native"],
"dependsOn": ["^build", "build:bob"],
}, },
// Special tasks // Special tasks
"lint:rust": {}, "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",
],
},
}, },
} }

View File

@@ -17,12 +17,7 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
}, },
"build": { "build": {
"dependsOn": ["^build"], "outputs": ["dist/**"],
},
"build:ios": {
"dependsOn": ["^build"],
},
"build:android": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
}, },
"test": { "test": {