mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
Merge commit '808f476bac05d204d46f57e314d56633f6bd3f11'
This commit is contained in:
@@ -28,13 +28,14 @@
|
|||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@fressh/assets": "workspace:*",
|
"@fressh/assets": "workspace:*",
|
||||||
"@fressh/react-native-uniffi-russh": "workspace:*",
|
"@fressh/react-native-uniffi-russh": "workspace:*",
|
||||||
|
"@fressh/react-native-xtermjs-webview": "workspace:*",
|
||||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.4",
|
"@react-navigation/elements": "^2.6.4",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/react-form": "^1.20.0",
|
"@tanstack/react-form": "^1.20.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.89.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "54.0.8",
|
"expo": "54.0.8",
|
||||||
"expo-clipboard": "~8.0.7",
|
"expo-clipboard": "~8.0.7",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
|
"p-queue": "^8.1.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
@@ -64,8 +66,9 @@
|
|||||||
"react-native-safe-area-context": "~5.6.1",
|
"react-native-safe-area-context": "~5.6.1",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.1",
|
"react-native-web": "~0.21.1",
|
||||||
|
"react-native-webview": "13.15.0",
|
||||||
"react-native-worklets": "~0.5.1",
|
"react-native-worklets": "~0.5.1",
|
||||||
"zod": "^4.1.8"
|
"zod": "^4.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@epic-web/config": "^1.21.3",
|
"@epic-web/config": "^1.21.3",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function TabsShellStack() {
|
|||||||
headerBlurEffect: undefined,
|
headerBlurEffect: undefined,
|
||||||
headerTransparent: false,
|
headerTransparent: false,
|
||||||
headerStyle: { backgroundColor: theme.colors.surface },
|
headerStyle: { backgroundColor: theme.colors.surface },
|
||||||
|
headerTintColor: theme.colors.textPrimary,
|
||||||
headerTitleStyle: {
|
headerTitleStyle: {
|
||||||
color: theme.colors.textPrimary,
|
color: theme.colors.textPrimary,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,57 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
|
||||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Platform,
|
type ListenerEvent,
|
||||||
Pressable,
|
type TerminalChunk,
|
||||||
ScrollView,
|
} from '@fressh/react-native-uniffi-russh';
|
||||||
Text,
|
import {
|
||||||
TextInput,
|
XtermJsWebView,
|
||||||
View,
|
type XtermWebViewHandle,
|
||||||
} from 'react-native';
|
} from '@fressh/react-native-xtermjs-webview';
|
||||||
|
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useRouter,
|
||||||
|
useFocusEffect,
|
||||||
|
} from 'expo-router';
|
||||||
|
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Pressable, View, Text } from 'react-native';
|
||||||
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||||
|
import { getSession } from '@/lib/ssh-registry';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
export default function TabsShellDetail() {
|
export default function TabsShellDetail() {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
startTransition(() => setReady(true)); // React 19: non-urgent
|
||||||
|
|
||||||
|
return () => setReady(false);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return <RouteSkeleton />;
|
||||||
return <ShellDetail />;
|
return <ShellDetail />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteSkeleton() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>Loading</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ShellDetail() {
|
function ShellDetail() {
|
||||||
|
const xtermRef = useRef<XtermWebViewHandle>(null);
|
||||||
|
const terminalReadyRef = useRef(false);
|
||||||
|
// Legacy buffer no longer used; relying on Rust ring for replay
|
||||||
|
const listenerIdRef = useRef<bigint | null>(null);
|
||||||
|
|
||||||
const { connectionId, channelId } = useLocalSearchParams<{
|
const { connectionId, channelId } = useLocalSearchParams<{
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@@ -26,37 +60,25 @@ function ShellDetail() {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const channelIdNum = Number(channelId);
|
const channelIdNum = Number(channelId);
|
||||||
const connection = connectionId
|
const sess =
|
||||||
? RnRussh.getSshConnection(String(connectionId))
|
|
||||||
: undefined;
|
|
||||||
const shell =
|
|
||||||
connectionId && channelId
|
connectionId && channelId
|
||||||
? RnRussh.getSshShell(String(connectionId), channelIdNum)
|
? getSession(String(connectionId), channelIdNum)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const connection = sess?.connection;
|
||||||
|
const shell = sess?.shell;
|
||||||
|
|
||||||
const [shellData, setShellData] = useState('');
|
// SSH -> xterm: on initialized, replay ring head then attach live listener
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connection) return;
|
const xterm = xtermRef.current;
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
|
|
||||||
try {
|
|
||||||
const bytes = new Uint8Array(data);
|
|
||||||
const chunk = decoder.decode(bytes);
|
|
||||||
setShellData((prev) => prev + chunk);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to decode shell data', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
connection.removeChannelListener(listenerId);
|
if (shell && listenerIdRef.current != null)
|
||||||
|
shell.removeListener(listenerIdRef.current);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
xterm?.flush?.();
|
||||||
};
|
};
|
||||||
}, [connection]);
|
}, [shell]);
|
||||||
|
|
||||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
const queryClient = useQueryClient();
|
||||||
useEffect(() => {
|
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
|
||||||
}, [shellData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
@@ -68,9 +90,15 @@ function ShellDetail() {
|
|||||||
accessibilityLabel="Disconnect"
|
accessibilityLabel="Disconnect"
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
if (!connection) return;
|
||||||
try {
|
try {
|
||||||
await connection?.disconnect();
|
await disconnectSshConnectionAndInvalidateQuery({
|
||||||
} catch {}
|
connectionId: connection.connectionId,
|
||||||
|
queryClient,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to disconnect', e);
|
||||||
|
}
|
||||||
router.replace('/shell');
|
router.replace('/shell');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -85,115 +113,92 @@ function ShellDetail() {
|
|||||||
{ backgroundColor: theme.colors.background },
|
{ backgroundColor: theme.colors.background },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<XtermJsWebView
|
||||||
style={{
|
ref={xtermRef}
|
||||||
flex: 1,
|
style={{ flex: 1 }}
|
||||||
backgroundColor: '#0E172B',
|
// WebView behavior that suits terminals
|
||||||
borderRadius: 12,
|
keyboardDisplayRequiresUserAction={false}
|
||||||
height: 400,
|
setSupportMultipleWindows={false}
|
||||||
borderWidth: 1,
|
overScrollMode="never"
|
||||||
borderColor: '#2A3655',
|
pullToRefreshEnabled={false}
|
||||||
overflow: 'hidden',
|
bounces={false}
|
||||||
marginBottom: 12,
|
setBuiltInZoomControls={false}
|
||||||
|
setDisplayZoomControls={false}
|
||||||
|
textZoom={100}
|
||||||
|
allowsLinkPreview={false}
|
||||||
|
textInteractionEnabled={false}
|
||||||
|
// xterm-ish props (applied via setOptions inside the page)
|
||||||
|
fontFamily="Menlo, ui-monospace, monospace"
|
||||||
|
fontSize={18} // bump if it still feels small
|
||||||
|
cursorBlink
|
||||||
|
scrollback={10000}
|
||||||
|
themeBackground={theme.colors.background}
|
||||||
|
themeForeground={theme.colors.textPrimary}
|
||||||
|
onRenderProcessGone={() => {
|
||||||
|
console.log('WebView render process gone -> clear()');
|
||||||
|
xtermRef.current?.clear?.();
|
||||||
}}
|
}}
|
||||||
>
|
onContentProcessDidTerminate={() => {
|
||||||
<ScrollView
|
console.log('WKWebView content process terminated -> clear()');
|
||||||
ref={scrollViewRef}
|
xtermRef.current?.clear?.();
|
||||||
contentContainerStyle={{
|
}}
|
||||||
paddingHorizontal: 12,
|
onLoadEnd={() => {
|
||||||
paddingTop: 4,
|
console.log('WebView onLoadEnd');
|
||||||
paddingBottom: 12,
|
}}
|
||||||
}}
|
onMessage={(m) => {
|
||||||
keyboardShouldPersistTaps="handled"
|
console.log('received msg', m);
|
||||||
>
|
if (m.type === 'initialized') {
|
||||||
<Text
|
if (terminalReadyRef.current) return;
|
||||||
selectable
|
terminalReadyRef.current = true;
|
||||||
style={{
|
|
||||||
color: '#D1D5DB',
|
// Replay from head, then attach live listener
|
||||||
fontSize: 14,
|
if (shell) {
|
||||||
lineHeight: 18,
|
void (async () => {
|
||||||
fontFamily: Platform.select({
|
const res = await shell.readBuffer({ mode: 'head' });
|
||||||
ios: 'Menlo',
|
console.log('readBuffer(head)', {
|
||||||
android: 'monospace',
|
chunks: res.chunks.length,
|
||||||
default: 'monospace',
|
nextSeq: res.nextSeq,
|
||||||
}),
|
dropped: res.dropped,
|
||||||
}}
|
});
|
||||||
>
|
if (res.chunks.length) {
|
||||||
{shellData || 'Connected. Output will appear here...'}
|
const chunks = res.chunks.map((c) => c.bytes);
|
||||||
</Text>
|
xtermRef.current?.writeMany?.(chunks);
|
||||||
</ScrollView>
|
xtermRef.current?.flush?.();
|
||||||
</View>
|
}
|
||||||
<CommandInput
|
const id = shell.addListener(
|
||||||
executeCommand={async (command) => {
|
(ev: ListenerEvent) => {
|
||||||
await shell?.sendData(
|
if ('kind' in ev && ev.kind === 'dropped') {
|
||||||
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
console.log('listener.dropped', ev);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
const chunk = ev as TerminalChunk;
|
||||||
|
xtermRef.current?.write(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)
|
||||||
|
xtermRef.current?.focus?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type === 'data') {
|
||||||
|
console.log('xterm->SSH', { len: m.data.length });
|
||||||
|
// Ensure we send the exact slice; send CR only for Enter.
|
||||||
|
const { buffer, byteOffset, byteLength } = m.data;
|
||||||
|
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
|
||||||
|
void shell?.sendData(ab as ArrayBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type === 'debug') {
|
||||||
|
console.log('xterm.debug', m.message);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandInput(props: {
|
|
||||||
executeCommand: (command: string) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [command, setCommand] = useState('');
|
|
||||||
|
|
||||||
async function handleExecute() {
|
|
||||||
if (!command.trim()) return;
|
|
||||||
await props.executeCommand(command);
|
|
||||||
setCommand('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
||||||
<TextInput
|
|
||||||
testID="command-input"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0E172B',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2A3655',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
color: '#E5E7EB',
|
|
||||||
fontSize: 16,
|
|
||||||
fontFamily: Platform.select({
|
|
||||||
ios: 'Menlo',
|
|
||||||
android: 'monospace',
|
|
||||||
default: 'monospace',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
value={command}
|
|
||||||
onChangeText={setCommand}
|
|
||||||
placeholder="Type a command and press Enter or Execute"
|
|
||||||
placeholderTextColor="#9AA0A6"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType="send"
|
|
||||||
onSubmitEditing={handleExecute}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
{ marginTop: 8 },
|
|
||||||
]}
|
|
||||||
onPress={handleExecute}
|
|
||||||
testID="execute-button"
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
|
||||||
Execute
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import {
|
import { type SshConnection } from '@fressh/react-native-uniffi-russh';
|
||||||
type RnRussh,
|
|
||||||
type SshConnection,
|
|
||||||
} 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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
@@ -24,6 +21,7 @@ import {
|
|||||||
listSshShellsQueryOptions,
|
listSshShellsQueryOptions,
|
||||||
type ShellWithConnection,
|
type ShellWithConnection,
|
||||||
} from '@/lib/query-fns';
|
} from '@/lib/query-fns';
|
||||||
|
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
export default function TabsShellList() {
|
export default function TabsShellList() {
|
||||||
@@ -80,11 +78,9 @@ type ActionTarget =
|
|||||||
connection: SshConnection;
|
connection: SshConnection;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LoadedState({
|
type ConnectionsList = ReturnType<typeof registryList>;
|
||||||
connections,
|
|
||||||
}: {
|
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||||
connections: ReturnType<typeof RnRussh.listSshConnectionsWithShells>;
|
|
||||||
}) {
|
|
||||||
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -137,9 +133,7 @@ function FlatView({
|
|||||||
connectionsWithShells,
|
connectionsWithShells,
|
||||||
setActionTarget,
|
setActionTarget,
|
||||||
}: {
|
}: {
|
||||||
connectionsWithShells: ReturnType<
|
connectionsWithShells: ConnectionsList;
|
||||||
typeof RnRussh.listSshConnectionsWithShells
|
|
||||||
>;
|
|
||||||
setActionTarget: (target: ActionTarget) => void;
|
setActionTarget: (target: ActionTarget) => void;
|
||||||
}) {
|
}) {
|
||||||
const flatShells = React.useMemo(() => {
|
const flatShells = React.useMemo(() => {
|
||||||
@@ -149,7 +143,7 @@ function FlatView({
|
|||||||
}, []);
|
}, []);
|
||||||
}, [connectionsWithShells]);
|
}, [connectionsWithShells]);
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList<ShellWithConnection>
|
||||||
data={flatShells}
|
data={flatShells}
|
||||||
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
|
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@@ -176,15 +170,13 @@ function GroupedView({
|
|||||||
connectionsWithShells,
|
connectionsWithShells,
|
||||||
setActionTarget,
|
setActionTarget,
|
||||||
}: {
|
}: {
|
||||||
connectionsWithShells: ReturnType<
|
connectionsWithShells: ConnectionsList;
|
||||||
typeof RnRussh.listSshConnectionsWithShells
|
|
||||||
>;
|
|
||||||
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>>({});
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList<ConnectionsList[number]>
|
||||||
data={connectionsWithShells}
|
data={connectionsWithShells}
|
||||||
// estimatedItemSize={80}
|
// estimatedItemSize={80}
|
||||||
keyExtractor={(item) => item.connectionId}
|
keyExtractor={(item) => item.connectionId}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||||
RnRussh,
|
|
||||||
type SshConnection,
|
|
||||||
type SshShellSession,
|
|
||||||
} from '@fressh/react-native-uniffi-russh';
|
|
||||||
import {
|
import {
|
||||||
queryOptions,
|
queryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -11,6 +7,11 @@ import {
|
|||||||
} from '@tanstack/react-query';
|
} 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 {
|
||||||
|
listConnectionsWithShells as registryList,
|
||||||
|
registerSession,
|
||||||
|
type ShellWithConnection,
|
||||||
|
} from './ssh-registry';
|
||||||
import { AbortSignalTimeout } from './utils';
|
import { AbortSignalTimeout } from './utils';
|
||||||
|
|
||||||
export const useSshConnMutation = () => {
|
export const useSshConnMutation = () => {
|
||||||
@@ -57,6 +58,9 @@ export const useSshConnMutation = () => {
|
|||||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||||
console.log('Connected to SSH server', connectionId, channelId);
|
console.log('Connected to SSH server', connectionId, channelId);
|
||||||
|
|
||||||
|
// Track in registry for app use
|
||||||
|
registerSession(sshConnection, shellInterface);
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: listSshShellsQueryOptions.queryKey,
|
queryKey: listSshShellsQueryOptions.queryKey,
|
||||||
});
|
});
|
||||||
@@ -77,19 +81,17 @@ export const useSshConnMutation = () => {
|
|||||||
|
|
||||||
export const listSshShellsQueryOptions = queryOptions({
|
export const listSshShellsQueryOptions = queryOptions({
|
||||||
queryKey: ['ssh-shells'],
|
queryKey: ['ssh-shells'],
|
||||||
queryFn: () => RnRussh.listSshConnectionsWithShells(),
|
queryFn: () => registryList(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ShellWithConnection = SshShellSession & {
|
export type { ShellWithConnection };
|
||||||
connection: SshConnection;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const closeSshShellAndInvalidateQuery = async (params: {
|
export const closeSshShellAndInvalidateQuery = async (params: {
|
||||||
channelId: number;
|
channelId: number;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}) => {
|
}) => {
|
||||||
const currentActiveShells = RnRussh.listSshConnectionsWithShells();
|
const currentActiveShells = registryList();
|
||||||
const connection = currentActiveShells.find(
|
const connection = currentActiveShells.find(
|
||||||
(c) => c.connectionId === params.connectionId,
|
(c) => c.connectionId === params.connectionId,
|
||||||
);
|
);
|
||||||
@@ -97,7 +99,6 @@ export const closeSshShellAndInvalidateQuery = async (params: {
|
|||||||
const shell = connection.shells.find((s) => s.channelId === params.channelId);
|
const shell = connection.shells.find((s) => s.channelId === params.channelId);
|
||||||
if (!shell) throw new Error('Shell not found');
|
if (!shell) throw new Error('Shell not found');
|
||||||
await shell.close();
|
await shell.close();
|
||||||
if (connection.shells.length <= 1) await connection.disconnect();
|
|
||||||
await params.queryClient.invalidateQueries({
|
await params.queryClient.invalidateQueries({
|
||||||
queryKey: listSshShellsQueryOptions.queryKey,
|
queryKey: listSshShellsQueryOptions.queryKey,
|
||||||
});
|
});
|
||||||
@@ -107,7 +108,10 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
|
|||||||
connectionId: string;
|
connectionId: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}) => {
|
}) => {
|
||||||
const connection = RnRussh.getSshConnection(params.connectionId);
|
const currentActiveShells = registryList();
|
||||||
|
const connection = currentActiveShells.find(
|
||||||
|
(c) => c.connectionId === params.connectionId,
|
||||||
|
);
|
||||||
if (!connection) throw new Error('Connection not found');
|
if (!connection) throw new Error('Connection not found');
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
await params.queryClient.invalidateQueries({
|
await params.queryClient.invalidateQueries({
|
||||||
|
|||||||
108
apps/mobile/src/lib/ssh-registry.ts
Normal file
108
apps/mobile/src/lib/ssh-registry.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
RnRussh,
|
||||||
|
type SshConnection,
|
||||||
|
type SshShell,
|
||||||
|
} from '@fressh/react-native-uniffi-russh';
|
||||||
|
|
||||||
|
// Simple in-memory registry owned by JS to track active handles.
|
||||||
|
// Keyed by `${connectionId}:${channelId}`.
|
||||||
|
|
||||||
|
export type SessionKey = string;
|
||||||
|
|
||||||
|
export type StoredSession = {
|
||||||
|
connection: SshConnection;
|
||||||
|
shell: SshShell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessions = new Map<SessionKey, StoredSession>();
|
||||||
|
|
||||||
|
export function makeSessionKey(
|
||||||
|
connectionId: string,
|
||||||
|
channelId: number,
|
||||||
|
): SessionKey {
|
||||||
|
return `${connectionId}:${channelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSession(
|
||||||
|
connection: SshConnection,
|
||||||
|
shell: SshShell,
|
||||||
|
): SessionKey {
|
||||||
|
const key = makeSessionKey(connection.connectionId, shell.channelId);
|
||||||
|
sessions.set(key, { connection, shell });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(
|
||||||
|
connectionId: string,
|
||||||
|
channelId: number,
|
||||||
|
): StoredSession | undefined {
|
||||||
|
return sessions.get(makeSessionKey(connectionId, channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSession(connectionId: string, channelId: number): void {
|
||||||
|
sessions.delete(makeSessionKey(connectionId, channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): StoredSession[] {
|
||||||
|
return Array.from(sessions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy list view expected shape
|
||||||
|
export type ShellWithConnection = StoredSession['shell'] & {
|
||||||
|
connection: SshConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listConnectionsWithShells(): (SshConnection & {
|
||||||
|
shells: StoredSession['shell'][];
|
||||||
|
})[] {
|
||||||
|
// Group shells by connection
|
||||||
|
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
|
||||||
|
for (const { connection, shell } of sessions.values()) {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience helpers for flows
|
||||||
|
export async function connectAndStart(
|
||||||
|
details: Parameters<typeof RnRussh.connect>[0],
|
||||||
|
) {
|
||||||
|
const conn = await RnRussh.connect(details);
|
||||||
|
const shell = await conn.startShell({ pty: 'Xterm' });
|
||||||
|
registerSession(conn, shell);
|
||||||
|
return { conn, shell };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeShell(connectionId: string, channelId: number) {
|
||||||
|
const sess = getSession(connectionId, channelId);
|
||||||
|
if (!sess) return;
|
||||||
|
await sess.shell.close();
|
||||||
|
removeSession(connectionId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectConnection(connectionId: string) {
|
||||||
|
const remaining = Array.from(sessions.entries()).filter(
|
||||||
|
([, v]) => v.connection.connectionId === connectionId,
|
||||||
|
);
|
||||||
|
for (const [key, sess] of remaining) {
|
||||||
|
try {
|
||||||
|
await sess.shell.close();
|
||||||
|
} catch {}
|
||||||
|
sessions.delete(key);
|
||||||
|
}
|
||||||
|
// Find one connection handle for this id to disconnect
|
||||||
|
const conn = remaining[0]?.[1].connection;
|
||||||
|
if (conn) {
|
||||||
|
try {
|
||||||
|
await conn.disconnect();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,19 +17,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/vercel": "^8.2.7",
|
"@astrojs/vercel": "^8.2.7",
|
||||||
"@fressh/assets": "workspace:*",
|
"@fressh/assets": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "4.1.9",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"astro": "^5.13.7",
|
"astro": "^5.13.7",
|
||||||
"tailwindcss": "^4.1.13"
|
"tailwindcss": "4.1.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@epic-web/config": "^1.21.3",
|
"@epic-web/config": "^1.21.3",
|
||||||
"@typescript-eslint/parser": "^8.43.0",
|
"@typescript-eslint/parser": "^8.44.0",
|
||||||
"eslint": "^9.35.0",
|
"eslint": "^9.35.0",
|
||||||
"eslint-plugin-astro": "^1.3.1",
|
"eslint-plugin-astro": "^1.3.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-astro": "0.14.1",
|
"prettier-plugin-astro": "0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14"
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
|
"vite": "6.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,35 +7,22 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::{Arc, Mutex, Weak};
|
use std::sync::{atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, Mutex, Weak};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH, Duration};
|
||||||
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::{broadcast, Mutex as AsyncMutex};
|
||||||
|
|
||||||
use russh::{self, client, ChannelMsg, Disconnect};
|
use russh::{self, client, ChannelMsg, Disconnect};
|
||||||
use russh::client::{Config as ClientConfig, Handle as ClientHandle};
|
use russh::client::{Config as ClientConfig, Handle as ClientHandle};
|
||||||
use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey};
|
use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey};
|
||||||
use russh_keys::ssh_key::{self, LineEnding};
|
use russh_keys::ssh_key::{self, LineEnding};
|
||||||
use once_cell::sync::Lazy;
|
use bytes::Bytes;
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
// Simpler aliases to satisfy clippy type-complexity.
|
// No global registries; handles are the only access points.
|
||||||
type ListenerEntry = (u64, Arc<dyn ChannelListener>);
|
|
||||||
type ListenerList = Vec<ListenerEntry>;
|
|
||||||
|
|
||||||
// Type aliases to keep static types simple and satisfy clippy.
|
|
||||||
type ConnectionId = String;
|
|
||||||
type ChannelId = u32;
|
|
||||||
type ShellKey = (ConnectionId, ChannelId);
|
|
||||||
type ConnMap = HashMap<ConnectionId, Arc<SSHConnection>>;
|
|
||||||
type ShellMap = HashMap<ShellKey, Arc<ShellSession>>;
|
|
||||||
|
|
||||||
// ---------- Global registries (strong references; lifecycle managed explicitly) ----------
|
|
||||||
static CONNECTIONS: Lazy<Mutex<ConnMap>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
|
||||||
static SHELLS: Lazy<Mutex<ShellMap>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
/// ---------- Types ----------
|
/// ---------- Types ----------
|
||||||
|
|
||||||
@@ -130,10 +117,30 @@ pub trait StatusListener: Send + Sync {
|
|||||||
fn on_change(&self, status: SSHConnectionStatus);
|
fn on_change(&self, status: SSHConnectionStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel data callback (stdout/stderr unified)
|
// Stream kind for terminal output
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum StreamKind { Stdout, Stderr }
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct TerminalChunk {
|
||||||
|
pub seq: u64,
|
||||||
|
pub t_ms: f64,
|
||||||
|
pub stream: StreamKind,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct DroppedRange { pub from_seq: u64, pub to_seq: u64 }
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum ShellEvent {
|
||||||
|
Chunk(TerminalChunk),
|
||||||
|
Dropped { from_seq: u64, to_seq: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
#[uniffi::export(with_foreign)]
|
#[uniffi::export(with_foreign)]
|
||||||
pub trait ChannelListener: Send + Sync {
|
pub trait ShellListener: Send + Sync {
|
||||||
fn on_data(&self, data: Vec<u8>);
|
fn on_event(&self, ev: ShellEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key types for generation
|
/// Key types for generation
|
||||||
@@ -152,6 +159,38 @@ pub struct StartShellOptions {
|
|||||||
pub on_status_change: Option<Arc<dyn StatusListener>>,
|
pub on_status_change: Option<Arc<dyn StatusListener>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum Cursor {
|
||||||
|
Head,
|
||||||
|
TailBytes { bytes: u64 },
|
||||||
|
Seq { seq: u64 },
|
||||||
|
TimeMs { t_ms: f64 },
|
||||||
|
Live,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct ListenerOptions {
|
||||||
|
pub cursor: Cursor,
|
||||||
|
pub coalesce_ms: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct BufferReadResult {
|
||||||
|
pub chunks: Vec<TerminalChunk>,
|
||||||
|
pub next_seq: u64,
|
||||||
|
pub dropped: Option<DroppedRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct BufferStats {
|
||||||
|
pub ring_bytes: u64,
|
||||||
|
pub used_bytes: u64,
|
||||||
|
pub chunks: u64,
|
||||||
|
pub head_seq: u64,
|
||||||
|
pub tail_seq: u64,
|
||||||
|
pub dropped_bytes_total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot of current connection info for property-like access in TS.
|
/// Snapshot of current connection info for property-like access in TS.
|
||||||
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
pub struct SshConnectionInfo {
|
pub struct SshConnectionInfo {
|
||||||
@@ -186,10 +225,6 @@ pub struct SSHConnection {
|
|||||||
|
|
||||||
// Weak self for child sessions to refer back without cycles.
|
// Weak self for child sessions to refer back without cycles.
|
||||||
self_weak: AsyncMutex<Weak<SSHConnection>>,
|
self_weak: AsyncMutex<Weak<SSHConnection>>,
|
||||||
|
|
||||||
// Data listeners for whatever shell is active. We track by id for removal.
|
|
||||||
listeners: Arc<Mutex<ListenerList>>,
|
|
||||||
next_listener_id: Arc<Mutex<u64>>, // simple counter guarded by same kind of mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
@@ -204,20 +239,44 @@ pub struct ShellSession {
|
|||||||
shell_status_listener: Option<Arc<dyn StatusListener>>,
|
shell_status_listener: Option<Arc<dyn StatusListener>>,
|
||||||
created_at_ms: f64,
|
created_at_ms: f64,
|
||||||
pty: PtyType,
|
pty: PtyType,
|
||||||
|
|
||||||
|
// Ring buffer
|
||||||
|
ring: Arc<Mutex<std::collections::VecDeque<Arc<Chunk>>>>,
|
||||||
|
ring_bytes_capacity: Arc<AtomicUsize>,
|
||||||
|
used_bytes: Arc<Mutex<usize>>,
|
||||||
|
dropped_bytes_total: Arc<AtomicU64>,
|
||||||
|
head_seq: Arc<AtomicU64>,
|
||||||
|
tail_seq: Arc<AtomicU64>,
|
||||||
|
|
||||||
|
// Live broadcast
|
||||||
|
sender: broadcast::Sender<Arc<Chunk>>,
|
||||||
|
|
||||||
|
// Listener tasks management
|
||||||
|
listener_tasks: Arc<Mutex<HashMap<u64, tokio::task::JoinHandle<()>>>>,
|
||||||
|
next_listener_id: AtomicU64,
|
||||||
|
default_coalesce_ms: AtomicU64,
|
||||||
|
rt_handle: tokio::runtime::Handle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SSHConnection {
|
impl fmt::Debug for SSHConnection {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
let listeners_len = self.listeners.lock().map(|v| v.len()).unwrap_or(0);
|
|
||||||
f.debug_struct("SSHConnection")
|
f.debug_struct("SSHConnection")
|
||||||
.field("connection_details", &self.connection_details)
|
.field("connection_details", &self.connection_details)
|
||||||
.field("created_at_ms", &self.created_at_ms)
|
.field("created_at_ms", &self.created_at_ms)
|
||||||
.field("tcp_established_at_ms", &self.tcp_established_at_ms)
|
.field("tcp_established_at_ms", &self.tcp_established_at_ms)
|
||||||
.field("listeners_len", &listeners_len)
|
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal chunk type kept in ring/broadcast
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Chunk {
|
||||||
|
seq: u64,
|
||||||
|
t_ms: f64,
|
||||||
|
stream: StreamKind,
|
||||||
|
bytes: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal client::Handler.
|
/// Minimal client::Handler.
|
||||||
struct NoopHandler;
|
struct NoopHandler;
|
||||||
impl client::Handler for NoopHandler {
|
impl client::Handler for NoopHandler {
|
||||||
@@ -247,21 +306,6 @@ impl SSHConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a channel listener and get an id you can later remove with.
|
|
||||||
pub fn add_channel_listener(&self, listener: Arc<dyn ChannelListener>) -> u64 {
|
|
||||||
let mut guard = self.listeners.lock().unwrap();
|
|
||||||
let mut id_guard = self.next_listener_id.lock().unwrap();
|
|
||||||
let id = *id_guard + 1;
|
|
||||||
*id_guard = id;
|
|
||||||
guard.push((id, listener));
|
|
||||||
id
|
|
||||||
}
|
|
||||||
pub fn remove_channel_listener(&self, id: u64) {
|
|
||||||
if let Ok(mut v) = self.listeners.lock() {
|
|
||||||
v.retain(|(lid, _)| *lid != id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a shell with the given PTY. Emits only Shell* statuses via options.on_status_change.
|
/// Start a shell with the given PTY. Emits only Shell* statuses via options.on_status_change.
|
||||||
pub async fn start_shell(&self, opts: StartShellOptions) -> Result<Arc<ShellSession>, SshError> {
|
pub async fn start_shell(&self, opts: StartShellOptions) -> Result<Arc<ShellSession>, SshError> {
|
||||||
// Prevent double-start (safe default).
|
// Prevent double-start (safe default).
|
||||||
@@ -281,29 +325,78 @@ impl SSHConnection {
|
|||||||
let channel_id: u32 = ch.id().into();
|
let channel_id: u32 = ch.id().into();
|
||||||
|
|
||||||
// Request PTY & shell.
|
// Request PTY & shell.
|
||||||
ch.request_pty(true, pty.as_ssh_name(), 80, 24, 0, 0, &[]).await?;
|
// Request a PTY with basic sane defaults: enable ECHO and set speeds.
|
||||||
|
// RFC4254 terminal mode opcodes: 53=ECHO, 128=TTY_OP_ISPEED, 129=TTY_OP_OSPEED
|
||||||
|
let modes: &[(russh::Pty, u32)] = &[
|
||||||
|
(russh::Pty::ECHO, 1),
|
||||||
|
(russh::Pty::ECHOK, 1),
|
||||||
|
(russh::Pty::ECHOE, 1),
|
||||||
|
(russh::Pty::ICANON, 1),
|
||||||
|
(russh::Pty::ISIG, 1),
|
||||||
|
(russh::Pty::ICRNL, 1),
|
||||||
|
(russh::Pty::ONLCR, 1),
|
||||||
|
(russh::Pty::TTY_OP_ISPEED, 38400),
|
||||||
|
(russh::Pty::TTY_OP_OSPEED, 38400),
|
||||||
|
];
|
||||||
|
ch.request_pty(true, pty.as_ssh_name(), 80, 24, 0, 0, modes).await?;
|
||||||
ch.request_shell(true).await?;
|
ch.request_shell(true).await?;
|
||||||
|
|
||||||
// Split for read/write; spawn reader.
|
// Split for read/write; spawn reader.
|
||||||
let (mut reader, writer) = ch.split();
|
let (mut reader, writer) = ch.split();
|
||||||
let listeners = self.listeners.clone();
|
|
||||||
|
// Setup ring + broadcast for this session
|
||||||
|
let (tx, _rx) = broadcast::channel::<Arc<Chunk>>(1024);
|
||||||
|
let ring = Arc::new(Mutex::new(std::collections::VecDeque::<Arc<Chunk>>::new()));
|
||||||
|
let used_bytes = Arc::new(Mutex::new(0usize));
|
||||||
|
let next_seq = Arc::new(AtomicU64::new(1));
|
||||||
|
let head_seq = Arc::new(AtomicU64::new(1));
|
||||||
|
let tail_seq = Arc::new(AtomicU64::new(0));
|
||||||
|
let dropped_bytes_total = Arc::new(AtomicU64::new(0));
|
||||||
|
let ring_bytes_capacity = Arc::new(AtomicUsize::new(2 * 1024 * 1024)); // default 2MiB
|
||||||
|
let default_coalesce_ms = AtomicU64::new(16); // default 16ms
|
||||||
|
|
||||||
|
let ring_clone = ring.clone();
|
||||||
|
let used_bytes_clone = used_bytes.clone();
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let ring_bytes_capacity_c = ring_bytes_capacity.clone();
|
||||||
|
let dropped_bytes_total_c = dropped_bytes_total.clone();
|
||||||
|
let head_seq_c = head_seq.clone();
|
||||||
|
let tail_seq_c = tail_seq.clone();
|
||||||
|
let next_seq_c = next_seq.clone();
|
||||||
let shell_listener_for_task = shell_status_listener.clone();
|
let shell_listener_for_task = shell_status_listener.clone();
|
||||||
let reader_task = tokio::spawn(async move {
|
let reader_task = tokio::spawn(async move {
|
||||||
|
let max_chunk = 16 * 1024; // 16KB
|
||||||
loop {
|
loop {
|
||||||
match reader.wait().await {
|
match reader.wait().await {
|
||||||
Some(ChannelMsg::Data { data }) => {
|
Some(ChannelMsg::Data { data }) => {
|
||||||
if let Ok(cl) = listeners.lock() {
|
append_and_broadcast(
|
||||||
let snapshot = cl.clone();
|
&data,
|
||||||
let buf = data.to_vec();
|
StreamKind::Stdout,
|
||||||
for (_, l) in snapshot { l.on_data(buf.clone()); }
|
&ring_clone,
|
||||||
}
|
&used_bytes_clone,
|
||||||
|
&ring_bytes_capacity_c,
|
||||||
|
&dropped_bytes_total_c,
|
||||||
|
&head_seq_c,
|
||||||
|
&tail_seq_c,
|
||||||
|
&next_seq_c,
|
||||||
|
&tx_clone,
|
||||||
|
max_chunk,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
||||||
if let Ok(cl) = listeners.lock() {
|
append_and_broadcast(
|
||||||
let snapshot = cl.clone();
|
&data,
|
||||||
let buf = data.to_vec();
|
StreamKind::Stderr,
|
||||||
for (_, l) in snapshot { l.on_data(buf.clone()); }
|
&ring_clone,
|
||||||
}
|
&used_bytes_clone,
|
||||||
|
&ring_bytes_capacity_c,
|
||||||
|
&dropped_bytes_total_c,
|
||||||
|
&head_seq_c,
|
||||||
|
&tail_seq_c,
|
||||||
|
&next_seq_c,
|
||||||
|
&tx_clone,
|
||||||
|
max_chunk,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::Close) | None => {
|
Some(ChannelMsg::Close) | None => {
|
||||||
if let Some(sl) = shell_listener_for_task.as_ref() {
|
if let Some(sl) = shell_listener_for_task.as_ref() {
|
||||||
@@ -311,7 +404,7 @@ impl SSHConnection {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => { /* ignore others */ }
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -324,16 +417,21 @@ impl SSHConnection {
|
|||||||
shell_status_listener,
|
shell_status_listener,
|
||||||
created_at_ms: now_ms(),
|
created_at_ms: now_ms(),
|
||||||
pty,
|
pty,
|
||||||
|
ring,
|
||||||
|
ring_bytes_capacity,
|
||||||
|
used_bytes,
|
||||||
|
dropped_bytes_total,
|
||||||
|
head_seq,
|
||||||
|
tail_seq,
|
||||||
|
sender: tx,
|
||||||
|
listener_tasks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
next_listener_id: AtomicU64::new(1),
|
||||||
|
default_coalesce_ms,
|
||||||
|
rt_handle: tokio::runtime::Handle::current(),
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.shell.lock().await = Some(session.clone());
|
*self.shell.lock().await = Some(session.clone());
|
||||||
|
|
||||||
// Register shell in global registry
|
|
||||||
if let Some(parent) = self.self_weak.lock().await.upgrade() {
|
|
||||||
let key = (parent.connection_id.clone(), channel_id);
|
|
||||||
if let Ok(mut map) = SHELLS.lock() { map.insert(key, session.clone()); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report ShellConnected.
|
// Report ShellConnected.
|
||||||
if let Some(sl) = session.shell_status_listener.as_ref() {
|
if let Some(sl) = session.shell_status_listener.as_ref() {
|
||||||
sl.on_change(SSHConnectionStatus::ShellConnected);
|
sl.on_change(SSHConnectionStatus::ShellConnected);
|
||||||
@@ -342,12 +440,7 @@ impl SSHConnection {
|
|||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send bytes to the active shell (stdin).
|
// Note: send_data now lives on ShellSession
|
||||||
pub async fn send_data(&self, data: Vec<u8>) -> Result<(), SshError> {
|
|
||||||
let guard = self.shell.lock().await;
|
|
||||||
let session = guard.as_ref().ok_or(SshError::Disconnected)?;
|
|
||||||
session.send_data(data).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// No exported close_shell: shell closure is handled via ShellSession::close()
|
// No exported close_shell: shell closure is handled via ShellSession::close()
|
||||||
|
|
||||||
@@ -360,8 +453,6 @@ impl SSHConnection {
|
|||||||
|
|
||||||
let h = self.handle.lock().await;
|
let h = self.handle.lock().await;
|
||||||
h.disconnect(Disconnect::ByApplication, "bye", "").await?;
|
h.disconnect(Disconnect::ByApplication, "bye", "").await?;
|
||||||
// Remove from registry after disconnect
|
|
||||||
if let Ok(mut map) = CONNECTIONS.lock() { map.remove(&self.connection_id); }
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +477,166 @@ impl ShellSession {
|
|||||||
|
|
||||||
/// Close the associated shell channel and stop its reader task.
|
/// Close the associated shell channel and stop its reader task.
|
||||||
pub async fn close(&self) -> Result<(), SshError> { self.close_internal().await }
|
pub async fn close(&self) -> Result<(), SshError> { self.close_internal().await }
|
||||||
|
|
||||||
|
/// Configure ring buffer policy.
|
||||||
|
pub async fn set_buffer_policy(&self, ring_bytes: Option<u64>, coalesce_ms: Option<u32>) {
|
||||||
|
if let Some(rb) = ring_bytes { self.ring_bytes_capacity.store(rb as usize, Ordering::Relaxed); self.evict_if_needed(); }
|
||||||
|
if let Some(cm) = coalesce_ms { self.default_coalesce_ms.store(cm as u64, Ordering::Relaxed); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer statistics snapshot.
|
||||||
|
pub fn buffer_stats(&self) -> BufferStats {
|
||||||
|
let used = *self.used_bytes.lock().unwrap_or_else(|p| p.into_inner()) as u64;
|
||||||
|
let chunks = match self.ring.lock() { Ok(q) => q.len() as u64, Err(p) => p.into_inner().len() as u64 };
|
||||||
|
BufferStats {
|
||||||
|
ring_bytes: self.ring_bytes_capacity.load(Ordering::Relaxed) as u64,
|
||||||
|
used_bytes: used,
|
||||||
|
chunks,
|
||||||
|
head_seq: self.head_seq.load(Ordering::Relaxed),
|
||||||
|
tail_seq: self.tail_seq.load(Ordering::Relaxed),
|
||||||
|
dropped_bytes_total: self.dropped_bytes_total.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current next sequence number.
|
||||||
|
pub fn current_seq(&self) -> u64 { self.tail_seq.load(Ordering::Relaxed).saturating_add(1) }
|
||||||
|
|
||||||
|
/// Read the ring buffer from a cursor.
|
||||||
|
pub fn read_buffer(&self, cursor: Cursor, max_bytes: Option<u64>) -> BufferReadResult {
|
||||||
|
let max_total = max_bytes.unwrap_or(512 * 1024) as usize; // default 512KB
|
||||||
|
let mut out_chunks: Vec<TerminalChunk> = Vec::new();
|
||||||
|
let mut dropped: Option<DroppedRange> = None;
|
||||||
|
let head_seq_now = self.head_seq.load(Ordering::Relaxed);
|
||||||
|
let tail_seq_now = self.tail_seq.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Lock ring to determine start and collect arcs, then drop lock.
|
||||||
|
let (_start_idx_unused, _start_seq, arcs): (usize, u64, Vec<Arc<Chunk>>) = {
|
||||||
|
let ring = match self.ring.lock() { Ok(g) => g, Err(p) => p.into_inner() };
|
||||||
|
let (start_seq, idx) = match cursor {
|
||||||
|
Cursor::Head => (head_seq_now, 0usize),
|
||||||
|
Cursor::Seq { seq: mut s } => {
|
||||||
|
if s < head_seq_now { dropped = Some(DroppedRange { from_seq: s, to_seq: head_seq_now - 1 }); s = head_seq_now; }
|
||||||
|
let idx = s.saturating_sub(head_seq_now) as usize;
|
||||||
|
(s, idx.min(ring.len()))
|
||||||
|
}
|
||||||
|
Cursor::TimeMs { t_ms: t } => {
|
||||||
|
// linear scan to find first chunk with t_ms >= t
|
||||||
|
let mut idx = 0usize; let mut s = head_seq_now;
|
||||||
|
for (i, ch) in ring.iter().enumerate() { if ch.t_ms >= t { idx = i; s = ch.seq; break; } }
|
||||||
|
(s, idx)
|
||||||
|
}
|
||||||
|
Cursor::TailBytes { bytes: n } => {
|
||||||
|
// Walk from tail backwards until approx n bytes, then forward.
|
||||||
|
let mut bytes = 0usize; let mut idx = ring.len();
|
||||||
|
for i in (0..ring.len()).rev() {
|
||||||
|
let b = ring[i].bytes.len();
|
||||||
|
if bytes >= n as usize { idx = i + 1; break; }
|
||||||
|
bytes += b; idx = i;
|
||||||
|
}
|
||||||
|
let s = if idx < ring.len() { ring[idx].seq } else { tail_seq_now.saturating_add(1) };
|
||||||
|
(s, idx)
|
||||||
|
}
|
||||||
|
Cursor::Live => (tail_seq_now.saturating_add(1), ring.len()),
|
||||||
|
};
|
||||||
|
let arcs: Vec<Arc<Chunk>> = ring.iter().skip(idx).cloned().collect();
|
||||||
|
(idx, start_seq, arcs)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build output respecting max_bytes
|
||||||
|
let mut total = 0usize;
|
||||||
|
for ch in arcs {
|
||||||
|
let len = ch.bytes.len();
|
||||||
|
if total + len > max_total { break; }
|
||||||
|
out_chunks.push(TerminalChunk { seq: ch.seq, t_ms: ch.t_ms, stream: ch.stream, bytes: ch.bytes.clone().to_vec() });
|
||||||
|
total += len;
|
||||||
|
}
|
||||||
|
let next_seq = if let Some(last) = out_chunks.last() { last.seq + 1 } else { tail_seq_now.saturating_add(1) };
|
||||||
|
BufferReadResult { chunks: out_chunks, next_seq, dropped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a listener with optional replay and live follow.
|
||||||
|
pub fn add_listener(&self, listener: Arc<dyn ShellListener>, opts: ListenerOptions) -> Result<u64, SshError> {
|
||||||
|
// Snapshot for replay; emit from task to avoid re-entrant callbacks during FFI.
|
||||||
|
let replay = self.read_buffer(opts.cursor.clone(), None);
|
||||||
|
let mut rx = self.sender.subscribe();
|
||||||
|
let id = self.next_listener_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
eprintln!("ShellSession.add_listener -> id={id}");
|
||||||
|
let default_coalesce_ms = self.default_coalesce_ms.load(Ordering::Relaxed) as u32;
|
||||||
|
let coalesce_ms = opts.coalesce_ms.unwrap_or(default_coalesce_ms);
|
||||||
|
|
||||||
|
let rt = self.rt_handle.clone();
|
||||||
|
let handle = rt.spawn(async move {
|
||||||
|
// Emit replay first
|
||||||
|
if let Some(dr) = replay.dropped.as_ref() {
|
||||||
|
listener.on_event(ShellEvent::Dropped { from_seq: dr.from_seq, to_seq: dr.to_seq });
|
||||||
|
}
|
||||||
|
for ch in replay.chunks.into_iter() {
|
||||||
|
listener.on_event(ShellEvent::Chunk(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_seq_seen: u64 = replay.next_seq.saturating_sub(1);
|
||||||
|
let mut acc: Vec<u8> = Vec::new();
|
||||||
|
let mut acc_stream: Option<StreamKind>;
|
||||||
|
let mut acc_last_seq: u64;
|
||||||
|
let mut acc_last_t: f64;
|
||||||
|
let window = Duration::from_millis(coalesce_ms as u64);
|
||||||
|
let mut pending_drop_from: Option<u64> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// First receive an item
|
||||||
|
let first = match rx.recv().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_n)) => { pending_drop_from = Some(last_seq_seen.saturating_add(1)); continue; }
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
};
|
||||||
|
if let Some(from) = pending_drop_from.take() {
|
||||||
|
if from <= first.seq.saturating_sub(1) {
|
||||||
|
listener.on_event(ShellEvent::Dropped { from_seq: from, to_seq: first.seq - 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Start accumulating
|
||||||
|
acc.clear(); acc_stream = Some(first.stream); acc_last_seq = first.seq; acc_last_t = first.t_ms; acc.extend_from_slice(&first.bytes);
|
||||||
|
last_seq_seen = first.seq;
|
||||||
|
|
||||||
|
// Drain within window while same stream
|
||||||
|
let mut deadline = tokio::time::Instant::now() + window;
|
||||||
|
loop {
|
||||||
|
let timeout = tokio::time::sleep_until(deadline);
|
||||||
|
tokio::pin!(timeout);
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut timeout => break,
|
||||||
|
msg = rx.recv() => {
|
||||||
|
match msg {
|
||||||
|
Ok(c) => {
|
||||||
|
if Some(c.stream) == acc_stream { acc.extend_from_slice(&c.bytes); acc_last_seq = c.seq; acc_last_t = c.t_ms; last_seq_seen = c.seq; }
|
||||||
|
else { // flush and start new
|
||||||
|
let chunk = TerminalChunk { seq: acc_last_seq, t_ms: acc_last_t, stream: acc_stream.unwrap_or(StreamKind::Stdout), bytes: std::mem::take(&mut acc) };
|
||||||
|
listener.on_event(ShellEvent::Chunk(chunk));
|
||||||
|
acc_stream = Some(c.stream); acc_last_seq = c.seq; acc_last_t = c.t_ms; acc.extend_from_slice(&c.bytes); last_seq_seen = c.seq;
|
||||||
|
deadline = tokio::time::Instant::now() + window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_n)) => { pending_drop_from = Some(last_seq_seen.saturating_add(1)); break; }
|
||||||
|
Err(broadcast::error::RecvError::Closed) => { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = acc_stream.take() {
|
||||||
|
let chunk = TerminalChunk { seq: acc_last_seq, t_ms: acc_last_t, stream: s, bytes: std::mem::take(&mut acc) };
|
||||||
|
listener.on_event(ShellEvent::Chunk(chunk));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Ok(mut map) = self.listener_tasks.lock() { map.insert(id, handle); }
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_listener(&self, id: u64) {
|
||||||
|
if let Ok(mut map) = self.listener_tasks.lock() {
|
||||||
|
if let Some(h) = map.remove(&id) { h.abort(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal lifecycle helpers (not exported via UniFFI)
|
// Internal lifecycle helpers (not exported via UniFFI)
|
||||||
@@ -403,13 +654,22 @@ impl ShellSession {
|
|||||||
if let Some(current) = guard.as_ref() {
|
if let Some(current) = guard.as_ref() {
|
||||||
if current.channel_id == self.channel_id { *guard = None; }
|
if current.channel_id == self.channel_id { *guard = None; }
|
||||||
}
|
}
|
||||||
// Remove from registry
|
|
||||||
if let Ok(mut map) = SHELLS.lock() {
|
|
||||||
map.remove(&(parent.connection_id.clone(), self.channel_id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evict_if_needed(&self) {
|
||||||
|
let cap = self.ring_bytes_capacity.load(Ordering::Relaxed);
|
||||||
|
let mut ring = match self.ring.lock() { Ok(g) => g, Err(p) => p.into_inner() };
|
||||||
|
let mut used = self.used_bytes.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
while *used > cap {
|
||||||
|
if let Some(front) = ring.pop_front() {
|
||||||
|
*used -= front.bytes.len();
|
||||||
|
self.dropped_bytes_total.fetch_add(front.bytes.len() as u64, Ordering::Relaxed);
|
||||||
|
self.head_seq.store(front.seq.saturating_add(1), Ordering::Relaxed);
|
||||||
|
} else { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ---------- Top-level API ----------
|
/// ---------- Top-level API ----------
|
||||||
@@ -459,57 +719,12 @@ pub async fn connect(options: ConnectOptions) -> Result<Arc<SSHConnection>, SshE
|
|||||||
handle: AsyncMutex::new(handle),
|
handle: AsyncMutex::new(handle),
|
||||||
shell: AsyncMutex::new(None),
|
shell: AsyncMutex::new(None),
|
||||||
self_weak: AsyncMutex::new(Weak::new()),
|
self_weak: AsyncMutex::new(Weak::new()),
|
||||||
listeners: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
next_listener_id: Arc::new(Mutex::new(0)),
|
|
||||||
});
|
});
|
||||||
// Initialize weak self reference.
|
// Initialize weak self reference.
|
||||||
*conn.self_weak.lock().await = Arc::downgrade(&conn);
|
*conn.self_weak.lock().await = Arc::downgrade(&conn);
|
||||||
// Register connection in global registry (strong ref; explicit lifecycle)
|
|
||||||
if let Ok(mut map) = CONNECTIONS.lock() { map.insert(conn.connection_id.clone(), conn.clone()); }
|
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ---------- Registry/listing API ----------
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn list_ssh_connections() -> Vec<SshConnectionInfo> {
|
|
||||||
// Collect clones outside the lock to avoid holding a MutexGuard across await
|
|
||||||
let conns: Vec<Arc<SSHConnection>> = CONNECTIONS
|
|
||||||
.lock()
|
|
||||||
.map(|map| map.values().cloned().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut out = Vec::with_capacity(conns.len());
|
|
||||||
for conn in conns { out.push(conn.info()); }
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn list_ssh_shells() -> Vec<ShellSessionInfo> {
|
|
||||||
// Collect shells outside the lock to avoid holding a MutexGuard across await
|
|
||||||
let shells: Vec<Arc<ShellSession>> = SHELLS
|
|
||||||
.lock()
|
|
||||||
.map(|map| map.values().cloned().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut out = Vec::with_capacity(shells.len());
|
|
||||||
for shell in shells { out.push(shell.info()); }
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn get_ssh_connection(id: String) -> Result<Arc<SSHConnection>, SshError> {
|
|
||||||
if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } }
|
|
||||||
Err(SshError::Disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// list_ssh_shells_for_connection removed; derive in JS from list_ssh_connections + get_ssh_shell
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn get_ssh_shell(connection_id: String, channel_id: u32) -> Result<Arc<ShellSession>, SshError> {
|
|
||||||
let key = (connection_id, channel_id);
|
|
||||||
if let Ok(map) = SHELLS.lock() { if let Some(shell) = map.get(&key) { return Ok(shell.clone()); } }
|
|
||||||
Err(SshError::Disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export(async_runtime = "tokio")]
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
pub async fn generate_key_pair(key_type: KeyType) -> Result<String, SshError> {
|
pub async fn generate_key_pair(key_type: KeyType) -> Result<String, SshError> {
|
||||||
let mut rng = OsRng;
|
let mut rng = OsRng;
|
||||||
@@ -532,3 +747,53 @@ fn now_ms() -> f64 {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
d.as_millis() as f64
|
d.as_millis() as f64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn append_and_broadcast(
|
||||||
|
data: &[u8],
|
||||||
|
stream: StreamKind,
|
||||||
|
ring: &Arc<Mutex<std::collections::VecDeque<Arc<Chunk>>>>,
|
||||||
|
used_bytes: &Arc<Mutex<usize>>,
|
||||||
|
ring_bytes_capacity: &Arc<AtomicUsize>,
|
||||||
|
dropped_bytes_total: &Arc<AtomicU64>,
|
||||||
|
head_seq: &Arc<AtomicU64>,
|
||||||
|
tail_seq: &Arc<AtomicU64>,
|
||||||
|
next_seq: &Arc<AtomicU64>,
|
||||||
|
sender: &broadcast::Sender<Arc<Chunk>>,
|
||||||
|
max_chunk: usize,
|
||||||
|
) {
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < data.len() {
|
||||||
|
let end = (offset + max_chunk).min(data.len());
|
||||||
|
let slice = &data[offset..end];
|
||||||
|
let seq = next_seq.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let t_ms = now_ms();
|
||||||
|
let chunk = Arc::new(Chunk { seq, t_ms, stream, bytes: Bytes::copy_from_slice(slice) });
|
||||||
|
// push to ring
|
||||||
|
{
|
||||||
|
let mut q = match ring.lock() { Ok(g) => g, Err(p) => p.into_inner() };
|
||||||
|
q.push_back(chunk.clone());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut used = used_bytes.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
*used += slice.len();
|
||||||
|
tail_seq.store(seq, Ordering::Relaxed);
|
||||||
|
// evict if needed
|
||||||
|
let cap = ring_bytes_capacity.load(Ordering::Relaxed);
|
||||||
|
if *used > cap {
|
||||||
|
let mut q = match ring.lock() { Ok(g) => g, Err(p) => p.into_inner() };
|
||||||
|
while *used > cap {
|
||||||
|
if let Some(front) = q.pop_front() {
|
||||||
|
*used -= front.bytes.len();
|
||||||
|
dropped_bytes_total.fetch_add(front.bytes.len() as u64, Ordering::Relaxed);
|
||||||
|
head_seq.store(front.seq.saturating_add(1), Ordering::Relaxed);
|
||||||
|
} else { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// broadcast
|
||||||
|
let _ = sender.send(chunk);
|
||||||
|
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 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 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.
|
||||||
|
* In practice it means that if you want to pass an object with callbacks and props to rust, it need to be in seperate args.
|
||||||
|
* If you want to pass an object with callbacks and props from rust to js (like ssh handles), you need to instead only pass an object with callbacks
|
||||||
|
* just make one of the callbacks a sync info() callback.
|
||||||
|
*
|
||||||
|
* Then in this api wrapper we can smooth over those rough edges.
|
||||||
* See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html
|
* See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html
|
||||||
*/
|
*/
|
||||||
import * as GeneratedRussh from './index';
|
import * as GeneratedRussh from './index';
|
||||||
@@ -9,6 +16,10 @@ import * as GeneratedRussh from './index';
|
|||||||
|
|
||||||
// #region Ideal API
|
// #region Ideal API
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Core types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ConnectionDetails = {
|
export type ConnectionDetails = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -18,6 +29,17 @@ export type ConnectionDetails = {
|
|||||||
| { type: 'key'; privateKey: string };
|
| { type: 'key'; privateKey: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SshConnectionStatus =
|
||||||
|
| 'tcpConnecting'
|
||||||
|
| 'tcpConnected'
|
||||||
|
| 'tcpDisconnected'
|
||||||
|
| 'shellConnecting'
|
||||||
|
| 'shellConnected'
|
||||||
|
| 'shellDisconnected';
|
||||||
|
|
||||||
|
export type PtyType =
|
||||||
|
| 'Vanilla' | 'Vt100' | 'Vt102' | 'Vt220' | 'Ansi' | 'Xterm' | 'Xterm256';
|
||||||
|
|
||||||
export type ConnectOptions = ConnectionDetails & {
|
export type ConnectOptions = ConnectionDetails & {
|
||||||
onStatusChange?: (status: SshConnectionStatus) => void;
|
onStatusChange?: (status: SshConnectionStatus) => void;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@@ -27,56 +49,97 @@ export type StartShellOptions = {
|
|||||||
pty: PtyType;
|
pty: PtyType;
|
||||||
onStatusChange?: (status: SshConnectionStatus) => void;
|
onStatusChange?: (status: SshConnectionStatus) => void;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export type StreamKind = 'stdout' | 'stderr';
|
||||||
|
|
||||||
|
export type TerminalChunk = {
|
||||||
|
/** Monotonic sequence number from the shell start (Rust u64; JS uses number). */
|
||||||
|
seq: number;
|
||||||
|
/** Milliseconds since UNIX epoch (double). */
|
||||||
|
tMs: number;
|
||||||
|
stream: StreamKind;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropNotice = { kind: 'dropped'; fromSeq: number; toSeq: number };
|
||||||
|
export type ListenerEvent = TerminalChunk | DropNotice;
|
||||||
|
|
||||||
|
export type Cursor =
|
||||||
|
| { mode: 'head' } // earliest available in ring
|
||||||
|
| { mode: 'tailBytes'; bytes: number } // last N bytes (best-effort)
|
||||||
|
| { mode: 'seq'; seq: number } // from a given sequence
|
||||||
|
| { mode: 'time'; tMs: number } // from timestamp
|
||||||
|
| { mode: 'live' }; // no replay, live only
|
||||||
|
|
||||||
|
export type ListenerOptions = {
|
||||||
|
cursor: Cursor;
|
||||||
|
/** Optional per-listener coalescing window in ms (e.g., 10–25). */
|
||||||
|
coalesceMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BufferStats = {
|
||||||
|
ringBytes: number; // configured capacity
|
||||||
|
usedBytes: number; // current usage
|
||||||
|
chunks: number; // chunks kept
|
||||||
|
headSeq: number; // oldest seq retained
|
||||||
|
tailSeq: number; // newest seq retained
|
||||||
|
droppedBytesTotal: number; // cumulative eviction
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BufferReadResult = {
|
||||||
|
chunks: TerminalChunk[];
|
||||||
|
nextSeq: number;
|
||||||
|
dropped?: { fromSeq: number; toSeq: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Handles
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SshConnection = {
|
export type SshConnection = {
|
||||||
connectionId: string;
|
readonly connectionId: string;
|
||||||
readonly createdAtMs: number;
|
readonly createdAtMs: number;
|
||||||
readonly tcpEstablishedAtMs: number;
|
readonly tcpEstablishedAtMs: number;
|
||||||
readonly connectionDetails: ConnectionDetails;
|
readonly connectionDetails: ConnectionDetails;
|
||||||
startShell: (params: StartShellOptions) => Promise<SshShellSession>;
|
|
||||||
addChannelListener: (listener: (data: ArrayBuffer) => void) => bigint;
|
startShell: (opts: StartShellOptions) => Promise<SshShell>;
|
||||||
removeChannelListener: (id: bigint) => void;
|
disconnect: (opts?: { signal?: AbortSignal }) => Promise<void>;
|
||||||
disconnect: (params?: { signal: AbortSignal }) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SshShellSession = {
|
export type SshShell = {
|
||||||
readonly channelId: number;
|
readonly channelId: number;
|
||||||
readonly createdAtMs: number;
|
readonly createdAtMs: number;
|
||||||
readonly pty: GeneratedRussh.PtyType;
|
readonly pty: PtyType;
|
||||||
readonly connectionId: string;
|
readonly connectionId: string;
|
||||||
sendData: (
|
|
||||||
data: ArrayBuffer,
|
// I/O
|
||||||
options?: { signal: AbortSignal }
|
sendData: (data: ArrayBuffer, opts?: { signal?: AbortSignal }) => Promise<void>;
|
||||||
) => Promise<void>;
|
close: (opts?: { signal?: AbortSignal }) => Promise<void>;
|
||||||
close: (params?: { signal: AbortSignal }) => Promise<void>;
|
|
||||||
|
// Buffer policy & stats
|
||||||
|
setBufferPolicy: (policy: { ringBytes?: number; coalesceMs?: number }) => Promise<void>;
|
||||||
|
bufferStats: () => Promise<BufferStats>;
|
||||||
|
currentSeq: () => Promise<number>;
|
||||||
|
|
||||||
|
// Replay + live
|
||||||
|
readBuffer: (cursor: Cursor, maxBytes?: number) => Promise<BufferReadResult>;
|
||||||
|
addListener: (
|
||||||
|
cb: (ev: ListenerEvent) => void,
|
||||||
|
opts: ListenerOptions
|
||||||
|
) => bigint;
|
||||||
|
removeListener: (id: bigint) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
type RusshApi = {
|
type RusshApi = {
|
||||||
connect: (options: ConnectOptions) => Promise<SshConnection>;
|
|
||||||
|
|
||||||
getSshConnection: (id: string) => SshConnection | undefined;
|
|
||||||
getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined;
|
|
||||||
listSshConnections: () => SshConnection[];
|
|
||||||
listSshShells: () => SshShellSession[];
|
|
||||||
listSshConnectionsWithShells: () => (SshConnection & { shells: SshShellSession[] })[];
|
|
||||||
|
|
||||||
generateKeyPair: (type: PrivateKeyType) => Promise<string>;
|
|
||||||
|
|
||||||
uniffiInitAsync: () => Promise<void>;
|
uniffiInitAsync: () => Promise<void>;
|
||||||
}
|
connect: (opts: ConnectOptions) => Promise<SshConnection>;
|
||||||
|
generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519') => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Weird stuff we have to do to get uniffi to have that ideal API
|
// #region Wrapper to match the ideal API
|
||||||
|
|
||||||
const privateKeyTypeLiteralToEnum = {
|
|
||||||
rsa: GeneratedRussh.KeyType.Rsa,
|
|
||||||
ecdsa: GeneratedRussh.KeyType.Ecdsa,
|
|
||||||
ed25519: GeneratedRussh.KeyType.Ed25519,
|
|
||||||
} as const satisfies Record<string, GeneratedRussh.KeyType>;
|
|
||||||
export type PrivateKeyType = keyof typeof privateKeyTypeLiteralToEnum;
|
|
||||||
|
|
||||||
|
|
||||||
const ptyTypeLiteralToEnum = {
|
const ptyTypeLiteralToEnum = {
|
||||||
Vanilla: GeneratedRussh.PtyType.Vanilla,
|
Vanilla: GeneratedRussh.PtyType.Vanilla,
|
||||||
@@ -87,8 +150,16 @@ const ptyTypeLiteralToEnum = {
|
|||||||
Xterm: GeneratedRussh.PtyType.Xterm,
|
Xterm: GeneratedRussh.PtyType.Xterm,
|
||||||
Xterm256: GeneratedRussh.PtyType.Xterm256,
|
Xterm256: GeneratedRussh.PtyType.Xterm256,
|
||||||
} as const satisfies Record<string, GeneratedRussh.PtyType>;
|
} as const satisfies Record<string, GeneratedRussh.PtyType>;
|
||||||
export type PtyType = keyof typeof ptyTypeLiteralToEnum;
|
|
||||||
|
|
||||||
|
const ptyEnumToLiteral: Record<GeneratedRussh.PtyType, PtyType> = {
|
||||||
|
[GeneratedRussh.PtyType.Vanilla]: 'Vanilla',
|
||||||
|
[GeneratedRussh.PtyType.Vt100]: 'Vt100',
|
||||||
|
[GeneratedRussh.PtyType.Vt102]: 'Vt102',
|
||||||
|
[GeneratedRussh.PtyType.Vt220]: 'Vt220',
|
||||||
|
[GeneratedRussh.PtyType.Ansi]: 'Ansi',
|
||||||
|
[GeneratedRussh.PtyType.Xterm]: 'Xterm',
|
||||||
|
[GeneratedRussh.PtyType.Xterm256]: 'Xterm256',
|
||||||
|
};
|
||||||
|
|
||||||
const sshConnStatusEnumToLiteral = {
|
const sshConnStatusEnumToLiteral = {
|
||||||
[GeneratedRussh.SshConnectionStatus.TcpConnecting]: 'tcpConnecting',
|
[GeneratedRussh.SshConnectionStatus.TcpConnecting]: 'tcpConnecting',
|
||||||
@@ -97,160 +168,158 @@ const sshConnStatusEnumToLiteral = {
|
|||||||
[GeneratedRussh.SshConnectionStatus.ShellConnecting]: 'shellConnecting',
|
[GeneratedRussh.SshConnectionStatus.ShellConnecting]: 'shellConnecting',
|
||||||
[GeneratedRussh.SshConnectionStatus.ShellConnected]: 'shellConnected',
|
[GeneratedRussh.SshConnectionStatus.ShellConnected]: 'shellConnected',
|
||||||
[GeneratedRussh.SshConnectionStatus.ShellDisconnected]: 'shellDisconnected',
|
[GeneratedRussh.SshConnectionStatus.ShellDisconnected]: 'shellDisconnected',
|
||||||
} as const satisfies Record<GeneratedRussh.SshConnectionStatus, string>;
|
} as const satisfies Record<GeneratedRussh.SshConnectionStatus, SshConnectionStatus>;
|
||||||
export type SshConnectionStatus = (typeof sshConnStatusEnumToLiteral)[keyof typeof sshConnStatusEnumToLiteral];
|
|
||||||
|
|
||||||
|
const streamEnumToLiteral = {
|
||||||
|
[GeneratedRussh.StreamKind.Stdout]: 'stdout',
|
||||||
|
[GeneratedRussh.StreamKind.Stderr]: 'stderr',
|
||||||
|
} as const satisfies Record<GeneratedRussh.StreamKind, StreamKind>;
|
||||||
|
|
||||||
function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails {
|
function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails {
|
||||||
|
const security: ConnectionDetails['security'] = details.security instanceof GeneratedRussh.Security.Password
|
||||||
|
? { type: 'password', password: details.security.inner.password }
|
||||||
|
: { type: 'key', privateKey: details.security.inner.keyId };
|
||||||
|
return { host: details.host, port: details.port, username: details.username, security };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
|
||||||
|
switch (cursor.mode) {
|
||||||
|
case 'head':
|
||||||
|
return new GeneratedRussh.Cursor.Head();
|
||||||
|
case 'tailBytes':
|
||||||
|
return new GeneratedRussh.Cursor.TailBytes({ bytes: BigInt(cursor.bytes) });
|
||||||
|
case 'seq':
|
||||||
|
return new GeneratedRussh.Cursor.Seq({ seq: BigInt(cursor.seq) });
|
||||||
|
case 'time':
|
||||||
|
return new GeneratedRussh.Cursor.TimeMs({ tMs: cursor.tMs });
|
||||||
|
case 'live':
|
||||||
|
return new GeneratedRussh.Cursor.Live();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTerminalChunk(ch: GeneratedRussh.TerminalChunk): TerminalChunk {
|
||||||
return {
|
return {
|
||||||
host: details.host,
|
seq: Number(ch.seq),
|
||||||
port: details.port,
|
tMs: ch.tMs,
|
||||||
username: details.username,
|
stream: streamEnumToLiteral[ch.stream],
|
||||||
security: details.security instanceof GeneratedRussh.Security.Password ? { type: 'password', password: details.security.inner.password } : { type: 'key', privateKey: details.security.inner.keyId },
|
bytes: new Uint8Array(ch.bytes as any),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection {
|
function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell {
|
||||||
// Wrap startShell in-place to preserve the UniFFI object's internal pointer.
|
|
||||||
const originalStartShell = conn.startShell.bind(conn);
|
|
||||||
const betterStartShell = async (params: StartShellOptions) => {
|
|
||||||
const shell = await originalStartShell(
|
|
||||||
{
|
|
||||||
pty: ptyTypeLiteralToEnum[params.pty],
|
|
||||||
onStatusChange: params.onStatusChange
|
|
||||||
? { onChange: (statusEnum) => params.onStatusChange?.(sshConnStatusEnumToLiteral[statusEnum]!) }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
params.abortSignal ? { signal: params.abortSignal } : undefined,
|
|
||||||
);
|
|
||||||
return wrapShellSession(shell);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accept a function for onData and adapt to the generated listener object.
|
|
||||||
const originalAddChannelListener = conn.addChannelListener.bind(conn);
|
|
||||||
const betterAddChannelListener = (listener: (data: ArrayBuffer) => void) =>
|
|
||||||
originalAddChannelListener({ onData: listener });
|
|
||||||
|
|
||||||
const connInfo = conn.info();
|
|
||||||
return {
|
|
||||||
connectionId: connInfo.connectionId,
|
|
||||||
connectionDetails: generatedConnDetailsToIdeal(connInfo.connectionDetails),
|
|
||||||
createdAtMs: connInfo.createdAtMs,
|
|
||||||
tcpEstablishedAtMs: connInfo.tcpEstablishedAtMs,
|
|
||||||
startShell: betterStartShell,
|
|
||||||
addChannelListener: betterAddChannelListener,
|
|
||||||
removeChannelListener: conn.removeChannelListener.bind(conn),
|
|
||||||
disconnect: conn.disconnect.bind(conn),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShellSession {
|
|
||||||
const info = shell.info();
|
const info = shell.info();
|
||||||
|
|
||||||
|
const setBufferPolicy: SshShell['setBufferPolicy'] = async (policy) => {
|
||||||
|
await shell.setBufferPolicy(policy.ringBytes != null ? BigInt(policy.ringBytes) : undefined, policy.coalesceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bufferStats: SshShell['bufferStats'] = async () => {
|
||||||
|
const s = shell.bufferStats();
|
||||||
|
return {
|
||||||
|
ringBytes: Number(s.ringBytes),
|
||||||
|
usedBytes: Number(s.usedBytes),
|
||||||
|
chunks: Number(s.chunks),
|
||||||
|
headSeq: Number(s.headSeq),
|
||||||
|
tailSeq: Number(s.tailSeq),
|
||||||
|
droppedBytesTotal: Number(s.droppedBytesTotal),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBuffer: SshShell['readBuffer'] = async (cursor, maxBytes) => {
|
||||||
|
const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes != null ? BigInt(maxBytes) : undefined);
|
||||||
|
return {
|
||||||
|
chunks: res.chunks.map(toTerminalChunk),
|
||||||
|
nextSeq: Number(res.nextSeq),
|
||||||
|
dropped: res.dropped ? { fromSeq: Number(res.dropped.fromSeq), toSeq: Number(res.dropped.toSeq) } : undefined,
|
||||||
|
} satisfies BufferReadResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addListener: SshShell['addListener'] = (cb, opts) => {
|
||||||
|
const listener = {
|
||||||
|
onEvent: (ev: GeneratedRussh.ShellEvent) => {
|
||||||
|
if (ev instanceof GeneratedRussh.ShellEvent.Chunk) {
|
||||||
|
cb(toTerminalChunk(ev.inner[0]!));
|
||||||
|
} else if (ev instanceof GeneratedRussh.ShellEvent.Dropped) {
|
||||||
|
cb({ kind: 'dropped', fromSeq: Number(ev.inner.fromSeq), toSeq: Number(ev.inner.toSeq) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies GeneratedRussh.ShellListener;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = shell.addListener(listener, { cursor: cursorToGenerated(opts.cursor), coalesceMs: opts.coalesceMs });
|
||||||
|
if (id === 0n) {
|
||||||
|
throw new Error('Failed to attach shell listener (id=0)');
|
||||||
|
}
|
||||||
|
return BigInt(id);
|
||||||
|
} catch (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: info.pty,
|
pty: ptyEnumToLiteral[info.pty],
|
||||||
connectionId: info.connectionId,
|
connectionId: info.connectionId,
|
||||||
sendData: shell.sendData.bind(shell),
|
sendData: (data, o) => shell.sendData(data, o?.signal ? { signal: o.signal } : undefined),
|
||||||
close: shell.close.bind(shell)
|
close: (o) => shell.close(o?.signal ? { signal: o.signal } : undefined),
|
||||||
|
setBufferPolicy,
|
||||||
|
bufferStats,
|
||||||
|
currentSeq: async () => Number(shell.currentSeq()),
|
||||||
|
readBuffer,
|
||||||
|
addListener,
|
||||||
|
removeListener: (id) => shell.removeListener(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection {
|
||||||
|
const inf = conn.info();
|
||||||
|
return {
|
||||||
|
connectionId: inf.connectionId,
|
||||||
|
connectionDetails: generatedConnDetailsToIdeal(inf.connectionDetails),
|
||||||
|
createdAtMs: inf.createdAtMs,
|
||||||
|
tcpEstablishedAtMs: inf.tcpEstablishedAtMs,
|
||||||
|
startShell: async (params) => {
|
||||||
|
const shell = await conn.startShell(
|
||||||
|
{
|
||||||
|
pty: ptyTypeLiteralToEnum[params.pty],
|
||||||
|
onStatusChange: params.onStatusChange
|
||||||
|
? { onChange: (statusEnum) => params.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
params.abortSignal ? { signal: params.abortSignal } : undefined,
|
||||||
|
);
|
||||||
|
return wrapShellSession(shell);
|
||||||
|
},
|
||||||
|
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({
|
? new GeneratedRussh.Security.Password({ password: options.security.password })
|
||||||
password: options.security.password,
|
|
||||||
})
|
|
||||||
: new GeneratedRussh.Security.Key({ keyId: options.security.privateKey });
|
: new GeneratedRussh.Security.Key({ keyId: options.security.privateKey });
|
||||||
const sshConnectionInterface = await GeneratedRussh.connect(
|
const sshConnection = await GeneratedRussh.connect(
|
||||||
{
|
{
|
||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
username: options.username,
|
username: options.username,
|
||||||
security,
|
security,
|
||||||
onStatusChange: options.onStatusChange ? {
|
onStatusChange: options.onStatusChange ? {
|
||||||
onChange: (statusEnum) => {
|
onChange: (statusEnum) => options.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum])
|
||||||
const tsLiteral = sshConnStatusEnumToLiteral[statusEnum];
|
|
||||||
if (!tsLiteral) throw new Error(`Invalid status enum: ${statusEnum}`);
|
|
||||||
options.onStatusChange?.(tsLiteral);
|
|
||||||
},
|
|
||||||
} : undefined,
|
} : undefined,
|
||||||
},
|
},
|
||||||
options.abortSignal
|
options.abortSignal ? { signal: options.abortSignal } : undefined,
|
||||||
? {
|
|
||||||
signal: options.abortSignal,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
return wrapConnection(sshConnectionInterface);
|
return wrapConnection(sshConnection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional registry lookups: return undefined if not found/disconnected
|
async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') {
|
||||||
function getSshConnection(id: string): SshConnection | undefined {
|
const map = { rsa: GeneratedRussh.KeyType.Rsa, ecdsa: GeneratedRussh.KeyType.Ecdsa, ed25519: GeneratedRussh.KeyType.Ed25519 } as const;
|
||||||
try {
|
return GeneratedRussh.generateKeyPair(map[type]);
|
||||||
const conn = GeneratedRussh.getSshConnection(id);
|
|
||||||
return wrapConnection(conn);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSshShell(connectionId: string, channelId: number): SshShellSession | undefined {
|
|
||||||
try {
|
|
||||||
const shell = GeneratedRussh.getSshShell(connectionId, channelId);
|
|
||||||
return wrapShellSession(shell);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listSshConnections(): SshConnection[] {
|
|
||||||
const infos = GeneratedRussh.listSshConnections();
|
|
||||||
const out: SshConnection[] = [];
|
|
||||||
for (const info of infos) {
|
|
||||||
try {
|
|
||||||
const conn = GeneratedRussh.getSshConnection(info.connectionId);
|
|
||||||
out.push(wrapConnection(conn));
|
|
||||||
} catch {
|
|
||||||
// ignore entries that no longer exist between snapshot and lookup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listSshShells(): SshShellSession[] {
|
|
||||||
const infos = GeneratedRussh.listSshShells();
|
|
||||||
const out: SshShellSession[] = [];
|
|
||||||
for (const info of infos) {
|
|
||||||
try {
|
|
||||||
const shell = GeneratedRussh.getSshShell(info.connectionId, info.channelId);
|
|
||||||
out.push(wrapShellSession(shell));
|
|
||||||
} catch {
|
|
||||||
// ignore entries that no longer exist between snapshot and lookup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: This feels a bit hacky. It is probably more effecient to do this join in rust and send
|
|
||||||
* the joined result to the app.
|
|
||||||
*/
|
|
||||||
function listSshConnectionsWithShells(): (SshConnection & { shells: SshShellSession[] })[] {
|
|
||||||
const connections = listSshConnections();
|
|
||||||
const shells = listSshShells();
|
|
||||||
return connections.map(connection => ({
|
|
||||||
...connection,
|
|
||||||
shells: shells.filter(shell => shell.connectionId === connection.connectionId),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function generateKeyPair(type: PrivateKeyType) {
|
|
||||||
return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -258,9 +327,4 @@ export const RnRussh = {
|
|||||||
uniffiInitAsync: GeneratedRussh.uniffiInitAsync,
|
uniffiInitAsync: GeneratedRussh.uniffiInitAsync,
|
||||||
connect,
|
connect,
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
getSshConnection,
|
|
||||||
listSshConnections,
|
|
||||||
listSshShells,
|
|
||||||
listSshConnectionsWithShells,
|
|
||||||
getSshShell,
|
|
||||||
} satisfies RusshApi;
|
} satisfies RusshApi;
|
||||||
|
|||||||
25
packages/react-native-xtermjs-webview/.gitignore
vendored
Normal file
25
packages/react-native-xtermjs-webview/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-internal
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
77
packages/react-native-xtermjs-webview/README.md
Normal file
77
packages/react-native-xtermjs-webview/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and
|
||||||
|
some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react)
|
||||||
|
uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc)
|
||||||
|
uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the
|
||||||
|
configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install
|
||||||
|
[eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x)
|
||||||
|
and
|
||||||
|
[eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom)
|
||||||
|
for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x';
|
||||||
|
import reactDom from 'eslint-plugin-react-dom';
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
27
packages/react-native-xtermjs-webview/eslint.config.js
Normal file
27
packages/react-native-xtermjs-webview/eslint.config.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import { globalIgnores, defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
13
packages/react-native-xtermjs-webview/index.html
Normal file
13
packages/react-native-xtermjs-webview/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||||
|
<div
|
||||||
|
id="terminal"
|
||||||
|
style="margin: 0; padding: 0; width: 100%; height: 100%"
|
||||||
|
></div>
|
||||||
|
<script type="module" src="/src-internal/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
packages/react-native-xtermjs-webview/package.json
Normal file
52
packages/react-native-xtermjs-webview/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "@fressh/react-native-xtermjs-webview",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build:main": "tsc -b && vite build",
|
||||||
|
"build:internal": "tsc -b && vite build --config vite.config.internal.ts",
|
||||||
|
"fmt:check": "cross-env SORT_IMPORTS=true prettier --check .",
|
||||||
|
"fmt": "cross-env SORT_IMPORTS=true prettier --write .",
|
||||||
|
"eslint:check": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint --fix --report-unused-disable-directives --max-warnings 0 .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"js-base64": "^3.7.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native-webview": "13.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@epic-web/config": "^1.21.3",
|
||||||
|
"@eslint/js": "^9.35.0",
|
||||||
|
"@types/react": "~19.1.12",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"js-base64": "^3.7.8",
|
||||||
|
"eslint": "^9.35.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"vite-plugin-singlefile": "^2.3.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native-webview": "13.15.0",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "^8.44.0",
|
||||||
|
"vite": "6.3.6",
|
||||||
|
"vite-plugin-dts": "^4.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/react-native-xtermjs-webview/prettier.config.mjs
Normal file
14
packages/react-native-xtermjs-webview/prettier.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import epicConfig from '@epic-web/config/prettier';
|
||||||
|
// Sometimes this plugin can remove imports that are being edited.
|
||||||
|
// As a workaround we will only use this in the cli. (pnpm run fmt)
|
||||||
|
const sortImports = process.env.SORT_IMPORTS === 'true-never';
|
||||||
|
|
||||||
|
/** @type {import("prettier").Options} */
|
||||||
|
export default {
|
||||||
|
...epicConfig,
|
||||||
|
semi: true,
|
||||||
|
plugins: [
|
||||||
|
...(sortImports ? ['prettier-plugin-organize-imports'] : []),
|
||||||
|
...(epicConfig.plugins || []),
|
||||||
|
],
|
||||||
|
};
|
||||||
193
packages/react-native-xtermjs-webview/src-internal/main.tsx
Normal file
193
packages/react-native-xtermjs-webview/src-internal/main.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { Base64 } from 'js-base64';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
terminal?: Terminal;
|
||||||
|
fitAddon?: FitAddon;
|
||||||
|
terminalWriteBase64?: (data: string) => void;
|
||||||
|
ReactNativeWebView?: { postMessage?: (data: string) => void };
|
||||||
|
__FRESSH_XTERM_BRIDGE__?: boolean;
|
||||||
|
__FRESSH_XTERM_MSG_HANDLER__?: (e: MessageEvent<string>) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post typed messages to React Native
|
||||||
|
*/
|
||||||
|
const post = (msg: unknown) =>
|
||||||
|
window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent boot guard: ensure we only install once.
|
||||||
|
* If the script happens to run twice (dev reloads, double-mounts), we bail out early.
|
||||||
|
*/
|
||||||
|
if (window.__FRESSH_XTERM_BRIDGE__) {
|
||||||
|
post({
|
||||||
|
type: 'debug',
|
||||||
|
message: 'bridge already installed; ignoring duplicate boot',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.__FRESSH_XTERM_BRIDGE__ = true;
|
||||||
|
|
||||||
|
// ---- Xterm setup
|
||||||
|
const term = new Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
convertEol: true,
|
||||||
|
scrollback: 10000,
|
||||||
|
cursorBlink: true,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
const root = document.getElementById('terminal')!;
|
||||||
|
term.open(root);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
// Expose for debugging (typed)
|
||||||
|
window.terminal = term;
|
||||||
|
window.fitAddon = fitAddon;
|
||||||
|
|
||||||
|
// Encode helper
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
// Initial handshake (send once)
|
||||||
|
setTimeout(() => post({ type: 'initialized' }), 500);
|
||||||
|
|
||||||
|
// User input from xterm -> RN (SSH) as UTF-8 bytes (Base64)
|
||||||
|
term.onData((data /* string */) => {
|
||||||
|
const bytes = enc.encode(data);
|
||||||
|
const b64 = Base64.fromUint8Array(bytes);
|
||||||
|
post({ type: 'input', b64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old handler if any (just in case)
|
||||||
|
if (window.__FRESSH_XTERM_MSG_HANDLER__) {
|
||||||
|
window.removeEventListener('message', window.__FRESSH_XTERM_MSG_HANDLER__!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus)
|
||||||
|
const handler = (e: MessageEvent<string>) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data) as
|
||||||
|
| { type: 'write'; b64?: string; chunks?: string[] }
|
||||||
|
| { type: 'resize'; cols?: number; rows?: number }
|
||||||
|
| { type: 'setFont'; family?: string; size?: number }
|
||||||
|
| { type: 'setTheme'; background?: string; foreground?: string }
|
||||||
|
| {
|
||||||
|
type: 'setOptions';
|
||||||
|
opts: Partial<{
|
||||||
|
cursorBlink: boolean;
|
||||||
|
scrollback: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| { type: 'clear' }
|
||||||
|
| { type: 'focus' };
|
||||||
|
|
||||||
|
if (!msg || typeof msg.type !== 'string') return;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'write': {
|
||||||
|
if (typeof msg.b64 === 'string') {
|
||||||
|
const bytes = Base64.toUint8Array(msg.b64);
|
||||||
|
term.write(bytes);
|
||||||
|
post({ type: 'debug', message: `write(bytes=${bytes.length})` });
|
||||||
|
} else if (Array.isArray(msg.chunks)) {
|
||||||
|
for (const b64 of msg.chunks) {
|
||||||
|
const bytes = Base64.toUint8Array(b64);
|
||||||
|
term.write(bytes);
|
||||||
|
}
|
||||||
|
post({
|
||||||
|
type: 'debug',
|
||||||
|
message: `write(chunks=${msg.chunks.length})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'resize': {
|
||||||
|
if (typeof msg.cols === 'number' && typeof msg.rows === 'number') {
|
||||||
|
term.resize(msg.cols, msg.rows);
|
||||||
|
post({ type: 'debug', message: `resize(${msg.cols}x${msg.rows})` });
|
||||||
|
}
|
||||||
|
fitAddon.fit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'setFont': {
|
||||||
|
const { family, size } = msg;
|
||||||
|
const patch: Partial<import('@xterm/xterm').ITerminalOptions> = {};
|
||||||
|
if (family) patch.fontFamily = family;
|
||||||
|
if (typeof size === 'number') patch.fontSize = size;
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
term.options = patch; // never spread existing options (avoids cols/rows setters)
|
||||||
|
post({
|
||||||
|
type: 'debug',
|
||||||
|
message: `setFont(${family ?? ''}, ${size ?? ''})`,
|
||||||
|
});
|
||||||
|
fitAddon.fit();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'setTheme': {
|
||||||
|
const { background, foreground } = msg;
|
||||||
|
const theme: Partial<import('@xterm/xterm').ITheme> = {};
|
||||||
|
if (background) {
|
||||||
|
theme.background = background;
|
||||||
|
document.body.style.backgroundColor = background;
|
||||||
|
}
|
||||||
|
if (foreground) theme.foreground = foreground;
|
||||||
|
if (Object.keys(theme).length) {
|
||||||
|
term.options = { theme }; // set only theme
|
||||||
|
post({
|
||||||
|
type: 'debug',
|
||||||
|
message: `setTheme(bg=${background ?? ''}, fg=${foreground ?? ''})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'setOptions': {
|
||||||
|
const opts = msg.opts ?? {};
|
||||||
|
const { cursorBlink, scrollback, fontFamily, fontSize } = opts;
|
||||||
|
const patch: Partial<import('@xterm/xterm').ITerminalOptions> = {};
|
||||||
|
if (typeof cursorBlink === 'boolean') patch.cursorBlink = cursorBlink;
|
||||||
|
if (typeof scrollback === 'number') patch.scrollback = scrollback;
|
||||||
|
if (fontFamily) patch.fontFamily = fontFamily;
|
||||||
|
if (typeof fontSize === 'number') patch.fontSize = fontSize;
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
term.options = patch;
|
||||||
|
post({
|
||||||
|
type: 'debug',
|
||||||
|
message: `setOptions(${Object.keys(patch).join(',')})`,
|
||||||
|
});
|
||||||
|
if (patch.fontFamily || patch.fontSize) fitAddon.fit();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clear': {
|
||||||
|
term.clear();
|
||||||
|
post({ type: 'debug', message: 'clear()' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'focus': {
|
||||||
|
term.focus();
|
||||||
|
post({ type: 'debug', message: 'focus()' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
post({ type: 'debug', message: `message handler error: ${String(err)}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__FRESSH_XTERM_MSG_HANDLER__ = handler;
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
}
|
||||||
1
packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
vendored
Normal file
1
packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
213
packages/react-native-xtermjs-webview/src/index.tsx
Normal file
213
packages/react-native-xtermjs-webview/src/index.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React, { useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { WebView } from 'react-native-webview';
|
||||||
|
import htmlString from '../dist-internal/index.html?raw';
|
||||||
|
import { Base64 } from 'js-base64';
|
||||||
|
|
||||||
|
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
|
||||||
|
|
||||||
|
type InboundMessage =
|
||||||
|
| { type: 'initialized' }
|
||||||
|
| { type: 'input'; b64: string } // user typed data from xterm -> RN
|
||||||
|
| { type: 'debug'; message: string };
|
||||||
|
|
||||||
|
type OutboundMessage =
|
||||||
|
| { type: 'write'; b64: string }
|
||||||
|
| { type: 'write'; chunks: string[] }
|
||||||
|
| { type: 'resize'; cols?: number; rows?: number }
|
||||||
|
| { type: 'setFont'; family?: string; size?: number }
|
||||||
|
| { type: 'setTheme'; background?: string; foreground?: string }
|
||||||
|
| {
|
||||||
|
type: 'setOptions';
|
||||||
|
opts: Partial<{
|
||||||
|
cursorBlink: boolean;
|
||||||
|
scrollback: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| { type: 'clear' }
|
||||||
|
| { type: 'focus' };
|
||||||
|
|
||||||
|
export type XtermInbound =
|
||||||
|
| { type: 'initialized' }
|
||||||
|
| { type: 'data'; data: Uint8Array }
|
||||||
|
| { type: 'debug'; message: string };
|
||||||
|
|
||||||
|
export type XtermWebViewHandle = {
|
||||||
|
write: (data: Uint8Array) => void; // bytes in (batched)
|
||||||
|
// Efficiently write many chunks in one postMessage (for initial replay)
|
||||||
|
writeMany: (chunks: Uint8Array[]) => void;
|
||||||
|
flush: () => void; // force-flush outgoing writes
|
||||||
|
resize: (cols?: number, rows?: number) => void;
|
||||||
|
setFont: (family?: string, size?: number) => void;
|
||||||
|
setTheme: (background?: string, foreground?: string) => void;
|
||||||
|
setOptions: (
|
||||||
|
opts: OutboundMessage extends { type: 'setOptions'; opts: infer O }
|
||||||
|
? O
|
||||||
|
: never,
|
||||||
|
) => void;
|
||||||
|
clear: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface XtermJsWebViewProps
|
||||||
|
extends StrictOmit<
|
||||||
|
React.ComponentProps<typeof WebView>,
|
||||||
|
'source' | 'originWhitelist' | 'onMessage'
|
||||||
|
> {
|
||||||
|
ref: React.RefObject<XtermWebViewHandle | null>;
|
||||||
|
onMessage?: (msg: XtermInbound) => void;
|
||||||
|
|
||||||
|
// xterm-ish props
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
cursorBlink?: boolean;
|
||||||
|
scrollback?: number;
|
||||||
|
themeBackground?: string;
|
||||||
|
themeForeground?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function XtermJsWebView({
|
||||||
|
ref,
|
||||||
|
onMessage,
|
||||||
|
fontFamily,
|
||||||
|
fontSize,
|
||||||
|
cursorBlink,
|
||||||
|
scrollback,
|
||||||
|
themeBackground,
|
||||||
|
themeForeground,
|
||||||
|
...props
|
||||||
|
}: XtermJsWebViewProps) {
|
||||||
|
const webRef = useRef<WebView>(null);
|
||||||
|
|
||||||
|
// ---- RN -> WebView message sender
|
||||||
|
const send = (obj: OutboundMessage) => {
|
||||||
|
const payload = JSON.stringify(obj);
|
||||||
|
console.log('sending msg', payload);
|
||||||
|
const js = `window.dispatchEvent(new MessageEvent('message',{data:${JSON.stringify(
|
||||||
|
payload,
|
||||||
|
)}})); true;`;
|
||||||
|
webRef.current?.injectJavaScript(js);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- rAF + 8KB coalescer for writes
|
||||||
|
const bufRef = useRef<Uint8Array | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const THRESHOLD = 8 * 1024;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!bufRef.current) return;
|
||||||
|
const b64 = Base64.fromUint8Array(bufRef.current);
|
||||||
|
bufRef.current = null;
|
||||||
|
if (rafRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
send({ type: 'write', b64 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
flush();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (data: Uint8Array) => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
if (!bufRef.current) {
|
||||||
|
bufRef.current = data;
|
||||||
|
} else {
|
||||||
|
const a = bufRef.current;
|
||||||
|
const merged = new Uint8Array(a.length + data.length);
|
||||||
|
merged.set(a, 0);
|
||||||
|
merged.set(data, a.length);
|
||||||
|
bufRef.current = merged;
|
||||||
|
}
|
||||||
|
if ((bufRef.current?.length ?? 0) >= THRESHOLD) flush();
|
||||||
|
else schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeMany = (chunks: Uint8Array[]) => {
|
||||||
|
if (!chunks || chunks.length === 0) return;
|
||||||
|
// Ensure any pending small buffered write is flushed before bulk write
|
||||||
|
flush();
|
||||||
|
const b64s = chunks.map((c) => Base64.fromUint8Array(c));
|
||||||
|
send({ type: 'write', chunks: b64s });
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
write,
|
||||||
|
writeMany,
|
||||||
|
flush,
|
||||||
|
resize: (cols?: number, rows?: number) =>
|
||||||
|
send({ type: 'resize', cols, rows }),
|
||||||
|
setFont: (family?: string, size?: number) =>
|
||||||
|
send({ type: 'setFont', family, size }),
|
||||||
|
setTheme: (background?: string, foreground?: string) =>
|
||||||
|
send({ type: 'setTheme', background, foreground }),
|
||||||
|
setOptions: (opts) => send({ type: 'setOptions', opts }),
|
||||||
|
clear: () => send({ type: 'clear' }),
|
||||||
|
focus: () => send({ type: 'focus' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cleanup pending rAF on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
bufRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Push initial options/theme whenever props change
|
||||||
|
useEffect(() => {
|
||||||
|
const opts: Record<string, unknown> = {};
|
||||||
|
if (typeof cursorBlink === 'boolean') opts.cursorBlink = cursorBlink;
|
||||||
|
if (typeof scrollback === 'number') opts.scrollback = scrollback;
|
||||||
|
if (fontFamily) opts.fontFamily = fontFamily;
|
||||||
|
if (typeof fontSize === 'number') opts.fontSize = fontSize;
|
||||||
|
if (Object.keys(opts).length) send({ type: 'setOptions', opts });
|
||||||
|
}, [cursorBlink, scrollback, fontFamily, fontSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeBackground || themeForeground) {
|
||||||
|
send({
|
||||||
|
type: 'setTheme',
|
||||||
|
background: themeBackground,
|
||||||
|
foreground: themeForeground,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [themeBackground, themeForeground]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebView
|
||||||
|
ref={webRef}
|
||||||
|
originWhitelist={['*']}
|
||||||
|
source={{ html: htmlString }}
|
||||||
|
onMessage={(e) => {
|
||||||
|
try {
|
||||||
|
const msg: InboundMessage = JSON.parse(e.nativeEvent.data);
|
||||||
|
console.log('received msg', msg);
|
||||||
|
if (msg.type === 'initialized') {
|
||||||
|
onMessage?.({ type: 'initialized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'input') {
|
||||||
|
const bytes = Base64.toUint8Array(msg.b64);
|
||||||
|
onMessage?.({ type: 'data', data: bytes });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'debug') {
|
||||||
|
onMessage?.({ type: 'debug', message: msg.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unknown payloads
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
packages/react-native-xtermjs-webview/src/vite-env.d.ts
vendored
Normal file
1
packages/react-native-xtermjs-webview/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app-internal.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src-internal"]
|
||||||
|
}
|
||||||
27
packages/react-native-xtermjs-webview/tsconfig.app.json
Normal file
27
packages/react-native-xtermjs-webview/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
8
packages/react-native-xtermjs-webview/tsconfig.json
Normal file
8
packages/react-native-xtermjs-webview/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.app-internal.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
packages/react-native-xtermjs-webview/tsconfig.node.json
Normal file
25
packages/react-native-xtermjs-webview/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vite.config.internal.ts"]
|
||||||
|
}
|
||||||
19
packages/react-native-xtermjs-webview/turbo.json
Normal file
19
packages/react-native-xtermjs-webview/turbo.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["build:internal", "build:main"]
|
||||||
|
},
|
||||||
|
"build:main": {
|
||||||
|
"inputs": ["src/**"],
|
||||||
|
"dependsOn": ["build:internal"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"build:internal": {
|
||||||
|
"inputs": ["src-internal/**"],
|
||||||
|
"outputs": ["dist-internal/**"]
|
||||||
|
},
|
||||||
|
"lint": {},
|
||||||
|
"lint:check": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [viteSingleFile()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-internal',
|
||||||
|
},
|
||||||
|
});
|
||||||
37
packages/react-native-xtermjs-webview/vite.config.ts
Normal file
37
packages/react-native-xtermjs-webview/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import dts from 'vite-plugin-dts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
tsconfigPath: './tsconfig.app.json',
|
||||||
|
// This makes dist/ look nice but breaks Cmd + Click
|
||||||
|
rollupTypes: false,
|
||||||
|
// We need this or the types defined in package.json will be missing
|
||||||
|
// If rollupTypes is true, this is forced true
|
||||||
|
insertTypesEntry: true,
|
||||||
|
compilerOptions: {
|
||||||
|
// This allows Cmd + Click from different packages in the monorepo
|
||||||
|
declarationMap: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react/jsx-runtime', 'react-native-webview'],
|
||||||
|
// external: () => {
|
||||||
|
// fs.writeFileSync('dep.log', `${dep}\n`, { flag: 'a' });
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.tsx'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'index.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1406
pnpm-lock.yaml
generated
1406
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user