From b5410f03945762fed7968b7b610d45e97ef9f468 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:38:09 -0400 Subject: [PATCH] sorta working but bad init --- apps/mobile/src/app/(tabs)/shell/detail.tsx | 81 ++++++--- .../src-internal/main.tsx | 107 ++++++++---- .../src/index.tsx | 164 ++++++++++-------- 3 files changed, 233 insertions(+), 119 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 377a47f..cd3619b 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -19,6 +19,9 @@ export default function TabsShellDetail() { function ShellDetail() { const xtermRef = useRef(null); + const terminalReadyRef = useRef(false); // gate for initial SSH output buffering + const pendingOutputRef = useRef([]); // bytes we got before xterm init + const { connectionId, channelId } = useLocalSearchParams<{ connectionId?: string; channelId?: string; @@ -37,22 +40,28 @@ function ShellDetail() { /** * SSH -> xterm (remote output) - * Send bytes only; batching is handled inside XtermJsWebView. + * If xterm isn't ready yet, buffer and flush on 'initialized'. */ useEffect(() => { if (!connection) return; - const xterm = xtermRef.current; - const listenerId = connection.addChannelListener((data: ArrayBuffer) => { - // Forward bytes to terminal (no string conversion) - const uInt8 = new Uint8Array(data); - xterm?.write(uInt8); + const listenerId = connection.addChannelListener((ab: ArrayBuffer) => { + const bytes = new Uint8Array(ab); + if (!terminalReadyRef.current) { + // Buffer until WebView->xterm has signaled 'initialized' + pendingOutputRef.current.push(bytes); + // Debug + console.log('SSH->buffer', { len: bytes.length }); + return; + } + // Forward bytes immediately + console.log('SSH->xterm', { len: bytes.length }); + xterm?.write(bytes); }); return () => { connection.removeChannelListener(listenerId); - // Flush any buffered writes on unmount xterm?.flush?.(); }; }, [connection]); @@ -95,6 +104,7 @@ function ShellDetail() { { + console.log('WebView render process gone, clearing terminal'); xtermRef.current?.clear?.(); }} onContentProcessDidTerminate={() => { + console.log( + 'WKWebView content process terminated, clearing terminal', + ); xtermRef.current?.clear?.(); }} - // Optional: set initial theme/font + // xterm-flavored props for styling/behavior + fontFamily="Menlo, ui-monospace, monospace" + fontSize={15} + cursorBlink + scrollback={10000} + themeBackground={theme.colors.background} + themeForeground={theme.colors.textPrimary} + // page load => we can push initial options/theme right away; + // xterm itself will still send 'initialized' once it's truly ready. onLoadEnd={() => { - // Set theme bg/fg and font settings once WebView loads; the page will - // still send 'initialized' after xterm is ready. - xtermRef.current?.setTheme?.( - theme.colors.background, - theme.colors.textPrimary, - ); - xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 50); + console.log('WebView onLoadEnd'); }} - onMessage={(message) => { - if (message.type === 'initialized') { - // Terminal is ready; you could send a greeting or focus it + onMessage={(m) => { + console.log('received msg', m); + if (m.type === 'initialized') { + terminalReadyRef.current = true; + + // Flush any buffered SSH output (welcome banners, etc.) + if (pendingOutputRef.current.length) { + const total = pendingOutputRef.current.reduce( + (n, a) => n + a.length, + 0, + ); + console.log('Flushing buffered output', { + chunks: pendingOutputRef.current.length, + bytes: total, + }); + for (const chunk of pendingOutputRef.current) { + xtermRef.current?.write(chunk); + } + pendingOutputRef.current = []; + xtermRef.current?.flush?.(); + } + + // Focus after ready to pop the soft keyboard (iOS needs this prop) xtermRef.current?.focus?.(); return; } - if (message.type === 'data') { + if (m.type === 'data') { // xterm user input -> SSH // NOTE: msg.data is a fresh Uint8Array starting at offset 0 - void shell?.sendData(message.data.buffer as ArrayBuffer); + console.log('xterm->SSH', { len: m.data.length }); + void shell?.sendData(m.data.buffer as ArrayBuffer); return; } - if (message.type === 'debug') { - console.log('xterm.debug', message.message); + if (m.type === 'debug') { + console.log('xterm.debug', m.message); } }} /> diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx index 2c1a51b..90dca6c 100644 --- a/packages/react-native-xtermjs-webview/src-internal/main.tsx +++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx @@ -19,7 +19,7 @@ const root = document.getElementById('terminal')!; term.open(root); fitAddon.fit(); -// Expose for debugging (optional) +// Expose for debugging (typed via vite-env.d.ts) window.terminal = term; window.fitAddon = fitAddon; @@ -30,7 +30,7 @@ const post = (msg: unknown) => window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg)); /** - * Encode/decode helpers + * Encode helper */ const enc = new TextEncoder(); @@ -50,74 +50,123 @@ term.onData((data /* string */) => { }); /** - * Message handler for RN -> WebView control/data - * We support: write, resize, setFont, setTheme, clear, focus + * RN -> WebView control/data + * Supported: write, resize, setFont, setTheme, setOptions, clear, focus + * NOTE: Never spread term.options (it contains cols/rows). Only set keys you intend. */ window.addEventListener('message', (e: MessageEvent) => { try { - const msg = JSON.parse(e.data); + 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': { - // Either a single b64 or an array of chunks 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': { + // Prefer fitAddon.fit(); only call resize if explicit cols/rows provided. if (typeof msg.cols === 'number' && typeof msg.rows === 'number') { - try { - term.resize(msg.cols, msg.rows); - } finally { - fitAddon.fit(); - } - } else { - // If cols/rows not provided, try fit - fitAddon.fit(); + 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; - if (family) document.body.style.fontFamily = family; - if (typeof size === 'number') - document.body.style.fontSize = `${size}px`; - fitAddon.fit(); + const patch: Partial = {}; + if (family) patch.fontFamily = family; + if (typeof size === 'number') patch.fontSize = size; + if (Object.keys(patch).length) { + term.options = patch; // no spread -> avoids cols/rows setters + post({ + type: 'debug', + message: `setFont(${family ?? ''}, ${size ?? ''})`, + }); + fitAddon.fit(); + } break; } case 'setTheme': { const { background, foreground } = msg; - if (background) document.body.style.backgroundColor = background; - // xterm theme API (optional) - term.options = { - ...term.options, - theme: { - ...(term.options.theme ?? {}), - background, - foreground, - }, - }; + const theme: Partial = {}; + 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 ?? {}; + // Filter out cols/rows defensively + const { cursorBlink, scrollback, fontFamily, fontSize } = opts; + const patch: Partial = {}; + 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; } } @@ -127,7 +176,7 @@ window.addEventListener('message', (e: MessageEvent) => { }); /** - * Handle container resize + * Keep terminal size in sync with container */ new ResizeObserver(() => { try { diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx index 3fd7fe6..1bb99fa 100644 --- a/packages/react-native-xtermjs-webview/src/index.tsx +++ b/packages/react-native-xtermjs-webview/src/index.tsx @@ -16,105 +16,115 @@ type OutboundMessage = | { 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 = { - /** - * Push raw bytes (Uint8Array) into the terminal. - * Writes are batched (rAF or >=8KB) for performance. - */ - write: (data: Uint8Array) => void; - - /** Force-flush any buffered output immediately. */ - flush: () => void; - - /** Resize the terminal to given cols/rows (optional, fit addon also runs). */ + write: (data: Uint8Array) => void; // bytes in (batched) + flush: () => void; // force-flush outgoing writes resize: (cols?: number, rows?: number) => void; - - /** Set font props inside the WebView page. */ setFont: (family?: string, size?: number) => void; - - /** Set basic theme colors (background/foreground). */ setTheme: (background?: string, foreground?: string) => void; - - /** Clear terminal contents. */ + setOptions: ( + opts: OutboundMessage extends { type: 'setOptions'; opts: infer O } + ? O + : never, + ) => void; clear: () => void; - - /** Focus the terminal input. */ focus: () => void; }; +export interface XtermJsWebViewProps + extends StrictOmit< + React.ComponentProps, + 'source' | 'originWhitelist' | 'onMessage' + > { + ref: React.RefObject; + onMessage?: (msg: XtermInbound) => void; + + // xterm-ish props (applied via setOptions before/after init) + fontFamily?: string; + fontSize?: number; + cursorBlink?: boolean; + scrollback?: number; + themeBackground?: string; + themeForeground?: string; +} + export function XtermJsWebView({ ref, onMessage, + fontFamily, + fontSize, + cursorBlink, + scrollback, + themeBackground, + themeForeground, ...props -}: StrictOmit< - React.ComponentProps, - 'source' | 'originWhitelist' | 'onMessage' -> & { - ref: React.RefObject; - onMessage?: ( - msg: - | { type: 'initialized' } - | { type: 'data'; data: Uint8Array } // input from xterm (user typed) - | { type: 'debug'; message: string }, - ) => void; -}) { - const webViewRef = useRef(null); +}: XtermJsWebViewProps) { + const webRef = useRef(null); - // ---- RN -> WebView message sender via injectJavaScript + window MessageEvent + // ---- 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;`; - webViewRef.current?.injectJavaScript(js); + webRef.current?.injectJavaScript(js); }; // ---- rAF + 8KB coalescer for writes - const writeBufferRef = useRef(null); - const rafIdRef = useRef(null); - const THRESHOLD = 8 * 1024; // 8KB + const bufRef = useRef(null); + const rafRef = useRef(null); + const THRESHOLD = 8 * 1024; const flush = () => { - if (!writeBufferRef.current) return; - const b64 = Base64.fromUint8Array(writeBufferRef.current); - writeBufferRef.current = null; - if (rafIdRef.current != null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; + 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 scheduleFlush = () => { - if (rafIdRef.current != null) return; - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = null; + const schedule = () => { + if (rafRef.current != null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; flush(); }); }; const write = (data: Uint8Array) => { if (!data || data.length === 0) return; - const chunk = data; // already a fresh Uint8Array per caller - if (!writeBufferRef.current) { - writeBufferRef.current = chunk; + if (!bufRef.current) { + bufRef.current = data; } else { - // concat - const a = writeBufferRef.current; - const merged = new Uint8Array(a.length + chunk.length); + const a = bufRef.current; + const merged = new Uint8Array(a.length + data.length); merged.set(a, 0); - merged.set(chunk, a.length); - writeBufferRef.current = merged; - } - if ((writeBufferRef.current?.length ?? 0) >= THRESHOLD) { - flush(); - } else { - scheduleFlush(); + merged.set(data, a.length); + bufRef.current = merged; } + if ((bufRef.current?.length ?? 0) >= THRESHOLD) flush(); + else schedule(); }; useImperativeHandle(ref, () => ({ @@ -126,6 +136,7 @@ export function XtermJsWebView({ 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' }), })); @@ -133,29 +144,46 @@ export function XtermJsWebView({ // Cleanup pending rAF on unmount useEffect(() => { return () => { - if (rafIdRef.current != null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } - writeBufferRef.current = null; + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + bufRef.current = null; }; }, []); + // Push initial options/theme whenever props change + useEffect(() => { + const opts: Record = {}; + 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 ( { + onMessage={(e) => { try { - const msg: InboundMessage = JSON.parse(event.nativeEvent.data); + 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') { - // Convert base64 -> bytes for the caller (SSH writer) const bytes = Base64.toUint8Array(msg.b64); onMessage?.({ type: 'data', data: bytes }); return;