Files
rustui-playground/TERMINAL_DEVELOPMENT_GUIDE.md
2026-06-25 14:31:42 +08:00

569 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Rust Browser Terminal Development Guide
本指南用于在 `rustui-playground` 中实现一套 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
rustui-playground/
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。
建议先调整 `rustui-playground/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`
验收:`rustui-playground` 只依赖这些 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`: 参考模块边界,不逐行移植。