;
-}) {
- 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: {}
From 2f5568a6d5efdd18abdefebc63abb51253d408da Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 17:22:50 -0400
Subject: [PATCH 04/12] rm one pkg
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 13 ++--
.../.gitignore | 24 -------
.../eslint.config.js | 27 -------
.../package.json | 43 ------------
.../prettier.config.mjs | 14 ----
.../tsconfig.json | 7 --
.../tsconfig.node.json | 25 -------
.../turbo.json | 7 --
.../react-native-xtermjs-webview/.gitignore | 1 +
.../index.html | 2 +-
.../react-native-xtermjs-webview/package.json | 8 ++-
.../src-internal}/main.tsx | 0
.../src-internal}/vite-env.d.ts | 0
.../src/index.tsx | 6 +-
.../tsconfig.app-internal.json} | 4 +-
.../tsconfig.json | 1 +
.../tsconfig.node.json | 2 +-
.../react-native-xtermjs-webview/turbo.json | 12 ++++
.../vite.config.internal.ts} | 3 +
pnpm-lock.yaml | 70 ++-----------------
20 files changed, 44 insertions(+), 225 deletions(-)
delete mode 100644 packages/react-native-xtermjs-webview-internal/.gitignore
delete mode 100644 packages/react-native-xtermjs-webview-internal/eslint.config.js
delete mode 100644 packages/react-native-xtermjs-webview-internal/package.json
delete mode 100644 packages/react-native-xtermjs-webview-internal/prettier.config.mjs
delete mode 100644 packages/react-native-xtermjs-webview-internal/tsconfig.json
delete mode 100644 packages/react-native-xtermjs-webview-internal/tsconfig.node.json
delete mode 100644 packages/react-native-xtermjs-webview-internal/turbo.json
rename packages/{react-native-xtermjs-webview-internal => react-native-xtermjs-webview}/index.html (82%)
rename packages/{react-native-xtermjs-webview-internal/src => react-native-xtermjs-webview/src-internal}/main.tsx (100%)
rename packages/{react-native-xtermjs-webview-internal/src => react-native-xtermjs-webview/src-internal}/vite-env.d.ts (100%)
rename packages/{react-native-xtermjs-webview-internal/tsconfig.app.json => react-native-xtermjs-webview/tsconfig.app-internal.json} (84%)
rename packages/{react-native-xtermjs-webview-internal/vite.config.ts => react-native-xtermjs-webview/vite.config.internal.ts} (82%)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index fe0b018..3d5adc7 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -38,9 +38,9 @@ function ShellDetail() {
function sendDataToXterm(data: ArrayBuffer) {
try {
- const bytes = new Uint8Array(data);
- console.log('sendDataToXterm', new TextDecoder().decode(bytes));
- xtermWebViewRef.current?.write(bytes);
+ const bytes = new Uint8Array(data.slice());
+ console.log('sendDataToXterm', new TextDecoder().decode(bytes.slice()));
+ xtermWebViewRef.current?.write(bytes.slice());
} catch (e) {
console.warn('Failed to decode shell data', e);
}
@@ -59,7 +59,10 @@ function ShellDetail() {
const xtermQueue = queueRef.current;
if (!connection || !xtermQueue) return;
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
- console.log('ssh.onData', new TextDecoder().decode(new Uint8Array(data)));
+ console.log(
+ 'ssh.onData',
+ new TextDecoder().decode(new Uint8Array(data.slice())),
+ );
void xtermQueue.add(() => {
sendDataToXterm(data);
});
@@ -126,7 +129,7 @@ setTimeout(() => {
}
const data = message.data;
console.log('xterm.onMessage', new TextDecoder().decode(data));
- void shell?.sendData(data.buffer as ArrayBuffer);
+ void shell?.sendData(data.slice().buffer as ArrayBuffer);
}}
/>
diff --git a/packages/react-native-xtermjs-webview-internal/.gitignore b/packages/react-native-xtermjs-webview-internal/.gitignore
deleted file mode 100644
index a547bf3..0000000
--- a/packages/react-native-xtermjs-webview-internal/.gitignore
+++ /dev/null
@@ -1,24 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
diff --git a/packages/react-native-xtermjs-webview-internal/eslint.config.js b/packages/react-native-xtermjs-webview-internal/eslint.config.js
deleted file mode 100644
index a1e9c7c..0000000
--- a/packages/react-native-xtermjs-webview-internal/eslint.config.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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,
- },
- },
- },
-]);
diff --git a/packages/react-native-xtermjs-webview-internal/package.json b/packages/react-native-xtermjs-webview-internal/package.json
deleted file mode 100644
index d607c1f..0000000
--- a/packages/react-native-xtermjs-webview-internal/package.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "name": "@fressh/react-native-xtermjs-webview-internal",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "files": [
- "dist"
- ],
- "scripts": {
- "dev": "vite",
- "build": "tsc -b && vite build",
- "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": {
- "@xterm/addon-fit": "^0.10.0",
- "@xterm/xterm": "^5.5.0",
- "js-base64": "^3.7.8",
- "react": "19.1.0",
- "react-dom": "19.1.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",
- "eslint": "^9.35.0",
- "eslint-plugin-react-hooks": "^5.2.0",
- "eslint-plugin-react-refresh": "^0.4.20",
- "globals": "^16.4.0",
- "prettier": "^3.6.2",
- "prettier-plugin-organize-imports": "^4.2.0",
- "typescript": "~5.9.2",
- "typescript-eslint": "^8.44.0",
- "vite": "6.3.6",
- "vite-plugin-singlefile": "^2.3.0"
- }
-}
diff --git a/packages/react-native-xtermjs-webview-internal/prettier.config.mjs b/packages/react-native-xtermjs-webview-internal/prettier.config.mjs
deleted file mode 100644
index 7b0e9e2..0000000
--- a/packages/react-native-xtermjs-webview-internal/prettier.config.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-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 || []),
- ],
-};
diff --git a/packages/react-native-xtermjs-webview-internal/tsconfig.json b/packages/react-native-xtermjs-webview-internal/tsconfig.json
deleted file mode 100644
index fb12418..0000000
--- a/packages/react-native-xtermjs-webview-internal/tsconfig.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
-}
diff --git a/packages/react-native-xtermjs-webview-internal/tsconfig.node.json b/packages/react-native-xtermjs-webview-internal/tsconfig.node.json
deleted file mode 100644
index 1a5ed45..0000000
--- a/packages/react-native-xtermjs-webview-internal/tsconfig.node.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "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"]
-}
diff --git a/packages/react-native-xtermjs-webview-internal/turbo.json b/packages/react-native-xtermjs-webview-internal/turbo.json
deleted file mode 100644
index 335d504..0000000
--- a/packages/react-native-xtermjs-webview-internal/turbo.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": ["//"],
- "tasks": {
- "lint": {},
- "lint:check": {}
- }
-}
diff --git a/packages/react-native-xtermjs-webview/.gitignore b/packages/react-native-xtermjs-webview/.gitignore
index a547bf3..054f5aa 100644
--- a/packages/react-native-xtermjs-webview/.gitignore
+++ b/packages/react-native-xtermjs-webview/.gitignore
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
+dist-internal
dist-ssr
*.local
diff --git a/packages/react-native-xtermjs-webview-internal/index.html b/packages/react-native-xtermjs-webview/index.html
similarity index 82%
rename from packages/react-native-xtermjs-webview-internal/index.html
rename to packages/react-native-xtermjs-webview/index.html
index 6f1ec3b..7725761 100644
--- a/packages/react-native-xtermjs-webview-internal/index.html
+++ b/packages/react-native-xtermjs-webview/index.html
@@ -8,6 +8,6 @@
id="terminal"
style="margin: 0; padding: 0; width: 100%; height: 100%"
>
-
+
diff --git a/packages/react-native-xtermjs-webview/package.json b/packages/react-native-xtermjs-webview/package.json
index 5c594bc..9eff657 100644
--- a/packages/react-native-xtermjs-webview/package.json
+++ b/packages/react-native-xtermjs-webview/package.json
@@ -8,7 +8,8 @@
},
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "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",
@@ -17,7 +18,6 @@
"preview": "vite preview"
},
"dependencies": {
- "@fressh/react-native-xtermjs-webview-internal": "workspace:*",
"js-base64": "^3.7.8"
},
"peerDependencies": {
@@ -31,8 +31,12 @@
"@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",
diff --git a/packages/react-native-xtermjs-webview-internal/src/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx
similarity index 100%
rename from packages/react-native-xtermjs-webview-internal/src/main.tsx
rename to packages/react-native-xtermjs-webview/src-internal/main.tsx
diff --git a/packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts b/packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
similarity index 100%
rename from packages/react-native-xtermjs-webview-internal/src/vite-env.d.ts
rename to packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx
index da9cd66..f04c098 100644
--- a/packages/react-native-xtermjs-webview/src/index.tsx
+++ b/packages/react-native-xtermjs-webview/src/index.tsx
@@ -1,6 +1,6 @@
import { useImperativeHandle, useRef, type ComponentProps } from 'react';
import { WebView } from 'react-native-webview';
-import htmlString from '@fressh/react-native-xtermjs-webview-internal/dist/index.html?raw';
+import htmlString from '../dist-internal/index.html?raw';
import { Base64 } from 'js-base64';
type StrictOmit = Omit;
@@ -27,7 +27,7 @@ export function XtermJsWebView({
useImperativeHandle(ref, () => {
return {
write: (data) => {
- const base64Data = Base64.fromUint8Array(data);
+ const base64Data = Base64.fromUint8Array(data.slice());
webViewRef.current?.injectJavaScript(`
window?.terminalWriteBase64?.('${base64Data}');
`);
@@ -46,7 +46,7 @@ export function XtermJsWebView({
onMessage?.({ type: 'initialized' });
return;
}
- const data = Base64.toUint8Array(message);
+ const data = Base64.toUint8Array(message.slice());
onMessage?.({ type: 'data', data });
}}
{...props}
diff --git a/packages/react-native-xtermjs-webview-internal/tsconfig.app.json b/packages/react-native-xtermjs-webview/tsconfig.app-internal.json
similarity index 84%
rename from packages/react-native-xtermjs-webview-internal/tsconfig.app.json
rename to packages/react-native-xtermjs-webview/tsconfig.app-internal.json
index 213a1d9..0609598 100644
--- a/packages/react-native-xtermjs-webview-internal/tsconfig.app.json
+++ b/packages/react-native-xtermjs-webview/tsconfig.app-internal.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app-internal.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
@@ -23,5 +23,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src-internal"]
}
diff --git a/packages/react-native-xtermjs-webview/tsconfig.json b/packages/react-native-xtermjs-webview/tsconfig.json
index fb12418..da25836 100644
--- a/packages/react-native-xtermjs-webview/tsconfig.json
+++ b/packages/react-native-xtermjs-webview/tsconfig.json
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.app-internal.json" },
{ "path": "./tsconfig.node.json" }
]
}
diff --git a/packages/react-native-xtermjs-webview/tsconfig.node.json b/packages/react-native-xtermjs-webview/tsconfig.node.json
index 1a5ed45..da9370a 100644
--- a/packages/react-native-xtermjs-webview/tsconfig.node.json
+++ b/packages/react-native-xtermjs-webview/tsconfig.node.json
@@ -21,5 +21,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "vite.config.internal.ts"]
}
diff --git a/packages/react-native-xtermjs-webview/turbo.json b/packages/react-native-xtermjs-webview/turbo.json
index 335d504..7715278 100644
--- a/packages/react-native-xtermjs-webview/turbo.json
+++ b/packages/react-native-xtermjs-webview/turbo.json
@@ -1,6 +1,18 @@
{
"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": {}
}
diff --git a/packages/react-native-xtermjs-webview-internal/vite.config.ts b/packages/react-native-xtermjs-webview/vite.config.internal.ts
similarity index 82%
rename from packages/react-native-xtermjs-webview-internal/vite.config.ts
rename to packages/react-native-xtermjs-webview/vite.config.internal.ts
index 208116f..958bf7c 100644
--- a/packages/react-native-xtermjs-webview-internal/vite.config.ts
+++ b/packages/react-native-xtermjs-webview/vite.config.internal.ts
@@ -4,4 +4,7 @@ import { viteSingleFile } from 'vite-plugin-singlefile';
// https://vite.dev/config/
export default defineConfig({
plugins: [viteSingleFile()],
+ build: {
+ outDir: 'dist-internal',
+ },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 641b396..102483b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -324,9 +324,6 @@ importers:
packages/react-native-xtermjs-webview:
dependencies:
- '@fressh/react-native-xtermjs-webview-internal':
- specifier: workspace:*
- version: link:../react-native-xtermjs-webview-internal
js-base64:
specifier: ^3.7.8
version: 3.7.8
@@ -346,6 +343,12 @@ importers:
'@vitejs/plugin-react':
specifier: ^5.0.2
version: 5.0.2(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
+ '@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
eslint:
specifier: ^9.35.0
version: 9.35.0(jiti@2.5.1)
@@ -385,67 +388,6 @@ importers:
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4(@types/node@24.3.0)(rollup@4.50.2)(typescript@5.9.2)(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
-
- 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
- react-dom:
- specifier: 19.1.0
- version: 19.1.0(react@19.1.0)
- devDependencies:
- '@epic-web/config':
- specifier: ^1.21.3
- version: 1.21.3(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.5.1))(prettier-plugin-astro@0.14.1)(prettier-plugin-organize-imports@4.2.0(prettier@3.6.2)(typescript@5.9.2))(prettier@3.6.2)(typescript@5.9.2)
- '@eslint/js':
- specifier: ^9.35.0
- version: 9.35.0
- '@types/react':
- specifier: ~19.1.12
- version: 19.1.12
- '@types/react-dom':
- specifier: ^19.1.7
- version: 19.1.9(@types/react@19.1.12)
- '@vitejs/plugin-react':
- specifier: ^5.0.2
- version: 5.0.2(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
- eslint:
- specifier: ^9.35.0
- version: 9.35.0(jiti@2.5.1)
- eslint-plugin-react-hooks:
- specifier: ^5.2.0
- version: 5.2.0(eslint@9.35.0(jiti@2.5.1))
- eslint-plugin-react-refresh:
- specifier: ^0.4.20
- version: 0.4.20(eslint@9.35.0(jiti@2.5.1))
- globals:
- specifier: ^16.4.0
- version: 16.4.0
- prettier:
- specifier: ^3.6.2
- version: 3.6.2
- prettier-plugin-organize-imports:
- specifier: ^4.2.0
- version: 4.2.0(prettier@3.6.2)(typescript@5.9.2)
- typescript:
- specifier: ~5.9.2
- version: 5.9.2
- typescript-eslint:
- specifier: ^8.44.0
- version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
- vite:
- specifier: 6.3.6
- version: 6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vite-plugin-singlefile:
specifier: ^2.3.0
version: 2.3.0(rollup@4.50.2)(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
From b24d44155d0c54cdfd1d44ddaeb122effc77812e Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 19:35:49 -0400
Subject: [PATCH 05/12] one ai prompt
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 87 +++++-----
.../src-internal/main.tsx | 154 +++++++++++++++---
.../src/index.tsx | 153 +++++++++++++++--
3 files changed, 301 insertions(+), 93 deletions(-)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index 3d5adc7..f03df40 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -7,7 +7,6 @@ import {
import { useQueryClient } from '@tanstack/react-query';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
-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';
@@ -19,7 +18,7 @@ export default function TabsShellDetail() {
}
function ShellDetail() {
- const xtermWebViewRef = useRef(null);
+ const xtermRef = useRef(null);
const { connectionId, channelId } = useLocalSearchParams<{
connectionId?: string;
channelId?: string;
@@ -36,43 +35,24 @@ function ShellDetail() {
? RnRussh.getSshShell(String(connectionId), channelIdNum)
: undefined;
- function sendDataToXterm(data: ArrayBuffer) {
- try {
- const bytes = new Uint8Array(data.slice());
- console.log('sendDataToXterm', new TextDecoder().decode(bytes.slice()));
- xtermWebViewRef.current?.write(bytes.slice());
- } catch (e) {
- console.warn('Failed to decode shell data', e);
- }
- }
-
- const queueRef = useRef(null);
-
+ /**
+ * SSH -> xterm (remote output)
+ * Send bytes only; batching is handled inside XtermJsWebView.
+ */
useEffect(() => {
- 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;
+ if (!connection) return;
+
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
- console.log(
- 'ssh.onData',
- new TextDecoder().decode(new Uint8Array(data.slice())),
- );
- void xtermQueue.add(() => {
- sendDataToXterm(data);
- });
+ // Forward bytes to terminal (no string conversion)
+ xtermRef.current?.write(new Uint8Array(data));
});
+
return () => {
connection.removeChannelListener(listenerId);
- xtermQueue.pause();
- xtermQueue.clear();
+ // Flush any buffered writes on unmount
+ xtermRef.current?.flush?.();
};
- }, [connection, queueRef]);
+ }, [connection]);
const queryClient = useQueryClient();
@@ -90,7 +70,7 @@ function ShellDetail() {
try {
await disconnectSshConnectionAndInvalidateQuery({
connectionId: connection.connectionId,
- queryClient: queryClient,
+ queryClient,
});
} catch (e) {
console.warn('Failed to disconnect', e);
@@ -110,26 +90,33 @@ function ShellDetail() {
]}
>
{
- window.fitAddon?.fit();
-}, 1_000);
- `}
+ ref={xtermRef}
+ style={{ flex: 1 }}
+ // Optional: set initial theme/font
+ 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.text,
+ );
+ xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 14);
+ }}
onMessage={(message) => {
if (message.type === 'initialized') {
- console.log('xterm.onMessage initialized');
- queueRef.current?.start();
+ // Terminal is ready; you could send a greeting or focus it
+ xtermRef.current?.focus?.();
return;
}
- const data = message.data;
- console.log('xterm.onMessage', new TextDecoder().decode(data));
- void shell?.sendData(data.slice().buffer as ArrayBuffer);
+ if (message.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);
+ return;
+ }
+ if (message.type === 'debug') {
+ console.log('xterm.debug', message.message);
+ }
}}
/>
diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx
index 2222441..c51b35d 100644
--- a/packages/react-native-xtermjs-webview/src-internal/main.tsx
+++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx
@@ -1,33 +1,137 @@
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { Base64 } from 'js-base64';
-
import '@xterm/xterm/css/xterm.css';
-const terminal = new Terminal();
-const fitAddon = new FitAddon();
-terminal.loadAddon(fitAddon);
-terminal.open(document.getElementById('terminal')!);
-fitAddon.fit();
-window.terminal = terminal;
-window.fitAddon = fitAddon;
-const postMessage = (arg: string) => {
- window.ReactNativeWebView?.postMessage?.(arg);
-};
-setTimeout(() => {
- postMessage('initialized');
-}, 10);
-
-terminal.onData((data) => {
- const base64Data = Base64.encode(data);
- postMessage(base64Data);
+/**
+ * Xterm setup
+ */
+const term = new Terminal({
+ allowProposedApi: true,
+ convertEol: true,
+ scrollback: 10000,
});
-function terminalWriteBase64(base64Data: string) {
+const fitAddon = new FitAddon();
+term.loadAddon(fitAddon);
+
+const root = document.getElementById('terminal')!;
+term.open(root);
+fitAddon.fit();
+
+// Expose for debugging (optional)
+window.terminal = term;
+window.fitAddon = fitAddon;
+
+/**
+ * Post typed messages to React Native
+ */
+const post = (msg: unknown) =>
+ window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg));
+
+/**
+ * Encode/decode helpers
+ */
+const enc = new TextEncoder();
+
+/**
+ * Initial handshake
+ */
+setTimeout(() => post({ type: 'initialized' }), 0);
+
+/**
+ * User input from xterm -> RN (SSH)
+ * Send UTF-8 bytes only (Base64-encoded)
+ */
+term.onData((data /* string */) => {
+ const bytes = enc.encode(data);
+ const b64 = Base64.fromUint8Array(bytes);
+ post({ type: 'input', b64 });
+});
+
+/**
+ * Message handler for RN -> WebView control/data
+ * We support: write, resize, setFont, setTheme, clear, focus
+ */
+window.addEventListener('message', (e: MessageEvent) => {
try {
- const data = Base64.toUint8Array(base64Data);
- terminal.write(data);
- } catch (e) {
- postMessage(`DEBUG: terminalWriteBase64 error ${e}`);
+ const msg = JSON.parse(e.data);
+ 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);
+ } else if (Array.isArray(msg.chunks)) {
+ for (const b64 of msg.chunks) {
+ const bytes = Base64.toUint8Array(b64);
+ term.write(bytes);
+ }
+ }
+ break;
+ }
+
+ case 'resize': {
+ 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();
+ }
+ 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();
+ 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,
+ },
+ };
+ break;
+ }
+
+ case 'clear': {
+ term.clear();
+ break;
+ }
+
+ case 'focus': {
+ term.focus();
+ break;
+ }
+ }
+ } catch (err) {
+ post({ type: 'debug', message: `message handler error: ${String(err)}` });
}
-}
-window.terminalWriteBase64 = terminalWriteBase64;
+});
+
+/**
+ * Handle container resize
+ */
+new ResizeObserver(() => {
+ try {
+ fitAddon.fit();
+ } catch (err) {
+ post({ type: 'debug', message: `resize observer error: ${String(err)}` });
+ }
+});
diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx
index f04c098..c2e6676 100644
--- a/packages/react-native-xtermjs-webview/src/index.tsx
+++ b/packages/react-native-xtermjs-webview/src/index.tsx
@@ -1,12 +1,48 @@
-import { useImperativeHandle, useRef, type ComponentProps } from 'react';
+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 = Omit;
+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: 'clear' }
+ | { type: 'focus' };
+
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). */
+ 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. */
+ clear: () => void;
+
+ /** Focus the terminal input. */
+ focus: () => void;
};
export function XtermJsWebView({
@@ -14,26 +50,95 @@ export function XtermJsWebView({
onMessage,
...props
}: StrictOmit<
- ComponentProps,
+ React.ComponentProps,
'source' | 'originWhitelist' | 'onMessage'
> & {
ref: React.RefObject;
onMessage?: (
- data: { type: 'data'; data: Uint8Array } | { type: 'initialized' },
+ msg:
+ | { type: 'initialized' }
+ | { type: 'data'; data: Uint8Array } // input from xterm (user typed)
+ | { type: 'debug'; message: string },
) => void;
}) {
const webViewRef = useRef(null);
- useImperativeHandle(ref, () => {
- return {
- write: (data) => {
- const base64Data = Base64.fromUint8Array(data.slice());
- webViewRef.current?.injectJavaScript(`
- window?.terminalWriteBase64?.('${base64Data}');
- `);
- },
+ // ---- RN -> WebView message sender via injectJavaScript + window MessageEvent
+ const send = (obj: OutboundMessage) => {
+ const payload = JSON.stringify(obj);
+ const js = `window.dispatchEvent(new MessageEvent('message',{data:${JSON.stringify(
+ payload,
+ )}})); true;`;
+ webViewRef.current?.injectJavaScript(js);
+ };
+
+ // ---- rAF + 8KB coalescer for writes
+ const writeBufferRef = useRef(null);
+ const rafIdRef = useRef(null);
+ const THRESHOLD = 8 * 1024; // 8KB
+
+ 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;
+ }
+ send({ type: 'write', b64 });
+ };
+
+ const scheduleFlush = () => {
+ if (rafIdRef.current != null) return;
+ rafIdRef.current = requestAnimationFrame(() => {
+ rafIdRef.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;
+ } else {
+ // concat
+ const a = writeBufferRef.current;
+ const merged = new Uint8Array(a.length + chunk.length);
+ merged.set(a, 0);
+ merged.set(chunk, a.length);
+ writeBufferRef.current = merged;
+ }
+ if ((writeBufferRef.current?.length ?? 0) >= THRESHOLD) {
+ flush();
+ } else {
+ scheduleFlush();
+ }
+ };
+
+ useImperativeHandle(ref, () => ({
+ write,
+ 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 }),
+ clear: () => send({ type: 'clear' }),
+ focus: () => send({ type: 'focus' }),
+ }));
+
+ // Cleanup pending rAF on unmount
+ useEffect(() => {
+ return () => {
+ if (rafIdRef.current != null) {
+ cancelAnimationFrame(rafIdRef.current);
+ rafIdRef.current = null;
+ }
+ writeBufferRef.current = null;
};
- });
+ }, []);
return (
{
- const message = event.nativeEvent.data;
- if (message === 'initialized') {
- onMessage?.({ type: 'initialized' });
- return;
+ try {
+ const msg: InboundMessage = JSON.parse(event.nativeEvent.data);
+ 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;
+ }
+ if (msg.type === 'debug') {
+ onMessage?.({ type: 'debug', message: msg.message });
+ return;
+ }
+ } catch {
+ // ignore unknown payloads
}
- const data = Base64.toUint8Array(message.slice());
- onMessage?.({ type: 'data', data });
}}
{...props}
/>
From 07f7ce9a6f327c477bb124cf5c709f9af8baf0f9 Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 19:35:57 -0400
Subject: [PATCH 06/12] lint passing ai prompt
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 24 ++++++++++++++++---
.../src-internal/main.tsx | 1 +
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index f03df40..e8ddf00 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -42,15 +42,17 @@ function ShellDetail() {
useEffect(() => {
if (!connection) return;
+ const xterm = xtermRef.current;
+
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
// Forward bytes to terminal (no string conversion)
- xtermRef.current?.write(new Uint8Array(data));
+ xterm?.write(new Uint8Array(data));
});
return () => {
connection.removeChannelListener(listenerId);
// Flush any buffered writes on unmount
- xtermRef.current?.flush?.();
+ xterm?.flush?.();
};
}, [connection]);
@@ -92,13 +94,29 @@ function ShellDetail() {
{
+ xtermRef.current?.clear?.();
+ }}
+ onContentProcessDidTerminate={() => {
+ xtermRef.current?.clear?.();
+ }}
// Optional: set initial theme/font
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.text,
+ theme.colors.textPrimary,
);
xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 14);
}}
diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx
index c51b35d..2c1a51b 100644
--- a/packages/react-native-xtermjs-webview/src-internal/main.tsx
+++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx
@@ -10,6 +10,7 @@ const term = new Terminal({
allowProposedApi: true,
convertEol: true,
scrollback: 10000,
+ cursorBlink: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
From c445eef1d10514738ece78cc0235831b17fe5e4c Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 20:24:25 -0400
Subject: [PATCH 07/12] some commit
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 5 +++--
packages/react-native-xtermjs-webview/src/index.tsx | 2 ++
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index e8ddf00..377a47f 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -46,7 +46,8 @@ function ShellDetail() {
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
// Forward bytes to terminal (no string conversion)
- xterm?.write(new Uint8Array(data));
+ const uInt8 = new Uint8Array(data);
+ xterm?.write(uInt8);
});
return () => {
@@ -118,7 +119,7 @@ function ShellDetail() {
theme.colors.background,
theme.colors.textPrimary,
);
- xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 14);
+ xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 50);
}}
onMessage={(message) => {
if (message.type === 'initialized') {
diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx
index c2e6676..3fd7fe6 100644
--- a/packages/react-native-xtermjs-webview/src/index.tsx
+++ b/packages/react-native-xtermjs-webview/src/index.tsx
@@ -66,6 +66,7 @@ export function XtermJsWebView({
// ---- RN -> WebView message sender via injectJavaScript + window MessageEvent
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;`;
@@ -148,6 +149,7 @@ export function XtermJsWebView({
onMessage={(event) => {
try {
const msg: InboundMessage = JSON.parse(event.nativeEvent.data);
+ console.log('received msg', msg);
if (msg.type === 'initialized') {
onMessage?.({ type: 'initialized' });
return;
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 08/12] 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;
From beb3b5fc6c07a9bbc8cba726728f2422be089511 Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 21:08:58 -0400
Subject: [PATCH 09/12] more stuff
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 45 +--
.../src-internal/main.tsx | 310 +++++++++---------
.../src-internal/vite-env.d.ts | 9 -
.../src/index.tsx | 2 +-
4 files changed, 176 insertions(+), 190 deletions(-)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index cd3619b..85f3c4d 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -19,8 +19,8 @@ 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 terminalReadyRef = useRef(false);
+ const pendingOutputRef = useRef([]);
const { connectionId, channelId } = useLocalSearchParams<{
connectionId?: string;
@@ -38,24 +38,19 @@ function ShellDetail() {
? RnRussh.getSshShell(String(connectionId), channelIdNum)
: undefined;
- /**
- * SSH -> xterm (remote output)
- * If xterm isn't ready yet, buffer and flush on 'initialized'.
- */
+ // SSH -> xterm (remote output). Buffer until xterm is initialized.
useEffect(() => {
if (!connection) return;
+
const xterm = xtermRef.current;
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);
});
@@ -104,7 +99,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?.();
- }}
- // xterm-flavored props for styling/behavior
+ // xterm-ish props (applied via setOptions inside the page)
fontFamily="Menlo, ui-monospace, monospace"
- fontSize={15}
+ fontSize={18} // bump if it still feels small
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.
+ onRenderProcessGone={() => {
+ console.log('WebView render process gone -> clear()');
+ xtermRef.current?.clear?.();
+ }}
+ onContentProcessDidTerminate={() => {
+ console.log('WKWebView content process terminated -> clear()');
+ xtermRef.current?.clear?.();
+ }}
onLoadEnd={() => {
console.log('WebView onLoadEnd');
}}
@@ -142,7 +133,7 @@ function ShellDetail() {
if (m.type === 'initialized') {
terminalReadyRef.current = true;
- // Flush any buffered SSH output (welcome banners, etc.)
+ // Flush buffered banner/welcome lines
if (pendingOutputRef.current.length) {
const total = pendingOutputRef.current.reduce(
(n, a) => n + a.length,
@@ -159,13 +150,11 @@ function ShellDetail() {
xtermRef.current?.flush?.();
}
- // Focus after ready to pop the soft keyboard (iOS needs this prop)
+ // Focus to pop the keyboard (iOS needs the prop we set)
xtermRef.current?.focus?.();
return;
}
if (m.type === 'data') {
- // xterm user input -> SSH
- // NOTE: msg.data is a fresh Uint8Array starting at offset 0
console.log('xterm->SSH', { len: m.data.length });
void shell?.sendData(m.data.buffer as ArrayBuffer);
return;
diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx
index 90dca6c..7d5272e 100644
--- a/packages/react-native-xtermjs-webview/src-internal/main.tsx
+++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx
@@ -3,25 +3,16 @@ import { FitAddon } from '@xterm/addon-fit';
import { Base64 } from 'js-base64';
import '@xterm/xterm/css/xterm.css';
-/**
- * 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 via vite-env.d.ts)
-window.terminal = term;
-window.fitAddon = fitAddon;
+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) => void;
+ }
+}
/**
* Post typed messages to React Native
@@ -30,158 +21,173 @@ const post = (msg: unknown) =>
window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg));
/**
- * Encode helper
+ * Idempotent boot guard: ensure we only install once.
+ * If the script happens to run twice (dev reloads, double-mounts), we bail out early.
*/
-const enc = new TextEncoder();
+if (window.__FRESSH_XTERM_BRIDGE__) {
+ post({
+ type: 'debug',
+ message: 'bridge already installed; ignoring duplicate boot',
+ });
+} else {
+ window.__FRESSH_XTERM_BRIDGE__ = true;
-/**
- * Initial handshake
- */
-setTimeout(() => post({ type: 'initialized' }), 0);
+ // ---- Xterm setup
+ const term = new Terminal({
+ allowProposedApi: true,
+ convertEol: true,
+ scrollback: 10000,
+ cursorBlink: true,
+ });
+ const fitAddon = new FitAddon();
+ term.loadAddon(fitAddon);
-/**
- * User input from xterm -> RN (SSH)
- * Send UTF-8 bytes only (Base64-encoded)
- */
-term.onData((data /* string */) => {
- const bytes = enc.encode(data);
- const b64 = Base64.fromUint8Array(bytes);
- post({ type: 'input', b64 });
-});
+ const root = document.getElementById('terminal')!;
+ term.open(root);
+ fitAddon.fit();
-/**
- * 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) 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' };
+ // Expose for debugging (typed)
+ window.terminal = term;
+ window.fitAddon = fitAddon;
- if (!msg || typeof msg.type !== 'string') return;
+ // Encode helper
+ const enc = new TextEncoder();
- 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);
+ // Initial handshake (send once)
+ setTimeout(() => post({ type: 'initialized' }), 8_000);
+
+ // 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) => {
+ 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})`,
+ });
}
- post({
- type: 'debug',
- message: `write(chunks=${msg.chunks.length})`,
- });
+ break;
}
- break;
- }
- case 'resize': {
- // Prefer fitAddon.fit(); only call resize if explicit cols/rows provided.
- 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 = {};
- 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 ?? ''})`,
- });
+ 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;
}
- break;
- }
- case 'setTheme': {
- const { background, foreground } = msg;
- const theme: Partial = {};
- if (background) {
- theme.background = background;
- document.body.style.backgroundColor = background;
+ case 'setFont': {
+ const { family, size } = msg;
+ const patch: Partial = {};
+ 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;
}
- 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 ?? ''})`,
- });
+
+ case 'setTheme': {
+ const { background, foreground } = msg;
+ 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;
}
- 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();
+ case 'setOptions': {
+ const opts = msg.opts ?? {};
+ 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;
}
- break;
- }
- case 'clear': {
- term.clear();
- post({ type: 'debug', message: 'clear()' });
- break;
- }
+ case 'clear': {
+ term.clear();
+ post({ type: 'debug', message: 'clear()' });
+ break;
+ }
- case 'focus': {
- term.focus();
- post({ type: 'debug', message: 'focus()' });
- break;
+ case 'focus': {
+ term.focus();
+ post({ type: 'debug', message: 'focus()' });
+ break;
+ }
}
+ } catch (err) {
+ post({ type: 'debug', message: `message handler error: ${String(err)}` });
}
- } catch (err) {
- post({ type: 'debug', message: `message handler error: ${String(err)}` });
- }
-});
+ };
-/**
- * Keep terminal size in sync with container
- */
-new ResizeObserver(() => {
- try {
- fitAddon.fit();
- } catch (err) {
- post({ type: 'debug', message: `resize observer error: ${String(err)}` });
- }
-});
+ window.__FRESSH_XTERM_MSG_HANDLER__ = handler;
+ window.addEventListener('message', handler);
+}
diff --git a/packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts b/packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
index 065b340..11f02fe 100644
--- a/packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
+++ b/packages/react-native-xtermjs-webview/src-internal/vite-env.d.ts
@@ -1,10 +1 @@
///
-
-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 1bb99fa..6780d43 100644
--- a/packages/react-native-xtermjs-webview/src/index.tsx
+++ b/packages/react-native-xtermjs-webview/src/index.tsx
@@ -56,7 +56,7 @@ export interface XtermJsWebViewProps
ref: React.RefObject;
onMessage?: (msg: XtermInbound) => void;
- // xterm-ish props (applied via setOptions before/after init)
+ // xterm-ish props
fontFamily?: string;
fontSize?: number;
cursorBlink?: boolean;
From 0fa28b2134a14740413a4e6c8e9502a5b1ef8ef0 Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 22:06:54 -0400
Subject: [PATCH 10/12] New rust lib passing lint
---
.../rust/uniffi-russh/src/lib.rs | 478 +++++++++++++-----
packages/react-native-uniffi-russh/src/api.ts | 381 ++++++++------
2 files changed, 580 insertions(+), 279 deletions(-)
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 0653ae9..a72febf 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
@@ -7,35 +7,22 @@
use std::collections::HashMap;
use std::fmt;
-use std::sync::{Arc, Mutex, Weak};
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::sync::{atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, Mutex, Weak};
+use std::time::{SystemTime, UNIX_EPOCH, Duration};
use rand::rngs::OsRng;
use thiserror::Error;
-use tokio::sync::Mutex as AsyncMutex;
+use tokio::sync::{broadcast, Mutex as AsyncMutex};
use russh::{self, client, ChannelMsg, Disconnect};
use russh::client::{Config as ClientConfig, Handle as ClientHandle};
use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey};
use russh_keys::ssh_key::{self, LineEnding};
-use once_cell::sync::Lazy;
+use bytes::Bytes;
uniffi::setup_scaffolding!();
-// Simpler aliases to satisfy clippy type-complexity.
-type ListenerEntry = (u64, Arc);
-type ListenerList = Vec;
-
-// Type aliases to keep static types simple and satisfy clippy.
-type ConnectionId = String;
-type ChannelId = u32;
-type ShellKey = (ConnectionId, ChannelId);
-type ConnMap = HashMap>;
-type ShellMap = HashMap>;
-
-// ---------- Global registries (strong references; lifecycle managed explicitly) ----------
-static CONNECTIONS: Lazy> = Lazy::new(|| Mutex::new(HashMap::new()));
-static SHELLS: Lazy> = Lazy::new(|| Mutex::new(HashMap::new()));
+// No global registries; handles are the only access points.
/// ---------- Types ----------
@@ -130,10 +117,30 @@ pub trait StatusListener: Send + Sync {
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,
+}
+
+#[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)]
-pub trait ChannelListener: Send + Sync {
- fn on_data(&self, data: Vec);
+pub trait ShellListener: Send + Sync {
+ fn on_event(&self, ev: ShellEvent);
}
/// Key types for generation
@@ -152,6 +159,38 @@ pub struct StartShellOptions {
pub on_status_change: Option>,
}
+#[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,
+}
+
+#[derive(Debug, Clone, PartialEq, uniffi::Record)]
+pub struct BufferReadResult {
+ pub chunks: Vec,
+ pub next_seq: u64,
+ pub dropped: Option,
+}
+
+#[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.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SshConnectionInfo {
@@ -186,10 +225,6 @@ pub struct SSHConnection {
// Weak self for child sessions to refer back without cycles.
self_weak: AsyncMutex>,
-
- // Data listeners for whatever shell is active. We track by id for removal.
- listeners: Arc>,
- next_listener_id: Arc>, // simple counter guarded by same kind of mutex
}
#[derive(uniffi::Object)]
@@ -204,20 +239,43 @@ pub struct ShellSession {
shell_status_listener: Option>,
created_at_ms: f64,
pty: PtyType,
+
+ // Ring buffer
+ ring: Arc>>>,
+ ring_bytes_capacity: Arc,
+ used_bytes: Arc>,
+ dropped_bytes_total: Arc,
+ head_seq: Arc,
+ tail_seq: Arc,
+
+ // Live broadcast
+ sender: broadcast::Sender>,
+
+ // Listener tasks management
+ listener_tasks: Arc>>>,
+ next_listener_id: AtomicU64,
+ default_coalesce_ms: AtomicU64,
}
impl fmt::Debug for SSHConnection {
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")
.field("connection_details", &self.connection_details)
.field("created_at_ms", &self.created_at_ms)
.field("tcp_established_at_ms", &self.tcp_established_at_ms)
- .field("listeners_len", &listeners_len)
.finish()
}
}
+// Internal chunk type kept in ring/broadcast
+#[derive(Debug)]
+struct Chunk {
+ seq: u64,
+ t_ms: f64,
+ stream: StreamKind,
+ bytes: Bytes,
+}
+
/// Minimal client::Handler.
struct NoopHandler;
impl client::Handler for NoopHandler {
@@ -247,21 +305,6 @@ impl SSHConnection {
}
}
- /// Add a channel listener and get an id you can later remove with.
- pub fn add_channel_listener(&self, listener: Arc) -> 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.
pub async fn start_shell(&self, opts: StartShellOptions) -> Result, SshError> {
// Prevent double-start (safe default).
@@ -286,24 +329,60 @@ impl SSHConnection {
// Split for read/write; spawn reader.
let (mut reader, writer) = ch.split();
- let listeners = self.listeners.clone();
+
+ // Setup ring + broadcast for this session
+ let (tx, _rx) = broadcast::channel::>(1024);
+ let ring = Arc::new(Mutex::new(std::collections::VecDeque::>::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 reader_task = tokio::spawn(async move {
+ let max_chunk = 16 * 1024; // 16KB
loop {
match reader.wait().await {
Some(ChannelMsg::Data { data }) => {
- if let Ok(cl) = listeners.lock() {
- let snapshot = cl.clone();
- let buf = data.to_vec();
- for (_, l) in snapshot { l.on_data(buf.clone()); }
- }
+ append_and_broadcast(
+ &data,
+ StreamKind::Stdout,
+ &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, .. }) => {
- if let Ok(cl) = listeners.lock() {
- let snapshot = cl.clone();
- let buf = data.to_vec();
- for (_, l) in snapshot { l.on_data(buf.clone()); }
- }
+ append_and_broadcast(
+ &data,
+ StreamKind::Stderr,
+ &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 => {
if let Some(sl) = shell_listener_for_task.as_ref() {
@@ -311,7 +390,7 @@ impl SSHConnection {
}
break;
}
- _ => { /* ignore others */ }
+ _ => {}
}
}
});
@@ -324,16 +403,20 @@ impl SSHConnection {
shell_status_listener,
created_at_ms: now_ms(),
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,
});
*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.
if let Some(sl) = session.shell_status_listener.as_ref() {
sl.on_change(SSHConnectionStatus::ShellConnected);
@@ -342,12 +425,7 @@ impl SSHConnection {
Ok(session)
}
- /// Send bytes to the active shell (stdin).
- pub async fn send_data(&self, data: Vec) -> Result<(), SshError> {
- let guard = self.shell.lock().await;
- let session = guard.as_ref().ok_or(SshError::Disconnected)?;
- session.send_data(data).await
- }
+ // Note: send_data now lives on ShellSession
// No exported close_shell: shell closure is handled via ShellSession::close()
@@ -360,8 +438,6 @@ impl SSHConnection {
let h = self.handle.lock().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(())
}
}
@@ -386,6 +462,160 @@ impl ShellSession {
/// Close the associated shell channel and stop its reader task.
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, coalesce_ms: Option) {
+ 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() as u64;
+ let chunks = self.ring.lock().map(|q| q.len() as u64).unwrap_or(0);
+ 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) -> BufferReadResult {
+ let max_total = max_bytes.unwrap_or(512 * 1024) as usize; // default 512KB
+ let mut out_chunks: Vec = Vec::new();
+ let mut dropped: Option = 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>) = {
+ let ring = self.ring.lock().unwrap();
+ 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> = 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, opts: ListenerOptions) -> u64 {
+ // Synchronous replay phase
+ let replay = self.read_buffer(opts.cursor.clone(), None);
+ 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 { listener.on_event(ShellEvent::Chunk(ch)); }
+
+ // Live phase
+ let mut rx = self.sender.subscribe();
+ let id = self.next_listener_id.fetch_add(1, Ordering::Relaxed);
+ 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 handle = tokio::spawn(async move {
+ let mut last_seq_seen: u64 = replay.next_seq.saturating_sub(1);
+ let mut acc: Vec = Vec::new();
+ let mut acc_stream: Option;
+ 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 = 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); }
+ 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)
@@ -403,13 +633,22 @@ impl ShellSession {
if let Some(current) = guard.as_ref() {
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(())
}
+
+ fn evict_if_needed(&self) {
+ let cap = self.ring_bytes_capacity.load(Ordering::Relaxed);
+ let mut ring = self.ring.lock().unwrap();
+ let mut used = self.used_bytes.lock().unwrap();
+ 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 ----------
@@ -459,57 +698,12 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE
handle: AsyncMutex::new(handle),
shell: AsyncMutex::new(None),
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.
*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)
}
-/// ---------- Registry/listing API ----------
-
-#[uniffi::export]
-pub fn list_ssh_connections() -> Vec {
- // Collect clones outside the lock to avoid holding a MutexGuard across await
- let conns: Vec> = 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 {
- // Collect shells outside the lock to avoid holding a MutexGuard across await
- let shells: Vec> = 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, 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, 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")]
pub async fn generate_key_pair(key_type: KeyType) -> Result {
let mut rng = OsRng;
@@ -532,3 +726,53 @@ fn now_ms() -> f64 {
.unwrap_or_default();
d.as_millis() as f64
}
+
+#[allow(clippy::too_many_arguments)]
+fn append_and_broadcast(
+ data: &[u8],
+ stream: StreamKind,
+ ring: &Arc>>>,
+ used_bytes: &Arc>,
+ ring_bytes_capacity: &Arc,
+ dropped_bytes_total: &Arc,
+ head_seq: &Arc,
+ tail_seq: &Arc,
+ next_seq: &Arc,
+ sender: &broadcast::Sender>,
+ 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 = ring.lock().unwrap();
+ q.push_back(chunk.clone());
+ }
+ {
+ let mut used = used_bytes.lock().unwrap();
+ *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 = ring.lock().unwrap();
+ 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;
+ }
+}
diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts
index ab6923c..34befea 100644
--- a/packages/react-native-uniffi-russh/src/api.ts
+++ b/packages/react-native-uniffi-russh/src/api.ts
@@ -1,7 +1,14 @@
/**
* We cannot make the generated code match this API exactly because uniffi
* - 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
*/
import * as GeneratedRussh from './index';
@@ -9,6 +16,10 @@ import * as GeneratedRussh from './index';
// #region Ideal API
+// ─────────────────────────────────────────────────────────────────────────────
+// Core types
+// ─────────────────────────────────────────────────────────────────────────────
+
export type ConnectionDetails = {
host: string;
port: number;
@@ -18,6 +29,17 @@ export type ConnectionDetails = {
| { 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 & {
onStatusChange?: (status: SshConnectionStatus) => void;
abortSignal?: AbortSignal;
@@ -27,56 +49,97 @@ export type StartShellOptions = {
pty: PtyType;
onStatusChange?: (status: SshConnectionStatus) => void;
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 = {
- connectionId: string;
+ readonly connectionId: string;
readonly createdAtMs: number;
readonly tcpEstablishedAtMs: number;
readonly connectionDetails: ConnectionDetails;
- startShell: (params: StartShellOptions) => Promise;
- addChannelListener: (listener: (data: ArrayBuffer) => void) => bigint;
- removeChannelListener: (id: bigint) => void;
- disconnect: (params?: { signal: AbortSignal }) => Promise;
+
+ startShell: (opts: StartShellOptions) => Promise;
+ disconnect: (opts?: { signal?: AbortSignal }) => Promise;
};
-export type SshShellSession = {
+export type SshShell = {
readonly channelId: number;
readonly createdAtMs: number;
- readonly pty: GeneratedRussh.PtyType;
+ readonly pty: PtyType;
readonly connectionId: string;
- sendData: (
- data: ArrayBuffer,
- options?: { signal: AbortSignal }
- ) => Promise;
- close: (params?: { signal: AbortSignal }) => Promise;
+
+ // I/O
+ sendData: (data: ArrayBuffer, opts?: { signal?: AbortSignal }) => Promise;
+ close: (opts?: { signal?: AbortSignal }) => Promise;
+
+ // Buffer policy & stats
+ setBufferPolicy: (policy: { ringBytes?: number; coalesceMs?: number }) => Promise;
+ bufferStats: () => Promise;
+ currentSeq: () => Promise;
+
+ // Replay + live
+ readBuffer: (cursor: Cursor, maxBytes?: number) => Promise;
+ addListener: (
+ cb: (ev: ListenerEvent) => void,
+ opts: ListenerOptions
+ ) => bigint;
+ removeListener: (id: bigint) => void;
};
-
type RusshApi = {
- connect: (options: ConnectOptions) => Promise;
-
- getSshConnection: (id: string) => SshConnection | undefined;
- getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined;
- listSshConnections: () => SshConnection[];
- listSshShells: () => SshShellSession[];
- listSshConnectionsWithShells: () => (SshConnection & { shells: SshShellSession[] })[];
-
- generateKeyPair: (type: PrivateKeyType) => Promise;
-
uniffiInitAsync: () => Promise;
-}
+ connect: (opts: ConnectOptions) => Promise;
+ generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519') => Promise;
+};
// #endregion
-// #region Weird stuff we have to do to get uniffi to have that ideal API
-
-const privateKeyTypeLiteralToEnum = {
- rsa: GeneratedRussh.KeyType.Rsa,
- ecdsa: GeneratedRussh.KeyType.Ecdsa,
- ed25519: GeneratedRussh.KeyType.Ed25519,
-} as const satisfies Record;
-export type PrivateKeyType = keyof typeof privateKeyTypeLiteralToEnum;
-
+// #region Wrapper to match the ideal API
const ptyTypeLiteralToEnum = {
Vanilla: GeneratedRussh.PtyType.Vanilla,
@@ -87,8 +150,16 @@ const ptyTypeLiteralToEnum = {
Xterm: GeneratedRussh.PtyType.Xterm,
Xterm256: GeneratedRussh.PtyType.Xterm256,
} as const satisfies Record;
-export type PtyType = keyof typeof ptyTypeLiteralToEnum;
+const ptyEnumToLiteral: Record = {
+ [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 = {
[GeneratedRussh.SshConnectionStatus.TcpConnecting]: 'tcpConnecting',
@@ -97,160 +168,151 @@ const sshConnStatusEnumToLiteral = {
[GeneratedRussh.SshConnectionStatus.ShellConnecting]: 'shellConnecting',
[GeneratedRussh.SshConnectionStatus.ShellConnected]: 'shellConnected',
[GeneratedRussh.SshConnectionStatus.ShellDisconnected]: 'shellDisconnected',
-} as const satisfies Record;
-export type SshConnectionStatus = (typeof sshConnStatusEnumToLiteral)[keyof typeof sshConnStatusEnumToLiteral];
+} as const satisfies Record;
+const streamEnumToLiteral = {
+ [GeneratedRussh.StreamKind.Stdout]: 'stdout',
+ [GeneratedRussh.StreamKind.Stderr]: 'stderr',
+} as const satisfies Record;
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 {
- host: details.host,
- port: details.port,
- username: details.username,
- security: details.security instanceof GeneratedRussh.Security.Password ? { type: 'password', password: details.security.inner.password } : { type: 'key', privateKey: details.security.inner.keyId },
+ seq: Number(ch.seq),
+ tMs: ch.tMs,
+ stream: streamEnumToLiteral[ch.stream],
+ bytes: new Uint8Array(ch.bytes as any),
};
}
-function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection {
- // 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 {
+function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell {
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;
+
+ const id = shell.addListener(listener, { cursor: cursorToGenerated(opts.cursor), coalesceMs: opts.coalesceMs });
+ return BigInt(id);
+ };
+
return {
channelId: info.channelId,
createdAtMs: info.createdAtMs,
- pty: info.pty,
+ pty: ptyEnumToLiteral[info.pty],
connectionId: info.connectionId,
- sendData: shell.sendData.bind(shell),
- close: shell.close.bind(shell)
+ sendData: (data, o) => shell.sendData(data, o?.signal ? { signal: o.signal } : undefined),
+ 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 {
const security =
options.security.type === 'password'
- ? new GeneratedRussh.Security.Password({
- password: options.security.password,
- })
+ ? new GeneratedRussh.Security.Password({ password: options.security.password })
: new GeneratedRussh.Security.Key({ keyId: options.security.privateKey });
- const sshConnectionInterface = await GeneratedRussh.connect(
+ const sshConnection = await GeneratedRussh.connect(
{
host: options.host,
port: options.port,
username: options.username,
security,
onStatusChange: options.onStatusChange ? {
- onChange: (statusEnum) => {
- const tsLiteral = sshConnStatusEnumToLiteral[statusEnum];
- if (!tsLiteral) throw new Error(`Invalid status enum: ${statusEnum}`);
- options.onStatusChange?.(tsLiteral);
- },
+ onChange: (statusEnum) => options.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum])
} : undefined,
},
- options.abortSignal
- ? {
- signal: options.abortSignal,
- }
- : undefined
+ options.abortSignal ? { signal: options.abortSignal } : undefined,
);
- return wrapConnection(sshConnectionInterface);
+ return wrapConnection(sshConnection);
}
-// Optional registry lookups: return undefined if not found/disconnected
-function getSshConnection(id: string): SshConnection | undefined {
- try {
- const conn = GeneratedRussh.getSshConnection(id);
- return wrapConnection(conn);
- } catch {
- return undefined;
- }
+async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') {
+ const map = { rsa: GeneratedRussh.KeyType.Rsa, ecdsa: GeneratedRussh.KeyType.Ecdsa, ed25519: GeneratedRussh.KeyType.Ed25519 } as const;
+ return GeneratedRussh.generateKeyPair(map[type]);
}
-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
@@ -258,9 +320,4 @@ export const RnRussh = {
uniffiInitAsync: GeneratedRussh.uniffiInitAsync,
connect,
generateKeyPair,
- getSshConnection,
- listSshConnections,
- listSshShells,
- listSshConnectionsWithShells,
- getSshShell,
} satisfies RusshApi;
From 7c448e2ec373d8381f0263036f5dc9456f6ca0f5 Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Wed, 17 Sep 2025 23:12:39 -0400
Subject: [PATCH 11/12] new broken
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 113 +++++++++++-------
apps/mobile/src/app/(tabs)/shell/index.tsx | 26 ++--
apps/mobile/src/lib/query-fns.ts | 28 +++--
apps/mobile/src/lib/ssh-registry.ts | 108 +++++++++++++++++
.../src/index.tsx | 11 ++
5 files changed, 215 insertions(+), 71 deletions(-)
create mode 100644 apps/mobile/src/lib/ssh-registry.ts
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index 85f3c4d..8915c54 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -1,26 +1,56 @@
import { Ionicons } from '@expo/vector-icons';
-import { RnRussh } from '@fressh/react-native-uniffi-russh';
+import {
+ type ListenerEvent,
+ type TerminalChunk,
+} from '@fressh/react-native-uniffi-russh';
import {
XtermJsWebView,
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 } from 'react';
-import { Pressable, View } from 'react-native';
+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 { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
+import { getSession } from '@/lib/ssh-registry';
import { useTheme } from '@/lib/theme';
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 ;
return ;
}
+function RouteSkeleton() {
+ return (
+
+ Loading
+
+ );
+}
+
function ShellDetail() {
const xtermRef = useRef(null);
const terminalReadyRef = useRef(false);
- const pendingOutputRef = useRef([]);
+ // Legacy buffer no longer used; relying on Rust ring for replay
+ const listenerIdRef = useRef(null);
const { connectionId, channelId } = useLocalSearchParams<{
connectionId?: string;
@@ -30,36 +60,23 @@ function ShellDetail() {
const theme = useTheme();
const channelIdNum = Number(channelId);
- const connection = connectionId
- ? RnRussh.getSshConnection(String(connectionId))
- : undefined;
- const shell =
+ const sess =
connectionId && channelId
- ? RnRussh.getSshShell(String(connectionId), channelIdNum)
+ ? getSession(String(connectionId), channelIdNum)
: undefined;
+ const connection = sess?.connection;
+ const shell = sess?.shell;
- // SSH -> xterm (remote output). Buffer until xterm is initialized.
+ // SSH -> xterm: on initialized, replay ring head then attach live listener
useEffect(() => {
- if (!connection) return;
-
const xterm = xtermRef.current;
-
- const listenerId = connection.addChannelListener((ab: ArrayBuffer) => {
- const bytes = new Uint8Array(ab);
- if (!terminalReadyRef.current) {
- pendingOutputRef.current.push(bytes);
- console.log('SSH->buffer', { len: bytes.length });
- return;
- }
- console.log('SSH->xterm', { len: bytes.length });
- xterm?.write(bytes);
- });
-
return () => {
- connection.removeChannelListener(listenerId);
+ if (shell && listenerIdRef.current != null)
+ shell.removeListener(listenerIdRef.current);
+ listenerIdRef.current = null;
xterm?.flush?.();
};
- }, [connection]);
+ }, [shell]);
const queryClient = useQueryClient();
@@ -133,21 +150,33 @@ function ShellDetail() {
if (m.type === 'initialized') {
terminalReadyRef.current = true;
- // Flush buffered banner/welcome lines
- 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?.();
+ // Replay from head, then attach live listener
+ if (shell) {
+ void (async () => {
+ const res = await shell.readBuffer({ mode: 'head' });
+ console.log('readBuffer(head)', {
+ chunks: res.chunks.length,
+ nextSeq: res.nextSeq,
+ dropped: res.dropped,
+ });
+ if (res.chunks.length) {
+ const chunks = res.chunks.map((c) => c.bytes);
+ xtermRef.current?.writeMany?.(chunks);
+ xtermRef.current?.flush?.();
+ }
+ const id = shell.addListener(
+ (ev: ListenerEvent) => {
+ if ('kind' in ev && ev.kind === 'dropped') {
+ console.log('listener.dropped', ev);
+ return;
+ }
+ const chunk = ev as TerminalChunk;
+ xtermRef.current?.write(chunk.bytes);
+ },
+ { cursor: { mode: 'live' } },
+ );
+ listenerIdRef.current = id;
+ })();
}
// Focus to pop the keyboard (iOS needs the prop we set)
diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx
index f364ff0..20ca6d3 100644
--- a/apps/mobile/src/app/(tabs)/shell/index.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/index.tsx
@@ -1,8 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
-import {
- type RnRussh,
- type SshConnection,
-} from '@fressh/react-native-uniffi-russh';
+import { type SshConnection } from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
@@ -24,6 +21,7 @@ import {
listSshShellsQueryOptions,
type ShellWithConnection,
} from '@/lib/query-fns';
+import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
import { useTheme } from '@/lib/theme';
export default function TabsShellList() {
@@ -80,11 +78,9 @@ type ActionTarget =
connection: SshConnection;
};
-function LoadedState({
- connections,
-}: {
- connections: ReturnType;
-}) {
+type ConnectionsList = ReturnType;
+
+function LoadedState({ connections }: { connections: ConnectionsList }) {
const [actionTarget, setActionTarget] = React.useState(
null,
);
@@ -137,9 +133,7 @@ function FlatView({
connectionsWithShells,
setActionTarget,
}: {
- connectionsWithShells: ReturnType<
- typeof RnRussh.listSshConnectionsWithShells
- >;
+ connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void;
}) {
const flatShells = React.useMemo(() => {
@@ -149,7 +143,7 @@ function FlatView({
}, []);
}, [connectionsWithShells]);
return (
-
data={flatShells}
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
renderItem={({ item }) => (
@@ -176,15 +170,13 @@ function GroupedView({
connectionsWithShells,
setActionTarget,
}: {
- connectionsWithShells: ReturnType<
- typeof RnRussh.listSshConnectionsWithShells
- >;
+ connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void;
}) {
const theme = useTheme();
const [expanded, setExpanded] = React.useState>({});
return (
-
data={connectionsWithShells}
// estimatedItemSize={80}
keyExtractor={(item) => item.connectionId}
diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts
index 639600f..76c164d 100644
--- a/apps/mobile/src/lib/query-fns.ts
+++ b/apps/mobile/src/lib/query-fns.ts
@@ -1,8 +1,4 @@
-import {
- RnRussh,
- type SshConnection,
- type SshShellSession,
-} from '@fressh/react-native-uniffi-russh';
+import { RnRussh } from '@fressh/react-native-uniffi-russh';
import {
queryOptions,
useMutation,
@@ -11,6 +7,11 @@ import {
} from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
+import {
+ listConnectionsWithShells as registryList,
+ registerSession,
+ type ShellWithConnection,
+} from './ssh-registry';
import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = () => {
@@ -57,6 +58,9 @@ export const useSshConnMutation = () => {
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
console.log('Connected to SSH server', connectionId, channelId);
+ // Track in registry for app use
+ registerSession(sshConnection, shellInterface);
+
await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
@@ -77,19 +81,17 @@ export const useSshConnMutation = () => {
export const listSshShellsQueryOptions = queryOptions({
queryKey: ['ssh-shells'],
- queryFn: () => RnRussh.listSshConnectionsWithShells(),
+ queryFn: () => registryList(),
});
-export type ShellWithConnection = SshShellSession & {
- connection: SshConnection;
-};
+export type { ShellWithConnection };
export const closeSshShellAndInvalidateQuery = async (params: {
channelId: number;
connectionId: string;
queryClient: QueryClient;
}) => {
- const currentActiveShells = RnRussh.listSshConnectionsWithShells();
+ const currentActiveShells = registryList();
const connection = currentActiveShells.find(
(c) => c.connectionId === params.connectionId,
);
@@ -97,7 +99,6 @@ export const closeSshShellAndInvalidateQuery = async (params: {
const shell = connection.shells.find((s) => s.channelId === params.channelId);
if (!shell) throw new Error('Shell not found');
await shell.close();
- if (connection.shells.length <= 1) await connection.disconnect();
await params.queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
@@ -107,7 +108,10 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
connectionId: string;
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');
await connection.disconnect();
await params.queryClient.invalidateQueries({
diff --git a/apps/mobile/src/lib/ssh-registry.ts b/apps/mobile/src/lib/ssh-registry.ts
new file mode 100644
index 0000000..ec3f6b2
--- /dev/null
+++ b/apps/mobile/src/lib/ssh-registry.ts
@@ -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();
+
+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();
+ 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[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 {}
+ }
+}
diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx
index 6780d43..c0fff7d 100644
--- a/packages/react-native-xtermjs-webview/src/index.tsx
+++ b/packages/react-native-xtermjs-webview/src/index.tsx
@@ -35,6 +35,8 @@ export type XtermInbound =
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;
@@ -127,8 +129,17 @@ export function XtermJsWebView({
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 }),
From 808f476bac05d204d46f57e314d56633f6bd3f11 Mon Sep 17 00:00:00 2001
From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com>
Date: Thu, 18 Sep 2025 00:17:35 -0400
Subject: [PATCH 12/12] Working xtermjs!
---
apps/mobile/src/app/(tabs)/shell/detail.tsx | 9 ++-
.../rust/uniffi-russh/src/lib.rs | 55 +++++++++++++------
packages/react-native-uniffi-russh/src/api.ts | 11 +++-
.../src-internal/main.tsx | 2 +-
4 files changed, 55 insertions(+), 22 deletions(-)
diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx
index 8915c54..a28a0e8 100644
--- a/apps/mobile/src/app/(tabs)/shell/detail.tsx
+++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx
@@ -148,6 +148,7 @@ function ShellDetail() {
onMessage={(m) => {
console.log('received msg', m);
if (m.type === 'initialized') {
+ if (terminalReadyRef.current) return;
terminalReadyRef.current = true;
// Replay from head, then attach live listener
@@ -173,8 +174,9 @@ function ShellDetail() {
const chunk = ev as TerminalChunk;
xtermRef.current?.write(chunk.bytes);
},
- { cursor: { mode: 'live' } },
+ { cursor: { mode: 'seq', seq: res.nextSeq } },
);
+ console.log('shell listener attached', id.toString());
listenerIdRef.current = id;
})();
}
@@ -185,7 +187,10 @@ function ShellDetail() {
}
if (m.type === 'data') {
console.log('xterm->SSH', { len: m.data.length });
- void shell?.sendData(m.data.buffer as ArrayBuffer);
+ // 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') {
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 a72febf..710f06c 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
@@ -255,6 +255,7 @@ pub struct ShellSession {
listener_tasks: Arc>>>,
next_listener_id: AtomicU64,
default_coalesce_ms: AtomicU64,
+ rt_handle: tokio::runtime::Handle,
}
impl fmt::Debug for SSHConnection {
@@ -324,7 +325,20 @@ impl SSHConnection {
let channel_id: u32 = ch.id().into();
// 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?;
// Split for read/write; spawn reader.
@@ -413,6 +427,7 @@ impl SSHConnection {
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());
@@ -471,8 +486,8 @@ impl ShellSession {
/// Buffer statistics snapshot.
pub fn buffer_stats(&self) -> BufferStats {
- let used = *self.used_bytes.lock().unwrap() as u64;
- let chunks = self.ring.lock().map(|q| q.len() as u64).unwrap_or(0);
+ 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,
@@ -496,7 +511,7 @@ impl ShellSession {
// Lock ring to determine start and collect arcs, then drop lock.
let (_start_idx_unused, _start_seq, arcs): (usize, u64, Vec>) = {
- let ring = self.ring.lock().unwrap();
+ 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 } => {
@@ -540,19 +555,25 @@ impl ShellSession {
}
/// Add a listener with optional replay and live follow.
- pub fn add_listener(&self, listener: Arc, opts: ListenerOptions) -> u64 {
- // Synchronous replay phase
+ pub fn add_listener(&self, listener: Arc, opts: ListenerOptions) -> Result {
+ // Snapshot for replay; emit from task to avoid re-entrant callbacks during FFI.
let replay = self.read_buffer(opts.cursor.clone(), None);
- 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 { listener.on_event(ShellEvent::Chunk(ch)); }
-
- // Live phase
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 handle = tokio::spawn(async move {
+ 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 = Vec::new();
let mut acc_stream: Option;
@@ -608,7 +629,7 @@ impl ShellSession {
}
});
if let Ok(mut map) = self.listener_tasks.lock() { map.insert(id, handle); }
- id
+ Ok(id)
}
pub fn remove_listener(&self, id: u64) {
@@ -639,8 +660,8 @@ impl ShellSession {
fn evict_if_needed(&self) {
let cap = self.ring_bytes_capacity.load(Ordering::Relaxed);
- let mut ring = self.ring.lock().unwrap();
- let mut used = self.used_bytes.lock().unwrap();
+ 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();
@@ -750,17 +771,17 @@ fn append_and_broadcast(
let chunk = Arc::new(Chunk { seq, t_ms, stream, bytes: Bytes::copy_from_slice(slice) });
// push to ring
{
- let mut q = ring.lock().unwrap();
+ 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();
+ 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 = ring.lock().unwrap();
+ 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();
diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts
index 34befea..97f5385 100644
--- a/packages/react-native-uniffi-russh/src/api.ts
+++ b/packages/react-native-uniffi-russh/src/api.ts
@@ -245,8 +245,15 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell
}
} satisfies GeneratedRussh.ShellListener;
- const id = shell.addListener(listener, { cursor: cursorToGenerated(opts.cursor), coalesceMs: opts.coalesceMs });
- return BigInt(id);
+ 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 {
diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx
index 7d5272e..7b0b51e 100644
--- a/packages/react-native-xtermjs-webview/src-internal/main.tsx
+++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx
@@ -54,7 +54,7 @@ if (window.__FRESSH_XTERM_BRIDGE__) {
const enc = new TextEncoder();
// Initial handshake (send once)
- setTimeout(() => post({ type: 'initialized' }), 8_000);
+ setTimeout(() => post({ type: 'initialized' }), 500);
// User input from xterm -> RN (SSH) as UTF-8 bytes (Base64)
term.onData((data /* string */) => {