☰
在浏览器里跑真正的 Rio 终端内核:librio-wasm 的架构、构建与 JS ABI 实战
2026/9/28 2:20:34 网站建设 项目流程
  • 开发工具
  • CLI
  • 跨平台

【免费下载链接】rio

A hardware-accelerated GPU terminal emulator focusing to run in desktops and browsers.

项目地址:https://gitcode.com/gh_mirrors/ri/rio
点击查看免费下载

librio-wasm 是 Rio 终端内核的 WebAssembly 封装:它把剥离了 PTY 功能的librio编译到wasm32-unknown-unknown,再通过 wasm-bindgen 暴露成浏览器可调用的 JavaScript ABI,是开源 npm 包 rioterm 背后的 JS 终端引擎。本文围绕 librio-wasm/README.md 展开,结合仓库源码讲解它的传输模型、RioTerm完整 API 面、渲染状态拉取协议与构建流程,读者读完后能独立完成一次 wasm 构建、理解feed/output双向数据通路,并照着 API 清单把一个可用的终端页面串起来。

librio-wasm 是什么:从 librio 到 JS ABI

Rio 的项目仓库里,终端内核被提取成了可嵌入的 librio crate,它把 PTY(伪终端)、VT 状态机和渲染状态拉取 API 打包在一起,并通过 C ABI(librio/src/capi.rs)提供给 Swift/C 宿主。librio-wasm 则是这条嵌入链路的另一端:

libriowithout itsptyfeature, compiled to wasm32-unknown-unknown and exposed through wasm-bindgen. This is the JS ABI behind the rioterm npm package, the same waylibrio's C ABI backs the Swift/C embedders.

也就是说,librio的 C ABI 服务于 Swift/C 嵌入者,而librio-wasm的 JS ABI 服务于 Web 嵌入者,两者共享同一套终端内核。从依赖关系可以印证这一点(librio-wasm/Cargo.toml):

[dependencies] librio = { path = "../librio", default-features = false } wasm-bindgen = "0.2.106" js-sys = "0.3.83"

关键在default-features = false:librio 的默认 feature 是pty + graphics(见 librio/Cargo.toml),而 wasm 构建显式关掉了pty,从而走"宿主自持传输"的非 PTY 路径。

一个值得注意的实现细节:整个 crate 以#![cfg(target_arch = "wasm32")]开头(librio-wasm/src/lib.rs),意味着它在原生目标上是一个空 crate。原因正如源码注释所写:JS 委托是刻意单线程的(RefCell+ JS 回调),这会被原生SurfaceDelegate要求的Send + Sync约束正确拒绝;原生嵌入者应该直接用librio。librio 侧也用条件编译放宽了线程约束——在 wasm 目标上MaybeSendSync是一个空 trait(librio/src/lib.rs),因为 wasm 只有单线程且回调持有!Send的 JS 函数。

浏览器里没有 PTY:host-owned transport 模型

README 用一句话点破了 Web 终端与桌面终端最本质的差异:

There is no PTY in a browser, so the host owns the transport: child output goes in throughfeed, and bytes the terminal wants delivered to the child (key encodings, mouse reports, DA responses) come back out through theoutputcallback.

桌面端librio默认开启ptyfeature:Surface会 spawn 一个 shell,并起一个 IO 线程通过teletypewriter/corcovado泵数据。而在浏览器里既没有 PTY 也没有子进程,所以角色对调——宿主(页面里的 JS 代码)拥有传输层:

  • 下行(子进程 → 终端):通过RioTerm.feed(bytes)把字节喂进终端;feed内部调用Surface::inject_output,走持久化的 VT 解析器(librio/src/lib.rs)。注意这里的语义是"把字节注入终端显示"而非"写入 PTY 输入",所以回放历史滚动缓冲是安全的——shell 永远看不到这些字节。
  • 上行(终端 → 子进程):终端想把字节交给子进程(按键编码、鼠标上报、DA 响应等)时,会走SurfaceDelegate::output回调。在非 pty 构建下,Listener收到RioEvent::PtyWrite后不再发往 PTY channel,而是直接调用delegate.output(librio/src/lib.rs)。

README 给出了典型的接线方式:把这两条通路接到一个 WebSocket 就是一个真实 shell,接到页内解释器就是一个演示终端。上游 Web 仓库(rioterm)会锁定某个 rio 修订版本并在 CI 中执行这套构建;librio-wasm 本身不会发布到 crates.io(librio-wasm/Cargo.toml 中publish = false)。

构建流程与产物

README 给出的构建命令很精简,两行即完成从 Rust 源码到浏览器可用 JS 包的全过程:

cargo build -p librio-wasm --release --target wasm32-unknown-unknown wasm-bindgen --target web --out-dir pkg \ target/wasm32-unknown-unknown/release/librio_wasm.wasm

要点拆解:

  1. 第一步用 Cargo 把librio-wasm编译成wasm32-unknown-unknown目标的 release 产物。crate 的 lib 类型是["cdylib", "rlib"](librio-wasm/Cargo.toml),cdylib保证产出可被 wasm-bindgen 消费的.wasm文件,rlib则允许它作为普通 Rust 库被测试和复用。
  2. 第二步用wasm-bindgen --target web生成 ES module 风格的 JS 胶水层和类型声明,输出到pkg目录。产物文件名来自 crate 名的下划线形式:librio_wasm.wasm。

前置条件是把wasm32-unknown-unknown目标加入 rustup(rustup target add wasm32-unknown-unknown)并安装wasm-bindgen-cli。另外,如果使用 wasm-pack 构建,仓库已经预置了 release profile 的 wasm-opt 参数(librio-wasm/Cargo.toml):

[package.metadata.wasm-pack.profile.release] wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext"]

注释说明了原因:rustc 默认会发出 bulk-memory / sign-ext 指令,wasm-opt 必须显式接受它们,否则优化阶段会报错。

仓库根目录的 Makefile 还提供了另一条面向演示前端的路径(run-wasm:先cargo build -p rioterm --target wasm32-unknown-unknown --lib,再cargo run -p rioterm-wasm),对应 frontends/wasm 里基于 winit + softbuffer 的浏览器演示;它是另一个独立目标,与 librio-wasm 的产物形态不同,适合对比理解"引擎封装"与"完整前端"两种 wasm 形态的区别。

RioTerm:一个终端表面 + 一份渲染状态

wasm-bindgen 暴露的核心类型是RioTerm(librio-wasm/src/lib.rs),其结构体注释说得很直白:"One terminal surface plus its pulled render state. The JS Terminal class in the rioterm package owns exactly one of these."——即一个RioTerm对应一个终端表面(Surface)和与之绑定的渲染状态(RenderState)。

构造函数(#[wasm_bindgen(constructor)],librio-wasm/src/lib.rs):

new RioTerm(cols: number, rows: number, pixelWidth: number, pixelHeight: number, scrollback: number): RioTerm
  • cols/rows:网格的列数与行数,底层会做max(2)兜底,防止退化为 0 尺寸;
  • pixel_width/pixel_height:宿主像素尺寸。它不是可有可无的装饰——终端用pixel_width / cols推导单元格度量(GridSize,见 librio/src/lib.rs),而 kitty 图像放置协议必须把像素映射到单元格,像素为 0 会静默丢弃所有放置;
  • scrollback:滚动缓冲(历史行)容量,SurfaceDesc的默认值是 10000 行(librio/src/lib.rs)。

构造函数返回Result<RioTerm, JsError>,创建 Surface 失败时会抛 JS 异常。创建过程在内部走Engine::new(delegate)→engine.create_surface(&desc)两步(librio/src/lib.rs),wasm 侧把"引擎只负责铸造 surface id、每个 RioTerm 恰好持有一个 surface"这一关系写进了注释。

键盘常量的双 ABI 一致性

librio-wasm 在顶层导出一整套KEY_*常量(librio-wasm/src/lib.rs),从KEY_CHAR = 0到KEY_SUPER_RIGHT = 25,外加KEY_ACTION_PRESS/REPEAT/RELEASE。源码注释点明了设计意图:

Key tags, matching librio's C ABI (RIO_KEY_*) so the two stay one vocabulary across Swift, C, and JS embedders.

对照 librio/src/capi.rs 里的RIO_KEY_*常量可以看到数值一一对应。这意味着无论宿主是 Swift、C 还是 JS,按键事件的"标签词表"都是同一份,减少了跨语言移植时的映射错误。wasm 侧的key()方法负责把(action, tag, codepoint, ...)还原成librio::Key枚举再交给Surface::key。

事件委托:队列 + flush,保证回调安全重入

wasm 宿主通过 8 个on_*方法注册事件回调(librio-wasm/src/lib.rs):

回调参数触发时机
on_outputUint8Array终端想把字节交给子进程(按键编码、鼠标上报、DA 响应)
on_wakeup无终端画面被破坏、需要重绘(驱动 rAF 调度)
on_title(title, subtitle|null)OSC 标题/副标题变化
on_bell无终端请求响铃
on_cursor_blink无光标闪烁状态变化
on_progress(state, value)OSC 9;4 进度报告(ConEmu 编号:0 移除、1 设置、2 错误、3 不确定、4 暂停;value 为 0-100)
on_clipboard(kind, text)剪贴板写入请求(0 剪贴板、1 选区)
on_close无终端请求关闭表面

这套机制背后是一个精心的并发设计。所有委托事件先被压进JsDelegate的RefCell<Vec<Event>>队列(librio-wasm/src/lib.rs),然后由flush()在每个入口点返回后统一排空并派发给 JS 回调(librio-wasm/src/lib.rs)。两个关键性质:

  1. 绝不在持有终端锁时执行 JS:事件只是入队,JS 回调只在flush阶段运行,此时终端锁已释放。因此 JS 回调可以安全地"回调进"这个对象(比如在on_output里调用feed向终端回写数据),不会死锁。
  2. 回调抛错不卡死排空:flush对每个回调的调用结果做Result检查,抛异常的回调被捕获丢弃(let _ = err;),保证一个坏回调不会卡住后续事件的分发。

wakeup事件还做了合并去重(librio-wasm/src/lib.rs):队列末尾已有Wakeup就不再追加,因为"一帧一个 wakeup"足够驱动 rAF 调度器。事件源则来自 librio 的Listener::dispatch(librio/src/lib.rs),它把RioEvent(渲染、标题、响铃、光标闪烁、剪贴板、PTY 写、退出等)映射为委托调用。

输入路径:按键、文本、粘贴与滚轮

key:让终端决定字节长什么样

key()是输入的核心入口(librio-wasm/src/lib.rs),签名:

key(action: number, tag: number, codepoint: number, functionKey: number, mods: number, consumedMods: number, composing: boolean, text: string | null): boolean

宿主把平台按键事件"几乎原样"交进来,然后由librio::key::encode决定最终到达子进程的字节。这个决策依赖终端状态——应用光标模式(DECCKM)、kitty 键盘标志、modifyOtherKeys——而宿主没有义务跟踪这些状态,所以编码逻辑留在内核里而不是前端(librio/src/key.rs)。Surface::key会构造EncodeContext(librio/src/lib.rs),把app_cursor、kitty flags、modify_other_keys、alt_is_meta全部读出来再编码;返回true表示按键产出了字节(已通过output回调送达)。

注意KeyEvent语义(librio/src/key.rs):key是未 shift 的键(shift+a的 key 是Char('a'),平台产出的A放在text里),consumed_mods记录平台为产出text已消耗的修饰键(比如北欧布局上 AltGr 已被消费,Alt 不应再编码为 meta)。

send_text / paste:普通文本与安全粘贴

  • send_text(text):把原始文本当作合成输入发给子进程(librio-wasm/src/lib.rs);
  • paste(text):按终端的 bracketed paste 规则处理。当程序开启 mode 2004 时,文本被包进ESC[200~ ... ESC[201~标记,并剔除 ESC、ETX 和 8-bit CSI——因为"负载永远不能提前闭合括号注入按键"(librio/src/lib.rs 与encode_paste,同文件 L403-L410);未开启时则把换行归一化为CR(回车键产生的就是它)。仓库测试 librio/src/lib.rs 专门验证了a\x1b[201~rm -rf /\x03这类恶意负载会被安全过滤。

mode_bits:给宿主做输入决策的位图

mode_bits(): number返回终端模式位图(librio-wasm/src/lib.rs),位定义来自 librio/src/lib.rs:

  • bit 0:鼠标上报(MOUSE_MODE)
  • bit 1:应用光标键(APP_CURSOR)
  • bit 2:备用屏幕(ALT_SCREEN)
  • bit 3:bracketed paste

宿主可以用它做"自己负责的那部分输入决策"(触摸滚动、按键栏),无需解析终端内部状态。

scroll_wheel:程序的优先级先于滚动缓冲

scroll_wheel(lines, col, row, mods)完整复刻了终端滚轮的语义(librio-wasm/src/lib.rs,底层逻辑见 librio/src/lib.rs),按顺序三选一:

  1. 程序开启鼠标上报 → 滚轮变成鼠标事件(wheel 按钮 64 上 / 65 下,SGR 或 X10 编码);
  2. 备用屏幕 + alternate scroll → 滚轮变成方向键(分页器可滚动),应用光标模式决定CSI/SS3;
  3. 否则 → 移动宿主的滚动缓冲视图。

按住 Shift 永远强制走"滚动缓冲"(用户明确要求看历史),覆盖前两条。返回true表示程序消费了事件。仓库测试 librio/src/lib.rs 用注入的CSI ?1049h/CSI ?1000h序列分别验证了三条路径。

渲染状态:一帧一快照,按行拉取

Web 渲染器与原生渲染器共享同一套"拉取式"渲染状态协议:每帧(或每次 wakeup 后)调用update()拉取一份新鲜快照,然后只读地消费它(librio-wasm/src/lib.rs)。RenderState::update在同一把锁下同时快照网格、解析后的行样式、终端调色板、光标、选区与 kitty 放置(librio/src/render_state.rs),保证 GPU 发射路径拿到的索引色/命名色和当帧画面严格对应。

快照读取 API 一览:

方法作用
lines()/columns()视口尺寸
cursor_line()/cursor_col()/cursor_visible()光标位置与可见性(CSI ?25l隐藏或滚入历史时为 false)
display_offset()/alt_screen()滚动偏移与备用屏幕状态
row_dirty(line)/reset_dirty()脏行增量重绘
write_cells(out)/write_row(line, out)把整视口/单行写入 u32 缓冲
text_row(line)/dump()纯文本视图(测试、无障碍树用,不做渲染)
serialize()整缓冲序列化为可回放的 VT 字节流(含 SGR 样式与 OSC 8 链接)
history_lines()滚动缓冲当前行数

单元格线格式:每格 4 个 u32 字

write_cells输出的是紧凑二进制格式而非对象数组,CELL_WORDS = 4定义每格的字数(librio-wasm/src/lib.rs):

[codepoint | wide << 21 | flags, fg, bg, style_flags]
  • word0:基础码点(低 21 位)、宽字符位(第 21 位)、CELL_HAS_CLUSTER标志(1 << 23,表示该格还挂着组合字符簇,见 librio-wasm/src/lib.rs);
  • word1/word2:前景/背景色,统一打包成kind << 24 | payload,颜色种类由COLOR_NAMED=0、COLOR_INDEXED=1、COLOR_RGB=2区分(librio-wasm/src/lib.rs)。主题活在 JS 侧,所以命名色和索引色在 JS 里解析成具体 RGB;
  • word3:样式标志位(StyleFlags)。

fill_row的实现(librio-wasm/src/lib.rs)还处理了空格的兜底:无内容的格写成空格码点 + 默认前景/背景 + 0 标志。返回值为写入的字数,缓冲过小则返回 0,所以调用方需要按lines * columns * CELL_WORDS预分配。

簇文本:让 ZWJ 表情和分解重音渲染正确

CELL_HAS_CLUSTER标志指向一个更完整的文本模型:当单元格挂着附加码点(组合字符,或 DEC 私有模式 2027 下的字素簇尾部)时,用cluster_text(line, col)取回"基础码点 + 全部附加码点"的完整字符串(librio-wasm/src/lib.rs)。渲染器画它而不是只画基础字符,ZWJ 表情(如🧑‍🌾)和分解重音(如e + U+0301)才能渲染成序列本义的单个字形;暂时不读这个标志的渲染器则继续画基础字符,只是退化为旧行为。对应的测试在 librio/src/lib.rs:mode 2027 开启后注入 ZWJ 表情与分解重音,cell_cluster_text(0, 0)返回完整的🧑‍🌾三码点序列。

配套的顶层函数cluster_width(codepoints)测量 UTF-32 缓冲里第一个字素簇的[长度, 宽度](librio-wasm/src/lib.rs),宽度取 2(宽)/ 1(窄)/ 0(零宽标记),与 mode 2027 下的排版规则完全一致——渲染器可用它给单元格排版文本,而无需回放输入。

选择、搜索、链接与 kitty 图像

选择(Selection)

selection_begin(viewportLine, col, kind, sideRight)的kind沿用 C ABI 取值:0 简单、1 词、2 行、3 块(librio-wasm/src/lib.rs),内部映射到SelectionKind。side参数决定指针落在单元格的哪一半,这直接决定拖动端点单元格是否被选中——仓库测试 librio/src/lib.rs 验证了"向右拖回第 0 列"必须用Side::Left才能把列 0 包含进来。配套 API:selection_update、selection_clear、selection_text,以及视口坐标下的viewport_selection()(返回[start_line, start_col, end_line, end_col, is_block])。

搜索

search(pattern, max)在滚动缓冲 + 屏幕全范围做正则搜索,按从上到下返回扁平四边形数组[start_line, start_col, end_line, end_col, ...],行坐标相对滚动缓冲环顶部(librio-wasm/src/lib.rs),因此滚动视口后命中坐标依然有效;无匹配或非法模式返回空数组。底层实现在 librio/src/lib.rs,测试 librio/src/lib.rs 验证了环相对坐标与max上限。

链接(OSC 8 与纯文本 URL)

链接是"命中测试形状"的 API,只在指针事件时调用,不为每帧付费:

  • link_at(line, col):取 OSC 8 超链接 URI(librio-wasm/src/lib.rs);
  • link_run(line, col):悬停行上可下划线的链接区间[start_col, end_col],跨行链接是共享一个 URI 的多段 run;
  • url_at(line, col):正则检测的纯文本 URL(覆盖 20 余种 scheme,见 librio/src/lib.rs),对未换行的逻辑行做检测,所以折行 URL 能解析完整;结果还会修剪掉行文标点——https://rio.dev,链接时不含逗号,右括号只有在其开括号也在 URL 内时才属于 URL(librio/src/lib.rs)。

OSC 8 链接解析的验证测试在 librio/src/lib.rs。

kitty 图像协议

渲染器按索引枚举画面上的 kitty 放置:

  • kitty_count():当前画面内放置数量;
  • kitty_geometry(index, cellWidth, cellHeight):返回[image_id, z_index, x, y, width, height, src_x, src_y, src_w, src_h](f64 数组,见 librio-wasm/src/lib.rs),几何已针对视口解析,滚出视野时返回空;
  • kitty_image_info(image_id):[width, height, stamp],stamp 在像素变化时改变,渲染器可按(id, stamp)缓存上传/位图;
  • kitty_image_rgba(image_id, out):拷贝 RGBA 像素(RGB 源补不透明 alpha),返回写入字节数。

底层RenderState::update会同时收集直接放置(direct overlay)与虚拟放置(U+10EEEE 占位符行段,kitten icat --transfer-mode在复用器下产生的形态),并按 z-index 升序排序(librio/src/render_state.rs),宿主可以按"背景之下 / 文字之下 / 文字之上"的边界切分绘制层。

与 librio 的关系:一份内核,多语言 ABI

最后回到 README 的定位:librio-wasm 是librio的 JS ABI,正如librio的 C ABI 服务 Swift/C 嵌入者。两者共享同一套终端内核(rio-vt的 VT 解析、网格、字素与图形协议),差异只在外层:

  • 原生/PTY 构建:Surface持有 PTY channel、shell pid、IO 线程,write把字节送进 channel(librio/src/lib.rs);
  • wasm/非 PTY 构建:Surface持有delegate,write把字节交给delegate.output,由 JS 端决定送往 WebSocket 还是页内解释器(librio/src/lib.rs)。

这解释了为什么 librio-wasm 的 Cargo.toml 里default-features = false,也解释了为什么 specs/librio.md 与 librio/README.md 会作为嵌入文档与本文配套阅读。若要在你自己的项目里复刻 rioterm 的接入方式,标准路径是:wasm-bindgen 生成pkg→new RioTerm(...)创建表面 → 注册 8 个on_*回调 → WebSocket 收字节调feed、把on_output的字节发出去 → 每帧update()后按CELL_WORDS格式画格子。整套 ABI 已经被 rioterm 的 Web 仓库锁版本并在 CI 中构建,你可以放心照此模式接入。

  • 开发工具
  • CLI
  • 跨平台

【免费下载链接】rio

A hardware-accelerated GPU terminal emulator focusing to run in desktops and browsers.

项目地址:https://gitcode.com/gh_mirrors/ri/rio
点击查看免费下载
上一篇:3.6GB显存也能跑!CogVideoX-2b量化推理终极优化指南
下一篇:vi-gemma-2b-RAG社区与支持:开发者资源和贡献指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询