mirror of
https://github.com/tale/headplane.git
synced 2026-08-11 14:26:56 +00:00
feat: support resizing and other xterm.js addons
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { decode } from 'cborg';
|
||||
import type { SSHFrameData } from '~/server/agent/dispatcher';
|
||||
import { decode, encode } from 'cborg';
|
||||
import type {
|
||||
SSHDataCommand,
|
||||
SSHFrameData,
|
||||
SSHResizeCommand,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
interface XTermProps {
|
||||
@@ -19,19 +29,40 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
|
||||
const gl = new WebglAddon();
|
||||
terminal.loadAddon(gl);
|
||||
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(fit);
|
||||
|
||||
gl.onContextLoss(() => {
|
||||
console.warn('WebGL context lost, falling back to canvas rendering');
|
||||
gl.dispose();
|
||||
});
|
||||
|
||||
terminal.open(container.current!);
|
||||
terminal.focus();
|
||||
term.current = terminal;
|
||||
@@ -63,7 +94,15 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
terminal.onData((input) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(input);
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_data',
|
||||
payload: {
|
||||
sessionId,
|
||||
data: new TextEncoder().encode(input),
|
||||
},
|
||||
} satisfies SSHDataCommand),
|
||||
);
|
||||
} else {
|
||||
console.warn('WebSocket is not open, cannot send data');
|
||||
}
|
||||
@@ -80,12 +119,57 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
const ro = new ResizeObserver(() => {
|
||||
const before = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
|
||||
fit.fit();
|
||||
if (before.cols !== terminal.cols || before.rows !== terminal.rows) {
|
||||
console.log(
|
||||
`Resized terminal to ${terminal.cols} cols and ${terminal.rows} rows`,
|
||||
);
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_resize',
|
||||
payload: {
|
||||
sessionId,
|
||||
width: terminal.cols,
|
||||
height: terminal.rows,
|
||||
},
|
||||
} satisfies SSHResizeCommand),
|
||||
);
|
||||
|
||||
setIsResizing(true);
|
||||
setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(container.current!);
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
term.current?.dispose();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [ws, queue]);
|
||||
|
||||
return <div ref={container} style={{ height: '100%', width: '100%' }} />;
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
<div ref={container} className="w-full h-full" />
|
||||
|
||||
{term.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||
)}
|
||||
>
|
||||
{term.current.cols}x{term.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Command {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface SSHConnectCommand extends Command {
|
||||
export interface SSHConnectCommand extends Command {
|
||||
op: 'ssh_conn';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
@@ -18,14 +18,31 @@ interface SSHConnectCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
interface SSHCloseCommand extends Command {
|
||||
export interface SSHCloseCommand extends Command {
|
||||
op: 'ssh_term';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand | SSHCloseCommand;
|
||||
export interface SSHResizeCommand extends Command {
|
||||
op: 'ssh_resize';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSHDataCommand extends Command {
|
||||
op: 'ssh_data';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
data: Uint8Array;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand | SSHCloseCommand | SSHResizeCommand;
|
||||
|
||||
export async function dispatchCommand(
|
||||
dispatcher: Writable,
|
||||
|
||||
@@ -13,7 +13,7 @@ export type ChannelType = 0 | 1 | 2;
|
||||
interface SSHFrame {
|
||||
sessionId: string;
|
||||
channel: ChannelType;
|
||||
payload: Blob | ArrayBufferLike | string;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
@@ -23,17 +23,9 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = Buffer.isBuffer(frame.payload)
|
||||
? frame.payload
|
||||
: typeof frame.payload === 'string'
|
||||
? Buffer.from(frame.payload, 'utf8')
|
||||
: frame.payload instanceof Blob
|
||||
? Buffer.from(await frame.payload.arrayBuffer())
|
||||
: Buffer.from(frame.payload);
|
||||
|
||||
// Size can only hold 4 bytes
|
||||
if (payload.length > 0xffffffff) {
|
||||
log.error('agent', 'SSH payload too large: %d bytes', payload.length);
|
||||
if (frame.payload.length > 0xffffffff) {
|
||||
log.error('agent', 'SSH payload too large: %d bytes', frame.payload.length);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +34,7 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
1 + // Version
|
||||
1 + // Channel Type
|
||||
(1 + sid.length) + // Session ID length + SID
|
||||
(4 + payload.length); // Payload length + Payload
|
||||
(4 + frame.payload.length); // Payload length + Payload
|
||||
|
||||
const buf = Buffer.alloc(frameSize);
|
||||
buf.write(MAGIC, 0, 'utf8');
|
||||
@@ -53,8 +45,8 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
const offset = 7 + sid.length;
|
||||
sid.copy(buf, 7);
|
||||
|
||||
buf.writeUInt32BE(payload.length, offset);
|
||||
payload.copy(buf, offset + 4);
|
||||
buf.writeUInt32BE(frame.payload.length, offset);
|
||||
frame.payload.copy(buf, offset + 4);
|
||||
return buf;
|
||||
}
|
||||
|
||||
|
||||
+49
-7
@@ -1,10 +1,17 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { decode } from 'cborg';
|
||||
import { Context } from 'hono';
|
||||
import { WSContext, WSEvents } from 'hono/ws';
|
||||
import log from '~/utils/log';
|
||||
import { dispatchCommand, dispatchWeb } from './dispatcher';
|
||||
import {
|
||||
Command,
|
||||
SSHDataCommand,
|
||||
SSHResizeCommand,
|
||||
dispatchCommand,
|
||||
dispatchWeb,
|
||||
} from './dispatcher';
|
||||
import { decodeSSHFrame, encodeSSHFrame } from './encoder';
|
||||
|
||||
interface SSHConnection {
|
||||
@@ -134,13 +141,48 @@ export class SSHMultiplexer {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedFrame = await encodeSSHFrame({
|
||||
sessionId,
|
||||
channel: 0, // stdin
|
||||
payload: event.data,
|
||||
});
|
||||
const wsData = Buffer.isBuffer(event.data)
|
||||
? event.data
|
||||
: typeof event.data === 'string'
|
||||
? Buffer.from(event.data, 'utf8')
|
||||
: event.data instanceof Blob
|
||||
? Buffer.from(await event.data.arrayBuffer())
|
||||
: Buffer.from(event.data);
|
||||
|
||||
this.sshInput.write(encodedFrame);
|
||||
const obj = decode(wsData) as Command;
|
||||
if (obj.op === 'ssh_data') {
|
||||
const data = obj as SSHDataCommand;
|
||||
if (data.payload.sessionId !== sessionId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Received data for mismatched SSH session %s',
|
||||
data.payload.sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedFrame = await encodeSSHFrame({
|
||||
sessionId,
|
||||
channel: 0, // stdin
|
||||
payload: Buffer.from(data.payload.data),
|
||||
});
|
||||
|
||||
this.sshInput.write(encodedFrame);
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_resize') {
|
||||
const resize = obj as SSHResizeCommand;
|
||||
if (resize.payload.sessionId !== sessionId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Received resize for mismatched SSH session %s',
|
||||
resize.payload.sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatchCommand(this.control, resize);
|
||||
}
|
||||
},
|
||||
|
||||
onClose: async (_, ws) => {
|
||||
|
||||
Reference in New Issue
Block a user