# 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 }, 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! { } } ``` ### 6.2 TerminalPanel 新增 `app/src/terminal/component.rs`。 职责: - 建立 WebSocket。 - 捕获键盘输入。 - 调用 terminal core 处理服务端 output。 - 渲染 terminal screen。 - 根据容器尺寸计算 cols/rows,并发送 resize。 MVP 可以先使用 DOM 渲染: ```text
...
``` 后续再切 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, pub cursor: TerminalCursor, pub title: Option, } pub struct TerminalRow { pub cells: Vec, } 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`: 参考模块边界,不逐行移植。