569 lines
14 KiB
Markdown
569 lines
14 KiB
Markdown
# 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`: 参考模块边界,不逐行移植。
|