diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b08cd57 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "expo", + "request": "attach", + "name": "Debug Expo app", + "projectRoot": "${workspaceFolder}/apps/mobile", + "bundlerPort": "8082", + "bundlerHost": "127.0.0.1" + } + ] +} diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 10e3c5d..f7abcf2 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -31,7 +31,7 @@ export default function TabsShellDetail() { // TODO: This is gross. It would be much better to switch // after the navigation animation completes. setReady(true); - }, 50); + }, 16); }); return () => { @@ -45,9 +45,19 @@ export default function TabsShellDetail() { } function RouteSkeleton() { + const theme = useTheme(); return ( - - Loading + + + Loading + ); } @@ -81,7 +91,7 @@ function ShellDetail() { useEffect(() => { if (shell && connection) return; console.log('shell or connection not found, replacing route with /shell'); - router.replace('/shell'); + router.back(); }, [connection, router, shell]); useEffect(() => { @@ -111,6 +121,7 @@ function ShellDetail() { return ( { const { y, height } = e.nativeEvent.layout; const extra = computeBottomExtra(y, height); @@ -120,9 +131,10 @@ function ShellDetail() { flex: 1, justifyContent: 'flex-start', backgroundColor: theme.colors.background, - padding: 0, - paddingBottom: - 4 + insets.bottom + (bottomExtra || estimatedTabBarHeight), + paddingTop: 2, + paddingLeft: 8, + paddingRight: 8, + paddingBottom: insets.bottom + (bottomExtra || estimatedTabBarHeight), }} > Object.values(s.connections)), ); + console.log('DEBUG list view connections', connections.length); return ( diff --git a/apps/mobile/src/lib/secrets-manager.ts b/apps/mobile/src/lib/secrets-manager.ts index f68c2ed..7ad8bf5 100644 --- a/apps/mobile/src/lib/secrets-manager.ts +++ b/apps/mobile/src/lib/secrets-manager.ts @@ -1,4 +1,4 @@ -import { RnRussh } from '@fressh/react-native-uniffi-russh'; +import { RnRussh, SshError_Tags } from '@fressh/react-native-uniffi-russh'; import { queryOptions } from '@tanstack/react-query'; import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; @@ -82,9 +82,9 @@ function makeBetterSecureStore< const unsafedRootManifest: unknown = rawRootManifestString ? JSON.parse(rawRootManifestString) : { - manifestVersion: rootManifestVersion, - manifestChunksIds: [], - }; + manifestVersion: rootManifestVersion, + manifestChunksIds: [], + }; const rootManifest = rootManifestSchema.parse(unsafedRootManifest); const manifestChunks = await Promise.all( rootManifest.manifestChunksIds.map(async (manifestChunkId) => { @@ -330,8 +330,15 @@ async function upsertPrivateKey(params: { metadata: StrictOmit; value: string; }) { - const validKey = RnRussh.validatePrivateKey(params.value); - if (!validKey) throw new Error('Invalid private key'); + const validateKeyResult = RnRussh.validatePrivateKey(params.value); + if (!validateKeyResult.valid) { + console.log('Invalid private key', validateKeyResult.error); + if (validateKeyResult.error.tag === SshError_Tags.RusshKeys) { + console.log('Invalid private key inner', validateKeyResult.error.inner); + console.log('Invalid private key content', params.value); + } + throw new Error('Invalid private key', { cause: validateKeyResult.error }); + } const keyId = params.keyId ?? `key_${Crypto.randomUUID()}`; log(`${params.keyId ? 'Upserting' : 'Creating'} private key ${keyId}`); // Preserve createdAtMs if the entry already exists diff --git a/apps/mobile/src/lib/ssh-store.ts b/apps/mobile/src/lib/ssh-store.ts index d273fce..4a64109 100644 --- a/apps/mobile/src/lib/ssh-store.ts +++ b/apps/mobile/src/lib/ssh-store.ts @@ -19,6 +19,7 @@ export const useSshStore = create((set) => ({ ...args, onDisconnected: (connectionId) => { args.onDisconnected?.(connectionId); + console.log('DEBUG connection disconnected', connectionId); set((s) => { const { [connectionId]: _omit, ...rest } = s.connections; return { connections: rest }; @@ -32,6 +33,7 @@ export const useSshStore = create((set) => ({ onClosed: (channelId) => { args.onClosed?.(channelId); const storeKey = `${connection.connectionId}-${channelId}` as const; + console.log('DEBUG shell closed', storeKey); set((s) => { const { [storeKey]: _omit, ...rest } = s.shells; return { shells: rest }; diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index fcaa35e..c912588 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -186,6 +186,7 @@ pub struct ShellSessionInfo { #[derive(uniffi::Object)] pub struct SshConnection { info: SshConnectionInfo, + on_disconnected_callback: Option>, client_handle: AsyncMutex>, shells: AsyncMutex>>, @@ -490,6 +491,11 @@ impl SshConnection { let h = self.client_handle.lock().await; h.disconnect(Disconnect::ByApplication, "bye", "").await?; + + if let Some(on_disconnected_callback) = self.on_disconnected_callback.as_ref() { + on_disconnected_callback.on_change(self.info.connection_id.clone()); + } + Ok(()) } } @@ -775,6 +781,7 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE client_handle: AsyncMutex::new(handle), shells: AsyncMutex::new(HashMap::new()), self_weak: AsyncMutex::new(Weak::new()), + on_disconnected_callback: options.on_disconnected_callback.clone(), }); // Initialize weak self reference. *conn.self_weak.lock().await = Arc::downgrade(&conn); diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts index edf274b..583d081 100644 --- a/packages/react-native-uniffi-russh/src/api.ts +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -153,7 +153,7 @@ type RusshApi = { // keySize?: number; // comment?: string; ) => Promise; - validatePrivateKey: (key: string) => boolean; + validatePrivateKey: (key: string) => { valid: true; error?: never } | { valid: false; error: GeneratedRussh.SshError }; }; // #endregion @@ -196,6 +196,8 @@ const streamEnumToLiteral = { [GeneratedRussh.StreamKind.Stderr]: 'stderr', } as const satisfies Record; + + function generatedConnDetailsToIdeal( details: GeneratedRussh.ConnectionDetails ): ConnectionDetails { @@ -379,17 +381,19 @@ async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') { return GeneratedRussh.generateKeyPair(map[type]); } -function validatePrivateKey(key: string) { +function validatePrivateKey(key: string): { valid: true; error?: never } | { valid: false; error: GeneratedRussh.SshError } { try { GeneratedRussh.validatePrivateKey(key); - return true; - } catch { - return false; + return { valid: true }; + } catch (e) { + return { valid: false, error: e as GeneratedRussh.SshError }; } } // #endregion +export { SshError, SshError_Tags } from './generated/uniffi_russh'; + export const RnRussh = { uniffiInitAsync: GeneratedRussh.uniffiInitAsync, connect, diff --git a/packages/react-native-xtermjs-webview/index.html b/packages/react-native-xtermjs-webview/index.html index 0809bab..1a4736f 100644 --- a/packages/react-native-xtermjs-webview/index.html +++ b/packages/react-native-xtermjs-webview/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" /> - +
void; - ReactNativeWebView?: { postMessage?: (data: string) => void }; + ReactNativeWebView?: { + postMessage?: (data: string) => void; + injectedObjectJson?: () => string | undefined; + }; __FRESSH_XTERM_BRIDGE__?: boolean; __FRESSH_XTERM_MSG_HANDLER__?: ( e: MessageEvent, @@ -27,114 +30,134 @@ const sendToRn = (msg: BridgeInboundMessage) => * 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__) { - sendToRn({ - 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; - - term.onData((data) => { - sendToRn({ type: 'input', str: data }); - }); - - // 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) => { - try { - const msg = e.data; - - if (!msg || typeof msg.type !== 'string') return; - - // TODO: https://xtermjs.org/docs/guides/flowcontrol/#ideas-for-a-better-mechanism - const termWrite = (bStr: string) => { - const bytes = bStrToBinary(bStr); - term.write(bytes); - }; - - switch (msg.type) { - case 'write': { - termWrite(msg.bStr); - break; - } - case 'writeMany': { - for (const bStr of msg.chunks) { - termWrite(bStr); - } - break; - } - case 'resize': { - term.resize(msg.cols, msg.rows); - break; - } - case 'fit': { - fitAddon.fit(); - break; - } - case 'setOptions': { - const newOpts: ITerminalOptions & { cols?: never; rows?: never } = { - ...term.options, - ...msg.opts, - theme: { - ...term.options.theme, - ...msg.opts.theme, - }, - }; - delete newOpts.cols; - delete newOpts.rows; - term.options = newOpts; - if ( - 'theme' in newOpts && - newOpts.theme && - 'background' in newOpts.theme && - newOpts.theme.background - ) { - document.body.style.backgroundColor = newOpts.theme.background; - } - break; - } - case 'clear': { - term.clear(); - break; - } - case 'focus': { - term.focus(); - break; - } - } - } catch (err) { +window.onload = () => { + try { + if (window.__FRESSH_XTERM_BRIDGE__) { sendToRn({ type: 'debug', - message: `message handler error: ${String(err)}`, + message: 'bridge already installed; ignoring duplicate boot', }); + return; } - }; - window.__FRESSH_XTERM_MSG_HANDLER__ = handler; - window.addEventListener('message', handler); + const injectedObjectJson = + window.ReactNativeWebView?.injectedObjectJson?.(); + if (!injectedObjectJson) { + sendToRn({ + type: 'debug', + message: 'injectedObjectJson not found; ignoring duplicate boot', + }); + return; + } - // Initial handshake (send once) - setTimeout(() => sendToRn({ type: 'initialized' }), 50); -} + window.__FRESSH_XTERM_BRIDGE__ = true; + + const injectedObject = JSON.parse(injectedObjectJson) as ITerminalOptions; + + // ---- Xterm setup + const term = new Terminal(injectedObject); + 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; + + term.onData((data) => { + sendToRn({ type: 'input', str: data }); + }); + + // 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) => { + try { + const msg = e.data; + + if (!msg || typeof msg.type !== 'string') return; + + // TODO: https://xtermjs.org/docs/guides/flowcontrol/#ideas-for-a-better-mechanism + const termWrite = (bStr: string) => { + const bytes = bStrToBinary(bStr); + term.write(bytes); + }; + + switch (msg.type) { + case 'write': { + termWrite(msg.bStr); + break; + } + case 'writeMany': { + for (const bStr of msg.chunks) { + termWrite(bStr); + } + break; + } + case 'resize': { + term.resize(msg.cols, msg.rows); + break; + } + case 'fit': { + fitAddon.fit(); + break; + } + case 'setOptions': { + const newOpts: ITerminalOptions & { cols?: never; rows?: never } = { + ...term.options, + ...msg.opts, + theme: { + ...term.options.theme, + ...msg.opts.theme, + }, + }; + delete newOpts.cols; + delete newOpts.rows; + term.options = newOpts; + if ( + 'theme' in newOpts && + newOpts.theme && + 'background' in newOpts.theme && + newOpts.theme.background + ) { + document.body.style.backgroundColor = newOpts.theme.background; + } + break; + } + case 'clear': { + term.clear(); + break; + } + case 'focus': { + term.focus(); + break; + } + } + } catch (err) { + sendToRn({ + type: 'debug', + message: `message handler error: ${String(err)}`, + }); + } + }; + + window.__FRESSH_XTERM_MSG_HANDLER__ = handler; + window.addEventListener('message', handler); + + // Initial handshake (send once) + setTimeout(() => sendToRn({ type: 'initialized' }), 50); + } catch (e) { + sendToRn({ + type: 'debug', + message: `error in xtermjs-webview: ${String(e)}`, + }); + } +}; diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx index 4f87c60..ea50f33 100644 --- a/packages/react-native-xtermjs-webview/src/index.tsx +++ b/packages/react-native-xtermjs-webview/src/index.tsx @@ -63,15 +63,17 @@ const defaultWebViewProps: WebViewOptions = { }; const defaultXtermOptions: Partial = { - fontFamily: 'Menlo, ui-monospace, monospace', - fontSize: 20, - cursorBlink: true, + allowProposedApi: true, + convertEol: true, scrollback: 10000, + cursorBlink: true, + fontFamily: 'Menlo, ui-monospace, monospace', + fontSize: 10, }; type UserControllableWebViewProps = StrictOmit< WebViewOptions, - 'source' | 'style' + 'source' | 'style' | 'injectedJavaScriptBeforeContentLoaded' >; export type XtermJsWebViewProps = { @@ -259,9 +261,10 @@ export function XtermJsWebView({ if (xTermOptionsEquals(appliedXtermOptions, mergedXTermOptions)) return; logger?.log?.(`setting options: `, mergedXTermOptions); sendToWebView({ type: 'setOptions', opts: mergedXTermOptions }); + autoFitFn(); appliedXtermOptionsRef.current = mergedXTermOptions; - }, [mergedXTermOptions, sendToWebView, logger, initialized]); + }, [mergedXTermOptions, sendToWebView, logger, initialized, autoFitFn]); const onMessage = useCallback( (e: WebViewMessageEvent) => { @@ -346,6 +349,15 @@ export function XtermJsWebView({ source={{ html: htmlString }} onMessage={onMessage} style={style} + injectedJavaScriptObject={mergedXTermOptions} + injectedJavaScriptBeforeContentLoaded={ + mergedXTermOptions.theme?.background + ? ` + document.body.style.backgroundColor = '${mergedXTermOptions.theme.background}'; + true; + ` + : undefined + } {...mergedWebViewOptions} /> );