mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
passing lint
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
4
packages/react-native-uniffi-russh/.prettierignore
Normal file
4
packages/react-native-uniffi-russh/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.turbo
|
||||||
|
lib/
|
||||||
|
node_modules/
|
||||||
|
rust/
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['node_modules/', 'lib/', 'src/generated/'],
|
ignores: ['node_modules/', 'lib/', 'src/generated/', 'eslint.config.mjs'],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
23
packages/react-native-uniffi-russh/scripts/native-build.ts
Normal file
23
packages/react-native-uniffi-russh/scripts/native-build.ts
Normal 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',
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user