Files
rustui-playground/TERMINAL_DEVELOPMENT_GUIDE.md
2026-06-08 16:34:13 +08:00

14 KiB
Raw Blame History

Rust Browser Terminal Development Guide

本指南用于在 base-path-demo 中实现一套 Rust 终端能力:

  • 后端Axum + portable-pty 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
  • 前端Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
  • 沉淀:先在业务项目中跑通,再拆成可复用 Rust 终端组件库。

1. 核心判断

浏览器终端必须拆成两层:

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. 目标架构

建议分四层沉淀。

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

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/terminalserver/src/terminal 内实现,等 API 稳定后再抽 crate。这样比一开始拆库更快也能减少早期重构成本。

3. 依赖规划

当前 workspace 已有 axum = "0.8",但 WebSocket 模块需要启用 ws feature。

建议先调整 base-path-demo/Cargo.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 建议增加:

[dependencies]
futures-util.workspace = true
portable-pty.workspace = true

app/Cargo.toml 建议增加:

[dependencies]
vt100.workspace = true

前端需要扩展 web-sys features

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 协议

先用简单、可调试的协议,不要过早优化。

浏览器到服务端:

#[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,
}

服务端到浏览器:

#[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" 的场景,建议同时支持:

/terminal/ws
/rustui/terminal/ws

挂载位置建议放在 leptos_router 外层,避免被 SSR fallback 吃掉。

目标形态:

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。

核心流程:

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

需要两个方向的任务:

PTY reader thread -> mpsc -> WebSocket sender
WebSocket receiver -> PTY writer

建议结构:

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 能看到输出。
  • 输入 topvim 不会把 UI 卡死。
  • 浏览器断开后后端 child 被终止。

6. 前端实现计划

6.1 页面接入

新增:

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 增加导航项。

建议页面名:

#[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 渲染:

<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

先支持基础输入:

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

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 是组件渲染所需的稳定数据结构:

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 合并:

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。

验收:浏览器页面能执行 pwdlscleartop,基础 ANSI 颜色可见。

M3: 组件化与体验

  • 抽出 TerminalCoreTerminalPanelTerminalTransport
  • 增加滚屏 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/wsportable-ptyfutures-utilvt100
  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.rsapp/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: 参考模块边界,不逐行移植。