Better xtermjs api

This commit is contained in:
EthanShoeDev
2025-09-18 21:36:40 -04:00
parent 9e789d23be
commit 519de821e2
6 changed files with 439 additions and 403 deletions

View File

@@ -49,7 +49,7 @@
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.8",
"expo-linking": "~8.0.8",
"expo-router": "6.0.6",
"expo-router": "6.0.7",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
@@ -72,28 +72,28 @@
},
"devDependencies": {
"@epic-web/config": "^1.21.3",
"@types/react": "~19.1.12",
"cmd-ts": "^0.14.1",
"eslint": "^9.35.0",
"@eslint/js": "^9.35.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
"@eslint-react/eslint-plugin": "^1.53.0",
"@eslint/js": "^9.35.0",
"@tanstack/eslint-plugin-query": "^5.86.0",
"@types/react": "~19.1.12",
"@typescript-eslint/parser": "^8.44.0",
"@typescript-eslint/utils": "^8.43.0",
"cmd-ts": "^0.14.1",
"eslint": "^9.35.0",
"eslint-config-expo": "~10.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.4.0",
"eslint-plugin-react-refresh": "^0.4.20",
"typescript-eslint": "^8.44.0",
"eslint-config-expo": "~10.0.0",
"globals": "^16.4.0",
"jiti": "^2.5.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.2.0",
"tsx": "^4.20.5",
"typescript": "~5.9.2"
"typescript": "~5.9.2",
"typescript-eslint": "^8.44.0"
},
"expo": {
"doctor": {

View File

@@ -50,6 +50,8 @@ function RouteSkeleton() {
);
}
const encoder = new TextEncoder();
function ShellDetail() {
const xtermRef = useRef<XtermWebViewHandle>(null);
const terminalReadyRef = useRef(false);
@@ -151,100 +153,72 @@ function ShellDetail() {
<XtermJsWebView
ref={xtermRef}
style={{ flex: 1 }}
// WebView behavior that suits terminals
keyboardDisplayRequiresUserAction={false}
setSupportMultipleWindows={false}
overScrollMode="never"
pullToRefreshEnabled={false}
bounces={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
allowsLinkPreview={false}
textInteractionEnabled={false}
logger={{
log: console.log,
debug: console.log,
warn: console.warn,
error: console.error,
}}
// xterm options
options={{
fontFamily: 'Menlo, ui-monospace, monospace',
fontSize: 80,
cursorBlink: true,
scrollback: 10000,
xtermOptions={{
theme: {
background: theme.colors.background,
foreground: theme.colors.textPrimary,
},
}}
onRenderProcessGone={() => {
console.log('WebView render process gone -> clear()');
const xr = xtermRef.current;
if (xr) xr.clear();
}}
onContentProcessDidTerminate={() => {
console.log('WKWebView content process terminated -> clear()');
const xr = xtermRef.current;
if (xr) xr.clear();
}}
onLoadEnd={() => {
console.log('WebView onLoadEnd');
}}
onMessage={(m) => {
console.log('received msg', m);
if (m.type === 'initialized') {
if (terminalReadyRef.current) return;
terminalReadyRef.current = true;
onInitialized={() => {
if (terminalReadyRef.current) return;
terminalReadyRef.current = true;
// 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);
const xr = xtermRef.current;
if (xr) {
xr.writeMany(chunks);
xr.flush();
}
}
const id = shell.addListener(
(ev: ListenerEvent) => {
if ('kind' in ev) {
console.log('listener.dropped', ev);
return;
}
const chunk = ev;
const xr3 = xtermRef.current;
if (xr3) xr3.write(chunk.bytes);
},
{ cursor: { mode: 'seq', seq: res.nextSeq } },
);
console.log('shell listener attached', id.toString());
listenerIdRef.current = id;
})();
}
// Focus to pop the keyboard (iOS needs the prop we set)
const xr2 = xtermRef.current;
if (xr2) xr2.focus();
return;
}
if (m.type === 'data') {
console.log('xterm->SSH', { len: m.data.length });
const { buffer, byteOffset, byteLength } = m.data;
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
if (shell) {
shell.sendData(ab as ArrayBuffer).catch((e: unknown) => {
console.warn('sendData failed', e);
router.back();
// 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,
});
}
return;
} else {
console.log('xterm.debug', m.message);
if (res.chunks.length) {
const chunks = res.chunks.map((c) => c.bytes);
const xr = xtermRef.current;
if (xr) {
xr.writeMany(chunks);
xr.flush();
}
}
const id = shell.addListener(
(ev: ListenerEvent) => {
if ('kind' in ev) {
console.log('listener.dropped', ev);
return;
}
const chunk = ev;
const xr3 = xtermRef.current;
if (xr3) xr3.write(chunk.bytes);
},
{ cursor: { mode: 'seq', seq: res.nextSeq } },
);
console.log('shell listener attached', id.toString());
listenerIdRef.current = id;
})();
}
// Focus to pop the keyboard (iOS needs the prop we set)
const xr2 = xtermRef.current;
if (xr2) xr2.focus();
return;
}}
onData={(terminalMessage) => {
if (!shell) return;
const bytes = encoder.encode(terminalMessage);
if (shell) {
shell.sendData(bytes.buffer).catch((e: unknown) => {
console.warn('sendData failed', e);
router.back();
});
}
return;
}}
/>
</SafeAreaView>