feat: add rust browser terminal prototype

This commit is contained in:
zhangheng
2026-06-08 16:34:13 +08:00
commit fd056ce502
43 changed files with 10312 additions and 0 deletions

View File

@@ -0,0 +1,568 @@
# Rust Browser Terminal Development Guide
本指南用于在 `base-path-demo` 中实现一套 Rust 终端能力:
- 后端Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
- 前端Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
- 沉淀:先在业务项目中跑通,再拆成可复用 Rust 终端组件库。
## 1. 核心判断
浏览器终端必须拆成两层:
```text
Browser Rust/WASM Terminal Component
<-> WebSocket protocol
Axum terminal session service
<-> portable-pty master
Shell process: bash/zsh/fish/vim/top
```
浏览器里的 WASM 不能直接创建系统 PTY因为浏览器沙箱不能启动本机 shell。PTY 必须在 Rust native 后端或桌面端创建。浏览器终端组件只做终端仿真、渲染和用户输入采集。
`portable-pty` 是 Rust crate提供跨平台 PTY API。它适合放在后端适配层不应该放进浏览器 WASM 组件。
## 2. 目标架构
建议分四层沉淀。
```text
base-path-demo/
app/
src/
pages/terminal.rs
terminal/
mod.rs
component.rs
core.rs
dom_renderer.rs
keyboard.rs
protocol.rs
server/
src/
terminal/
mod.rs
pty_session.rs
ws.rs
main.rs
```
后续抽库时再拆成 workspace crates
```text
crates/
terminal-core/ # no web-sys, no axum; parser/buffer/cursor/style
terminal-leptos/ # Leptos component and browser renderer
terminal-protocol/ # shared client/server message types
terminal-pty-server/ # portable-pty + Axum integration
```
第一阶段可以先在 `app/src/terminal``server/src/terminal` 内实现,等 API 稳定后再抽 crate。这样比一开始拆库更快也能减少早期重构成本。
## 3. 依赖规划
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
建议先调整 `base-path-demo/Cargo.toml`
```toml
[workspace.dependencies]
axum = { version = "0.8", features = ["ws"] }
futures-util = "0.3"
portable-pty = "0.9"
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
vt100 = "0.16"
```
`server/Cargo.toml` 建议增加:
```toml
[dependencies]
futures-util.workspace = true
portable-pty.workspace = true
```
`app/Cargo.toml` 建议增加:
```toml
[dependencies]
vt100.workspace = true
```
前端需要扩展 `web-sys` features
```toml
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
"Blob",
"CloseEvent",
"Document",
"Element",
"ErrorEvent",
"Event",
"EventTarget",
"HtmlElement",
"KeyboardEvent",
"MessageEvent",
"Url",
"WebSocket",
"Window",
] }
```
说明:
- `portable-pty` 只用于 native server。
- `vt100` 可先用于 WASM 端解析字节流并维护屏幕状态,后续再替换为自研 `terminal-core`
- `futures-util` 用于 Axum WebSocket split。
## 4. WebSocket 协议
先用简单、可调试的协议,不要过早优化。
浏览器到服务端:
```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientTerminalMessage {
Input { data: String },
Resize {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
},
Ping,
}
```
服务端到浏览器:
```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerTerminalMessage {
Output { data: String },
Exit { code: Option<i32> },
Error { message: String },
Pong,
}
```
MVP 可以先用 JSON 文本传输。稳定后再优化为:
- 控制消息走 JSON text frame。
- PTY output/input 走 binary frame。
- 大输出使用批处理,避免每个字节触发一次 render。
## 5. 后端实现计划
### 5.1 路由
`server/src/main.rs` 中新增 WebSocket route。
因为当前项目有 `SiteConfig::BASE_PATH = "/rustui"` 的场景,建议同时支持:
```text
/terminal/ws
/rustui/terminal/ws
```
挂载位置建议放在 `leptos_router` 外层,避免被 SSR fallback 吃掉。
目标形态:
```rust
let terminal_routes = Router::new()
.route("/terminal/ws", axum::routing::get(terminal_ws_handler));
let app = if SiteConfig::BASE_PATH.is_empty() {
terminal_routes.merge(leptos_router)
} else {
Router::new()
.route("/rustui/api/{*fn_name}", axum::routing::post(handle_server_fns))
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
.merge(terminal_routes.clone())
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
};
```
### 5.2 PTY session
新增 `server/src/terminal/pty_session.rs`
职责:
- 创建 PTY。
- 启动 shell。
- 拿到 master reader/writer。
- 支持 resize。
- 在断开连接时终止 child。
核心流程:
```rust
use portable_pty::{CommandBuilder, PtySize, PtySystem, native_pty_system};
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows,
cols,
pixel_width,
pixel_height,
})?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_owned());
let child = pair.slave.spawn_command(CommandBuilder::new(shell))?;
let reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
```
注意:
- `portable-pty` 的 reader/writer 是阻塞 IO放进 `tokio::task::spawn_blocking` 或专用线程,不要直接阻塞 async runtime。
- 每个 WebSocket 连接对应一个 PTY session。
- socket close、read error、child exit 时要清理 session。
- 初版只允许启动固定 shell不允许前端传任意命令。
### 5.3 WebSocket 桥接
新增 `server/src/terminal/ws.rs`
需要两个方向的任务:
```text
PTY reader thread -> mpsc -> WebSocket sender
WebSocket receiver -> PTY writer
```
建议结构:
```rust
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(handle_terminal_socket)
}
async fn handle_terminal_socket(socket: WebSocket) {
let session = PtySession::spawn(default_size)?;
let (mut ws_sender, mut ws_receiver) = socket.split();
// Task A: PTY output -> WS output.
// Task B: WS input/resize -> PTY.
// On either side ending, abort/cleanup the other side.
}
```
MVP 验收标准:
- 打开页面能看到 shell prompt。
- 输入 `pwd` 能看到输出。
- 输入 `top``vim` 不会把 UI 卡死。
- 浏览器断开后后端 child 被终止。
## 6. 前端实现计划
### 6.1 页面接入
新增:
```text
app/src/pages/terminal.rs
app/src/terminal/mod.rs
```
修改:
- `app/src/pages/mod.rs` 暴露 terminal page。
- `app/src/app.rs` 增加 `/terminal` route。
- `app/src/menu_items.rs` 增加导航项。
建议页面名:
```rust
#[component]
pub fn TerminalPage() -> impl IntoView {
view! {
<AdminShell>
<TerminalPanel />
</AdminShell>
}
}
```
### 6.2 TerminalPanel
新增 `app/src/terminal/component.rs`
职责:
- 建立 WebSocket。
- 捕获键盘输入。
- 调用 terminal core 处理服务端 output。
- 渲染 terminal screen。
- 根据容器尺寸计算 cols/rows并发送 resize。
MVP 可以先使用 DOM 渲染:
```text
<div class="terminal-root" tabindex="0">
<For each=screen_lines>
<div class="terminal-row">
<span style=cell_style>...</span>
</div>
</For>
</div>
```
后续再切 Canvas/WebGL。
### 6.3 输入处理
新增 `app/src/terminal/keyboard.rs`
先支持基础输入:
```text
Enter -> "\r"
Backspace -> "\x7f"
Tab -> "\t"
ArrowUp -> "\x1b[A"
ArrowDown -> "\x1b[B"
ArrowRight -> "\x1b[C"
ArrowLeft -> "\x1b[D"
Ctrl+C -> "\x03"
Ctrl+D -> "\x04"
普通字符 -> event.key
```
后续增强:
- Alt/meta 组合键。
- bracketed paste。
- IME composition。
- 鼠标协议。
- Kitty keyboard protocol。
### 6.4 终端 core
MVP 不建议立即手写完整 ANSI/VT parser。先用 `vt100::Parser` 做 core
```rust
pub struct TerminalCore {
parser: vt100::Parser,
}
impl TerminalCore {
pub fn new(rows: u16, cols: u16) -> Self;
pub fn process(&mut self, bytes: &[u8]);
pub fn resize(&mut self, rows: u16, cols: u16);
pub fn snapshot(&self) -> TerminalSnapshot;
}
```
`TerminalSnapshot` 是组件渲染所需的稳定数据结构:
```rust
pub struct TerminalSnapshot {
pub rows: Vec<TerminalRow>,
pub cursor: TerminalCursor,
pub title: Option<String>,
}
pub struct TerminalRow {
pub cells: Vec<TerminalCell>,
}
pub struct TerminalCell {
pub text: String,
pub fg: TerminalColor,
pub bg: TerminalColor,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
}
```
后续自研 core 时,可以逐步替换:
- `terminal-core::parser`
- `terminal-core::buffer`
- `terminal-core::screen`
- `terminal-core::style`
- `terminal-core::unicode_width`
## 7. 渲染策略
### MVP: DOM renderer
优点:
- 容易实现。
- 容易调试。
- 选区、复制、无障碍更自然。
缺点:
- 大量输出时性能一般。
- 每个 cell 都渲染 DOM 会很重。
建议 MVP 不要每个 cell 一个 span而是按 style run 合并:
```text
row = [
run(style A, "hello "),
run(style B, "world"),
]
```
### 第二阶段: Canvas renderer
优点:
- 性能好。
- 更接近 xterm.js 的渲染模型。
缺点:
- 选区、光标、字体测量需要自己做。
- IME 和可访问性需要额外 DOM 辅助层。
推荐路线:先 DOM等 parser/protocol/session 稳定后再做 Canvas。
## 8. 安全边界
浏览器终端是高风险能力,必须先定边界。
MVP 安全策略:
- 默认只在 development 环境启用。
- WebSocket route 必须鉴权,至少先做本地开发开关。
- 不允许前端指定任意启动命令。
- shell 工作目录固定在项目目录或安全 sandbox 目录。
- 限制并发 session 数。
- 限制空闲时间,超时自动 kill child。
- 记录 session start/stop/error 日志,但不要记录全部输入输出,避免泄露 secret。
后续生产策略:
- 接入用户身份和 RBAC。
- 每个 session 用独立低权限系统用户或容器隔离。
- 支持 allowlist 命令模式。
- 限制环境变量透传。
- 添加审计和资源配额。
## 9. 里程碑
### M0: 文档与目录准备
- 新增本指南。
- 明确 crate/模块命名。
- 确认 Axum route 如何兼容 `/rustui` base path。
验收:团队能按本文拆任务。
### M1: 后端 PTY + WebSocket
- 启用 `axum/ws`
- 实现 `PtySession`
- 实现 `/terminal/ws`
- 支持 input/output/resize。
- 断连自动清理 child。
验收:用浏览器或 WebSocket client 连接后,可以和 shell 交互。
### M2: Leptos DOM 终端 MVP
- 新增 `/terminal` 页面。
- 建立 WebSocket。
- 捕获基础键盘输入。
-`vt100` 解析 output。
- DOM 渲染 rows/runs。
- 支持 resize。
验收:浏览器页面能执行 `pwd``ls``clear``top`,基础 ANSI 颜色可见。
### M3: 组件化与体验
- 抽出 `TerminalCore``TerminalPanel``TerminalTransport`
- 增加滚屏 scrollback。
- 增加复制、粘贴、选区。
- 增加连接状态、重连、错误提示。
- 增加主题 token。
验收:组件可在其他 Leptos 页面复用。
### M4: 抽 Rust 组件库
-`app/src/terminal` 抽出 `terminal-core`
- 从页面组件抽出 `terminal-leptos`
- 从消息类型抽出 `terminal-protocol`
- 从 server module 抽出 `terminal-pty-server`
验收:`base-path-demo` 只依赖这些 crates不再持有核心实现。
### M5: 自研 parser/buffer
- 保留 `vt100` 作为参考测试 oracle。
- 实现自研 escape parser。
- 实现 buffer、cursor、SGR style、scroll region。
- 对比 `vt100` snapshot 做回归测试。
验收:常见 shell、git、vim、top、cargo 输出行为稳定。
## 10. 测试策略
后端测试:
- `PtySession` 创建和清理。
- resize 后 PTY size 更新。
- WebSocket 收到 input 后能产生 output。
- socket 断开后 child 退出。
前端 core 测试:
- 普通文本换行。
- ANSI 颜色。
- cursor movement。
- clear screen。
- resize reflow。
集成测试:
- 启动 `cargo leptos watch` 或测试 server。
- 打开 `/rustui/terminal`
- 输入 `echo hello`
- 断言页面出现 `hello`
## 11. 开发注意事项
- 不要在 async task 中直接阻塞读取 PTY。
- 不要把 `portable-pty` 编译到 WASM 目标。
- 不要一开始支持“前端传 command”这会扩大安全面。
- 不要先做 Canvas先把协议和 core 跑稳。
- 不要把每个 cell 都渲染成独立 DOM 节点,优先按 style run 合并。
- 不要把 xterm.js 当成逐行移植对象只参考它的边界parser、buffer、renderer、input、addon。
## 12. 推荐第一批改动清单
建议按这个顺序提交:
1. `Cargo.toml` 增加 `axum/ws``portable-pty``futures-util``vt100`
2. `server/src/terminal/pty_session.rs` 实现 PTY session。
3. `server/src/terminal/ws.rs` 实现 WebSocket handler。
4. `server/src/main.rs``/terminal/ws``/rustui/terminal/ws`
5. `app/src/terminal/protocol.rs` 定义共享消息。
6. `app/src/terminal/core.rs` 包装 `vt100::Parser`
7. `app/src/terminal/component.rs` 实现 DOM 终端。
8. `app/src/pages/terminal.rs` 接入页面。
9. `app/src/app.rs``app/src/menu_items.rs` 接入路由和导航。
10. 增加基础测试和手动验收记录。
## 13. 参考资料
- `portable-pty`: Rust 跨平台 PTY crate用于后端创建和控制伪终端。
- `axum::extract::ws`: Axum WebSocket 支持,需要启用 `ws` feature。
- `vt100`: Rust terminal byte stream parser可作为 MVP parser 和后续自研 core 的对照实现。
- `xterm.js`: 参考模块边界,不逐行移植。