some things working

This commit is contained in:
EthanShoeDev
2025-09-17 16:44:16 -04:00
parent b3eb42c348
commit 86ff6762a3
8 changed files with 125 additions and 189 deletions

View File

@@ -28,6 +28,7 @@
"@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",
@@ -48,13 +49,13 @@
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.8", "expo-image": "~3.0.8",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"@fressh/react-native-xtermjs-webview": "workspace:*",
"expo-router": "6.0.6", "expo-router": "6.0.6",
"expo-secure-store": "~15.0.7", "expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"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",

View File

@@ -5,22 +5,15 @@ import {
type XtermWebViewHandle, type XtermWebViewHandle,
} from '@fressh/react-native-xtermjs-webview'; } from '@fressh/react-native-xtermjs-webview';
import { useQueryClient } from '@tanstack/react-query';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react'; import PQueue from 'p-queue';
import { import React, { useEffect, useRef } from 'react';
Platform, import { Pressable, View } from 'react-native';
Pressable,
ScrollView,
Text,
TextInput,
View,
} 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 { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
const renderer: 'xtermjs' | 'rn-text' = 'xtermjs';
const decoder = new TextDecoder('utf-8');
export default function TabsShellDetail() { export default function TabsShellDetail() {
return <ShellDetail />; return <ShellDetail />;
} }
@@ -43,29 +36,42 @@ function ShellDetail() {
? RnRussh.getSshShell(String(connectionId), channelIdNum) ? RnRussh.getSshShell(String(connectionId), channelIdNum)
: undefined; : undefined;
const [shellData, setShellData] = useState(''); function sendDataToXterm(data: ArrayBuffer) {
try {
const bytes = new Uint8Array(data);
console.log('sendDataToXterm', new TextDecoder().decode(bytes));
xtermWebViewRef.current?.write(bytes);
} catch (e) {
console.warn('Failed to decode shell data', e);
}
}
const queueRef = useRef<PQueue>(null);
useEffect(() => { useEffect(() => {
if (!connection) return; if (!queueRef.current)
queueRef.current = new PQueue({
concurrency: 1,
intervalCap: 1, // <= one task per interval
interval: 100, // <= 100ms between tasks
autoStart: false, // <= buffer until we start()
});
const xtermQueue = queueRef.current;
if (!connection || !xtermQueue) return;
const listenerId = connection.addChannelListener((data: ArrayBuffer) => { const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
try { console.log('ssh.onData', new TextDecoder().decode(new Uint8Array(data)));
const bytes = new Uint8Array(data); void xtermQueue.add(() => {
xtermWebViewRef.current?.write(bytes); sendDataToXterm(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); connection.removeChannelListener(listenerId);
xtermQueue.pause();
xtermQueue.clear();
}; };
}, [connection]); }, [connection, queueRef]);
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 }}>
@@ -77,9 +83,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: queryClient,
});
} catch (e) {
console.warn('Failed to disconnect', e);
}
router.replace('/shell'); router.replace('/shell');
}} }}
> >
@@ -94,140 +106,30 @@ function ShellDetail() {
{ backgroundColor: theme.colors.background }, { backgroundColor: theme.colors.background },
]} ]}
> >
<ScrollView> <XtermJsWebView
{renderer === 'xtermjs' ? ( ref={xtermWebViewRef}
<XtermJsWebView style={{ flex: 1, height: 400 }}
ref={xtermWebViewRef} // textZoom={0}
style={{ flex: 1, height: 400 }} injectedJavaScript={`
// textZoom={0} document.body.style.backgroundColor = '${theme.colors.background}';
// injectedJavaScript={` const termDiv = document.getElementById('terminal');
// setTimeout(() => { window.terminal.options.fontSize = 50;
// document.body.style.backgroundColor = '${theme.colors.background}'; setTimeout(() => {
// document.body.style.color = '${theme.colors.textPrimary}'; window.fitAddon?.fit();
// document.body.style.fontSize = '80px'; }, 1_000);
// const termDiv = document.getElementById('terminal'); `}
// termDiv.style.backgroundColor = '${theme.colors.background}'; onMessage={(message) => {
// termDiv.style.color = '${theme.colors.textPrimary}'; if (message.type === 'initialized') {
// window.terminal.options.fontSize = 50; console.log('xterm.onMessage initialized');
// }, 50); queueRef.current?.start();
// `} return;
onMessage={(event) => { }
console.log('onMessage', event.nativeEvent.data); const data = message.data;
}} console.log('xterm.onMessage', new TextDecoder().decode(data));
/> void shell?.sendData(data.buffer as ArrayBuffer);
) : ( }}
<View />
style={{
flex: 1,
backgroundColor: '#0E172B',
borderRadius: 12,
height: 400,
borderWidth: 1,
borderColor: '#2A3655',
overflow: 'hidden',
marginBottom: 12,
}}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
}}
keyboardShouldPersistTaps="handled"
>
<Text
selectable
style={{
color: '#D1D5DB',
fontSize: 14,
lineHeight: 18,
fontFamily: Platform.select({
ios: 'Menlo',
android: 'monospace',
default: 'monospace',
}),
}}
>
{shellData || 'Connected. Output will appear here...'}
</Text>
</ScrollView>
</View>
)}
<CommandInput
executeCommand={async (command) => {
await shell?.sendData(
Uint8Array.from(new TextEncoder().encode(command + '\n'))
.buffer,
);
}}
/>
</ScrollView>
</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>
);
}

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html style="margin: 0; padding: 0; width: 100vw; height: 100vh">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
</head> </head>
<body style="margin: 0; padding: 0; width: 100%; height: 100%"> <body style="margin: 0; padding: 0; width: 100vw; height: 100vh">
<div <div
id="terminal" id="terminal"
style="margin: 0; padding: 0; width: 100%; height: 100%" style="margin: 0; padding: 0; width: 100%; height: 100%"

View File

@@ -17,22 +17,24 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"js-base64": "^3.7.8",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@epic-web/config": "^1.21.3",
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",
"@types/react": "~19.1.12", "@types/react": "~19.1.12",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.2.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0", "globals": "^16.4.0",
"@epic-web/config": "^1.21.3", "prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.2.0",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.44.0", "typescript-eslint": "^8.44.0",
"vite": "6.3.6", "vite": "6.3.6",

View File

@@ -1,24 +1,30 @@
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { Base64 } from 'js-base64';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
const decoder = new TextDecoder('utf-8');
const terminal = new Terminal(); const terminal = new Terminal();
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(document.getElementById('terminal')!); terminal.open(document.getElementById('terminal')!);
terminal.write('Hello from Xterm.js!'); fitAddon.fit();
window.terminal = terminal; window.terminal = terminal;
window.fitAddon = fitAddon;
const postMessage = (arg: string) => { const postMessage = (arg: string) => {
window.ReactNativeWebView?.postMessage?.(arg); window.ReactNativeWebView?.postMessage?.(arg);
}; };
setTimeout(() => { setTimeout(() => {
postMessage('DEBUG: set timeout'); postMessage('initialized');
}, 1000); }, 10);
terminal.onData((data) => {
const base64Data = Base64.encode(data);
postMessage(base64Data);
});
function terminalWriteBase64(base64Data: string) { function terminalWriteBase64(base64Data: string) {
try { try {
postMessage(`DEBUG: terminalWriteBase64 ${base64Data}`); const data = Base64.toUint8Array(base64Data);
const data = new Uint8Array(Buffer.from(base64Data, 'base64'));
postMessage(`DEBUG: terminalWriteBase64 decoded ${decoder.decode(data)}`);
terminal.write(data); terminal.write(data);
} catch (e) { } catch (e) {
postMessage(`DEBUG: terminalWriteBase64 error ${e}`); postMessage(`DEBUG: terminalWriteBase64 error ${e}`);

View File

@@ -2,6 +2,7 @@
interface Window { interface Window {
terminal?: Terminal; terminal?: Terminal;
fitAddon?: FitAddon;
terminalWriteBase64?: (data: string) => void; terminalWriteBase64?: (data: string) => void;
ReactNativeWebView?: { ReactNativeWebView?: {
postMessage?: (data: string) => void; postMessage?: (data: string) => void;

View File

@@ -8,13 +8,19 @@ type StrictOmit<T, K extends keyof T> = Omit<T, K>;
export type XtermWebViewHandle = { export type XtermWebViewHandle = {
write: (data: Uint8Array) => void; write: (data: Uint8Array) => void;
}; };
const decoder = new TextDecoder('utf-8');
export function XtermJsWebView({ export function XtermJsWebView({
ref, ref,
onMessage,
...props ...props
}: StrictOmit<ComponentProps<typeof WebView>, 'source' | 'originWhitelist'> & { }: StrictOmit<
ComponentProps<typeof WebView>,
'source' | 'originWhitelist' | 'onMessage'
> & {
ref: React.RefObject<XtermWebViewHandle | null>; ref: React.RefObject<XtermWebViewHandle | null>;
onMessage?: (
data: { type: 'data'; data: Uint8Array } | { type: 'initialized' },
) => void;
}) { }) {
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
@@ -22,17 +28,8 @@ export function XtermJsWebView({
return { return {
write: (data) => { write: (data) => {
const base64Data = Base64.fromUint8Array(data); const base64Data = Base64.fromUint8Array(data);
console.log('writing rn side', {
base64Data,
dataLength: data.length,
});
console.log(
'try to decode',
decoder.decode(Base64.toUint8Array(base64Data)),
);
webViewRef.current?.injectJavaScript(` webViewRef.current?.injectJavaScript(`
window?.terminalWriteBase64('${base64Data}'); window?.terminalWriteBase64?.('${base64Data}');
`); `);
}, },
}; };
@@ -43,6 +40,15 @@ export function XtermJsWebView({
ref={webViewRef} ref={webViewRef}
originWhitelist={['*']} originWhitelist={['*']}
source={{ html: htmlString }} source={{ html: htmlString }}
onMessage={(event) => {
const message = event.nativeEvent.data;
if (message === 'initialized') {
onMessage?.({ type: 'initialized' });
return;
}
const data = Base64.toUint8Array(message);
onMessage?.({ type: 'data', data });
}}
{...props} {...props}
/> />
); );

18
pnpm-lock.yaml generated
View File

@@ -130,6 +130,9 @@ importers:
expo-system-ui: expo-system-ui:
specifier: ~6.0.7 specifier: ~6.0.7
version: 6.0.7(expo@54.0.8)(react-native-web@0.21.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)) version: 6.0.7(expo@54.0.8)(react-native-web@0.21.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))
p-queue:
specifier: ^8.1.1
version: 8.1.1
react: react:
specifier: 19.1.0 specifier: 19.1.0
version: 19.1.0 version: 19.1.0
@@ -385,9 +388,15 @@ importers:
packages/react-native-xtermjs-webview-internal: packages/react-native-xtermjs-webview-internal:
dependencies: dependencies:
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/xterm': '@xterm/xterm':
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
js-base64:
specifier: ^3.7.8
version: 3.7.8
react: react:
specifier: 19.1.0 specifier: 19.1.0
version: 19.1.0 version: 19.1.0
@@ -3512,6 +3521,11 @@ packages:
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0': '@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -13028,6 +13042,10 @@ snapshots:
'@xmldom/xmldom@0.8.11': {} '@xmldom/xmldom@0.8.11': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {} '@xterm/xterm@5.5.0': {}
abbrev@3.0.1: {} abbrev@3.0.1: {}