diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2d2d7ad..146475a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,6 +28,7 @@ "@expo/vector-icons": "^15.0.2", "@fressh/assets": "workspace:*", "@fressh/react-native-uniffi-russh": "workspace:*", + "@fressh/react-native-xtermjs-webview": "workspace:*", "@react-native-segmented-control/segmented-control": "2.5.7", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.4", @@ -48,13 +49,13 @@ "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", "expo-linking": "~8.0.8", - "@fressh/react-native-xtermjs-webview": "workspace:*", "expo-router": "6.0.6", "expo-secure-store": "~15.0.7", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", "expo-system-ui": "~6.0.7", + "p-queue": "^8.1.1", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.4", diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 8568532..fe0b018 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -5,22 +5,15 @@ import { type XtermWebViewHandle, } from '@fressh/react-native-xtermjs-webview'; +import { useQueryClient } from '@tanstack/react-query'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useEffect, useRef, useState } from 'react'; -import { - Platform, - Pressable, - ScrollView, - Text, - TextInput, - View, -} from 'react-native'; +import PQueue from 'p-queue'; +import React, { useEffect, useRef } from 'react'; +import { Pressable, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns'; import { useTheme } from '@/lib/theme'; -const renderer: 'xtermjs' | 'rn-text' = 'xtermjs'; -const decoder = new TextDecoder('utf-8'); - export default function TabsShellDetail() { return ; } @@ -43,29 +36,42 @@ function ShellDetail() { ? RnRussh.getSshShell(String(connectionId), channelIdNum) : 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(null); 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) => { - try { - const bytes = new Uint8Array(data); - xtermWebViewRef.current?.write(bytes); - const chunk = decoder.decode(bytes); - setShellData((prev) => prev + chunk); - } catch (e) { - console.warn('Failed to decode shell data', e); - } + console.log('ssh.onData', new TextDecoder().decode(new Uint8Array(data))); + void xtermQueue.add(() => { + sendDataToXterm(data); + }); }); return () => { connection.removeChannelListener(listenerId); + xtermQueue.pause(); + xtermQueue.clear(); }; - }, [connection]); + }, [connection, queueRef]); - const scrollViewRef = useRef(null); - useEffect(() => { - scrollViewRef.current?.scrollToEnd({ animated: true }); - }, [shellData]); + const queryClient = useQueryClient(); return ( @@ -77,9 +83,15 @@ function ShellDetail() { accessibilityLabel="Disconnect" hitSlop={10} onPress={async () => { + if (!connection) return; try { - await connection?.disconnect(); - } catch {} + await disconnectSshConnectionAndInvalidateQuery({ + connectionId: connection.connectionId, + queryClient: queryClient, + }); + } catch (e) { + console.warn('Failed to disconnect', e); + } router.replace('/shell'); }} > @@ -94,140 +106,30 @@ function ShellDetail() { { backgroundColor: theme.colors.background }, ]} > - - {renderer === 'xtermjs' ? ( - { - // document.body.style.backgroundColor = '${theme.colors.background}'; - // document.body.style.color = '${theme.colors.textPrimary}'; - // document.body.style.fontSize = '80px'; - // const termDiv = document.getElementById('terminal'); - // termDiv.style.backgroundColor = '${theme.colors.background}'; - // termDiv.style.color = '${theme.colors.textPrimary}'; - // window.terminal.options.fontSize = 50; - // }, 50); - // `} - onMessage={(event) => { - console.log('onMessage', event.nativeEvent.data); - }} - /> - ) : ( - - - - {shellData || 'Connected. Output will appear here...'} - - - - )} - { - await shell?.sendData( - Uint8Array.from(new TextEncoder().encode(command + '\n')) - .buffer, - ); - }} - /> - + { + window.fitAddon?.fit(); +}, 1_000); + `} + onMessage={(message) => { + if (message.type === 'initialized') { + console.log('xterm.onMessage initialized'); + queueRef.current?.start(); + return; + } + const data = message.data; + console.log('xterm.onMessage', new TextDecoder().decode(data)); + void shell?.sendData(data.buffer as ArrayBuffer); + }} + /> ); } - -function CommandInput(props: { - executeCommand: (command: string) => Promise; -}) { - const [command, setCommand] = useState(''); - - async function handleExecute() { - if (!command.trim()) return; - await props.executeCommand(command); - setCommand(''); - } - - return ( - - - - - Execute - - - - ); -} diff --git a/packages/react-native-xtermjs-webview-internal/index.html b/packages/react-native-xtermjs-webview-internal/index.html index a7be769..6f1ec3b 100644 --- a/packages/react-native-xtermjs-webview-internal/index.html +++ b/packages/react-native-xtermjs-webview-internal/index.html @@ -1,9 +1,9 @@ - + - +
{ window.ReactNativeWebView?.postMessage?.(arg); }; setTimeout(() => { - postMessage('DEBUG: set timeout'); -}, 1000); + postMessage('initialized'); +}, 10); + +terminal.onData((data) => { + const base64Data = Base64.encode(data); + postMessage(base64Data); +}); function terminalWriteBase64(base64Data: string) { try { - postMessage(`DEBUG: terminalWriteBase64 ${base64Data}`); - const data = new Uint8Array(Buffer.from(base64Data, 'base64')); - postMessage(`DEBUG: terminalWriteBase64 decoded ${decoder.decode(data)}`); - + const data = Base64.toUint8Array(base64Data); terminal.write(data); } catch (e) { postMessage(`DEBUG: terminalWriteBase64 error ${e}`); diff --git a/packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts b/packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts index 4e37827..065b340 100644 --- a/packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts +++ b/packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface Window { terminal?: Terminal; + fitAddon?: FitAddon; terminalWriteBase64?: (data: string) => void; ReactNativeWebView?: { postMessage?: (data: string) => void; diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx index f2e9153..da9cd66 100644 --- a/packages/react-native-xtermjs-webview/src/index.tsx +++ b/packages/react-native-xtermjs-webview/src/index.tsx @@ -8,13 +8,19 @@ type StrictOmit = Omit; export type XtermWebViewHandle = { write: (data: Uint8Array) => void; }; -const decoder = new TextDecoder('utf-8'); export function XtermJsWebView({ ref, + onMessage, ...props -}: StrictOmit, 'source' | 'originWhitelist'> & { +}: StrictOmit< + ComponentProps, + 'source' | 'originWhitelist' | 'onMessage' +> & { ref: React.RefObject; + onMessage?: ( + data: { type: 'data'; data: Uint8Array } | { type: 'initialized' }, + ) => void; }) { const webViewRef = useRef(null); @@ -22,17 +28,8 @@ export function XtermJsWebView({ return { write: (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(` - window?.terminalWriteBase64('${base64Data}'); + window?.terminalWriteBase64?.('${base64Data}'); `); }, }; @@ -43,6 +40,15 @@ export function XtermJsWebView({ ref={webViewRef} originWhitelist={['*']} 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} /> ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa308fa..641b396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: expo-system-ui: 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)) + p-queue: + specifier: ^8.1.1 + version: 8.1.1 react: specifier: 19.1.0 version: 19.1.0 @@ -385,9 +388,15 @@ importers: packages/react-native-xtermjs-webview-internal: dependencies: + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 + js-base64: + specifier: ^3.7.8 + version: 3.7.8 react: specifier: 19.1.0 version: 19.1.0 @@ -3512,6 +3521,11 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} 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': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} @@ -13028,6 +13042,10 @@ snapshots: '@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': {} abbrev@3.0.1: {}