GPUI Shell 深度指南:用 JavaScript 扩展 Rust GPUI 应用的脚本运行时与插件体系
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
gpui-shell(仓库中位于 crates/shell)是 gpui-kit 提供的一个脚本运行时:它让一个已经用 Rust 和 GPUI 写好的桌面应用,可以用 JavaScript 进行扩展——插件优先,其次才支持用 JavaScript 独立编写整个应用。本文基于 website/shell/index.md 展开,并结合 engine.md、capabilities.md、state.md、dock.md 等配套文档与 crates/shell/src/lib.rs 等源码,讲清它的设计动机、脚本与宿主的职责边界、性能与体积成本、安全模型,以及"脚本如何变成界面"这一核心机制的完整链路。读完你会理解:为什么说"脚本只负责描述一次界面,之后的每一帧都由 Rust 重放",以及如何在自己的 GPUI 应用里挂载一个脚本 View。
为什么需要 gpui-shell:一次编译,之后全部用脚本扩展
一个普通的 Rust GPUI 应用,每加一个面板、一个侧边工具或一段业务逻辑,都要重新编译、重新分发二进制;第三方想贡献一个面板,只能 fork 整个仓库。gpui-shell要解决的就是这件事:宿主应用只编译和发布一次,之后新的界面与逻辑以脚本形式加载进同一个进程——不重编译、不重新分发二进制、贡献者也不需要 fork。
它有两个目标,优先级明确:
- 首要目标:插件扩展。宿主应用构建一次运行时,决定脚本可以触达什么,脚本在同一进程内画出真实界面。
- 次要目标:用 JavaScript 写整个应用。CLI 可以独立运行一个应用目录,这本身是一条可用路径,同时也是插件的开发方式:先把脚本跑成独立应用,再挂载进宿主。
需要特别澄清的是:它不是 Electron,也不是 Tauri。没有 WebView、没有 DOM、没有 HTML/CSS、没有浏览器内核、也没有 Node.js。脚本从不"渲染"——它只描述一次界面,之后 Rust 在每一帧把这份描述重放成真实的 GPUI 元素,走的是与基于gpui-base的 Rust 应用完全相同的元素模型与 GPU 渲染管线。JavaScript 在这里是应用层而不是渲染层,这正是"一次重绘不花任何 JavaScript、接入整个运行时只增加约 +13.5 MiB 二进制体积"(详见 engine.md)的原因。
两个目标建立在同一条拆分之上:gpui-shell直接构建在gpui-base之上,QuickJS 运行在宿主自己的线程上。宿主构建运行时并授予脚本权限;脚本在同一进程内画出真实界面。Rust 保留渲染、布局、文本编辑、虚拟化、焦点、浮层和一切系统能力;脚本拥有组合、呈现和业务逻辑。
三十行代码的完整示例:一个带样式的计数器
文档用一个计数器示例说明这套 API 的形态——View类来自gpui-kit,布局与组件来自gpui-base,样式全部是链式方法:
import { View } from "gpui-kit"; import { v_flex, Button } from "gpui-base"; export default class Counter extends View { init() { this.count = 0; } render(cx) { return v_flex() .size_full() .items_center() .justify_center() .gap(20) .bg(cx.theme().colors.background) .child( div() .text_3xl() .text_color(cx.theme().colors.foreground) .child(`${this.count}`), ) .child( Button.new("increment") .h(32) .px(14) .items_center() .justify_center() .bg(cx.theme().colors.primary) .text_color(cx.theme().colors.primary_foreground) .rounded(6) .on_click((_event, cx) => { this.count += 1; cx.notify(); }) .child("Increment"), ); } }这个示例已经包含了这套运行时的大部分约定:init只跑一次、用来初始化跨帧状态;render返回一个元素、只在 View 失效时运行(而非每帧);样式方法使用 Rust 侧的snake_case拼写(items_center、on_click、text_color),并直接从cx.theme().colors读取语义主题色;没有任何自更新机制——改完状态必须显式调用cx.notify()。完整的开发流程(check、types、--watch热重载、CLI 命令参考)见 getting-started.md。
为什么插件优先:一次决策如何塑造整个运行时
crates/base/src/dock已经拥有了插件系统所需的一半:纯数据的布局、用名字从持久化文件里重建面板的PanelRegistry,以及跟随面板一起保存的serde_json::Value。缺的另一半是——面板的实现必须编译进宿主二进制,不 fork 就没人能贡献。gpui-shell补上的正是这一半。
"插件优先"不是一句定位口号,它是下述一系列设计决策的根源。若只面向独立脚本应用,这些决策每一项都会走另一条路:
| 决策 | 为什么它源于插件 |
|---|---|
Capabilities::default()是空集,由宿主授予 | 插件是别人写的代码;授权必须是宿主的决定,而不是插件在自身 manifest 里的自我声明 |
每个插件独立的Policy,卸载时取消所有携带它的任务 | 多个插件共享一个运行时,授权不能在插件之间互相渗漏 |
| 脚本故障是可恢复的异常,宿主进程存活 | 一个坏掉的插件不应拖垮整个应用 |
| 重绘重放 Snapshot,绝不进入 VM | 帧预算由宿主负责,插件的 JavaScript 不能压在上面 |
HostModule把宿主自己的 Rust 借给脚本 | 只有脚本运行在宿主内时才有意义——独立应用没有宿主可借 |
| Dock 面板在卸载后仍保留位置与状态 | 插件会被安装和移除;面板要回到原来的位置,带着它原来的东西 |
| 基础层不提供任何表现层,所以脚本拥有全部表现 | 插件必须长得像宿主的一部分,这需要控制每一个像素 |
独立脚本应用只用到其中很少一部分。它得到的是迭代速度——热重载、check、生成的gpui-kit.d.ts——所以它排在第二位:它是插件被开发和验证的地方,而不是这个运行时的目的本身。
文本编辑、语法高亮、LSP、虚拟化和动效采样都留在 Rust。这是一条职责划分线,而不是对脚本能力的限制:宿主拥有一切必须贴近 GPU 和系统的能力,因此插件永远不会成为应用性能或稳定性的变量。
⚠️注意:插件是目标,但还不是完整的接口插件机制之下的机器已经构建并测试——manifest 解析与发现、加载与卸载、每插件策略与数据目录。脚本目前可以贡献面板并绘制 dock 的 chrome:
DockArea、dock_area(...)和DockArea.register_panel都是公开的,含脚本面板的布局可以跨重启存活。仍然缺失的是其余贡献注册表(gpui.command、gpui.keymap)、授权 UI,以及使用PluginManager的 CLI。今天能端到端跑通的是独立路径,包含 dock。见 dock.md。
架构:脚本描述,宿主渲染
脚本从不持有 GPUI 元素。它记录一份描述——构建链中的每一次调用都把一条操作写进一个 arena(元素描述竞技场),Rust 在需要帧时把这些操作重放成真实元素。布局、绘制、命中测试、滚动、IME 和文本编辑都留在 Rust,从不回调脚本。
引擎是设计的一个参数而不是组成部分。目前只有 QuickJS 一个实现,但接缝之上的所有东西——arena、materializer、调用作用域、样式表、主题、能力模型、浮层宿主、热重载——在源码中都不指名任何 VM。关于引擎接缝的完整讨论(为什么存在、测量方法、接缝两侧各有什么)见 engine.md。从仓库源码看,引擎也确实被隔离在一个 cargo feature 后面:crates/shell/Cargo.toml 中default = ["quickjs"],引擎依赖通过 git 修订版本锁定,这与文档描述的"引擎可替换"设计一致。
能力:一个完整的应用层,而不是一组控件
脚本拿到的是一个 Rust 应用在gpui-base上能拿到的一切:元素与布局、链接与控件、基于语义主题令牌的流畅样式面、通过init/render/cx.notify()管理的 View 状态、保留的宿主状态(如文本输入的 rope 与选区)、对话框、sheet 与 toast、异步任务、原生转场与弹簧动画,以及受门控的文件系统、存储、剪贴板、进程、HTTP、TCP 和 WebSocket 能力面。
围绕这些能力的是开发工具链:--watch保存即热重载;gpui-shell.json在代码运行前声明身份与最小权限能力;生成的gpui-kit.d.ts向编辑器或模型描述整个 API;check在应用运行前报告错误。
💡
gpui-kit.d.ts可以放进.gitignore——它是生成的。
性能:脚本不在帧路径上
render不会每帧运行一次。它把界面描述进一份 Snapshot(快照),在下次cx.notify()之前,每一次重绘都在 Rust 里重放这份 Snapshot。指针划过按钮、光标闪烁、列表滚动、原生转场或弹簧推进——这些都不运行 JavaScript。
运行时把两类事件分开计数,画廊的 Shell story(cargo run -- shell)把两个计数器同时显示在屏幕上:
| 界面正在做什么 | 每秒帧数 | 每秒 JavaScript 运行次数 |
|---|---|---|
| 重绘,且 JavaScript 读取的内容没有变化 | 60 | 0 |
| 价格每 50 ms 变动 | 60 | 19 |
帧数属于显示,JavaScript 次数属于数据。第二行里其余的 41 帧都在重放一份已经存在的描述。
因此成本按用户动作支付,而不是按帧支付。在一个 443 节点的面板上,运行render并把整个界面记录进 Snapshot 耗时 1.1 ms,只在状态变化时支付;之后的每一帧耗时 1.3 ms,那是渲染本身——把 Snapshot 变成元素、布局、绘制,里面没有 JavaScript。
| 每帧成本 | |
|---|---|
| 没有 Snapshot | 1.1 ms(JS 渲染)+ 1.3 ms(Rust 渲染)=2.4 ms/帧渲染 |
| 有 Snapshot | 1.3 ms |
面板变大也不会改变这一点。engine.md 中的基准覆盖到 8,403 个节点,任何规模的帧都不运行 JavaScript,并且最小的规模在每次 CI 构建上都会被断言。详细的成本分解——描述成本(脚本→Snapshot)、物化成本(Snapshot→GPUI 元素)、完整缓存重绘成本,以及 240–340 ns/次记录调用的 FFI 边界成本——都记录在 engine.md 中。性能的完整推论(按 View 拆分边界、cx.notify()的正确姿势、帧率与呈现延迟的区别、如何读计数器)见 performance.md。
体积:接入一个脚本运行时 +13.5 MiB
一个真正运行脚本应用的宿主,发布体积为26.1 MiB的二进制,常驻内存81 MiB,其中包含 QuickJS 和整个标准运行时。去掉该依赖,同样的应用只增加+13.5 MiB 二进制和 +14 MiB 内存。
这个数字是常量而不是比例:体积是它五倍的组件画廊,也只增加同样的 13.5 MiB。实测环境是一台 MacBook Pro(M3、8 核、24 GB):帧数和运行次数来自 Shell story,毫秒数来自基准的 release 构建,二进制与内存数字来自examples/hello_world与gpui-shellCLI 的 release 构建。二进制和内存花在哪里、为什么这个常量无法再压缩(hyper、rustls、ring和解释器本身),见 engine.md。
安全:默认一无所有,语言被裁剪到与之匹配
Capabilities::default()是空集——没有文件访问、没有存储、没有剪贴板、没有进程执行、没有网络。宿主在加载 View 之前决定授权,View 一生都保持该授权;fs表面上每一条路径都经过一个解析器,任何落到已授权根目录之外的东西都会被拒绝。宿主侧对应的公开函数是 crates/shell/src/lib.rs 中的set_capabilities,文档注释明确写着"nothing is permitted until this is called"。
在授权之下,沙箱还裁剪语言本身,因为一个 VM 最终要托管多个插件:eval和全部四个函数构造器被移除;内置原型被冻结,一个插件无法为另一个插件改动Object.prototype;模块解析被限制在应用目录内;堆(256 MiB)、解释器栈(1 MiB)和单次调用时间(render内 50 ms)都有上限。那个时间上限是一个catch块吞不掉的中断,这一点由测试保证。完整的默认拒绝清单、manifest 写法、fs/storage/process/net各面以及沙箱资源上限表,见 capabilities.md。
值得单独列出的是 manifest 的形态。一个目录靠gpui-shell.json被识别,manifest 是惰性数据——发现阶段只读取身份、可选的版本元数据、Git 依赖和请求的权限,不执行入口模块。它识别id、name、version、shell-version、entry、dependencies和capabilities;只有id、name、entry是必需的:
{ "id": "com.example.quotes", "name": "Quotes", "version": "1.0.0", "shell-version": "0.6.0", "entry": "main.js", "dependencies": { "omarchy-ui": "huacnlee/omarchy-ui" }, "capabilities": { "fs": { "read": ["${pluginDir}"], "write": ["${dataDir}"] }, "network": { "hosts": ["stream.example.com"], "http": [ { "scheme": "https", "host": "api.example.com", "methods": ["GET"], "path_prefixes": ["/v1/"] } ] }, "storage": true, "clipboard": { "read": false, "write": true }, "process": { "exit": false } } }该块中每一项在缺省时都默认拒绝,唯一例外是storage默认授予——写"storage": false即可拒绝。每个拒绝都会给出可操作的修复提示(例如filesystem read is not granted; declare capabilities.fs.read in the manifest),而不是模糊的报错。宿主侧授予能力的 Rust API 形态如下(摘自 capabilities.md):
gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([application_root.clone()]) .write_roots([data_directory.clone()]) .storage(true) .exit(true), );脚本如何变成界面:三次消费带来的三个推论
GPUI 的元素是使用时被消费的值:RenderOnce::render以self按值接收、.child()按值接收其子元素、View 每次重绘都要重建整棵元素树。因此 JavaScript 对象永远不可能是一个 GPUI 元素——它没有东西可以握住。
所以脚本不构建元素,它描述元素。构建链中的每一次调用都在元素描述 arena 里记录一条操作;脚本持有的对象只是一个指向该 arena 的整数索引。当 GPUI 要求 View 渲染时,Rust 把记录的操作重放成真实元素、交给 GPUI,然后清空 arena。布局、绘制、命中测试、滚动和 IME 永远不会回到脚本。
三个推论直接由此而来,每个都有专门页面:
- 元素是单次使用的。描述在一次渲染结束后就没了,所以一个被存起来的元素在下次使用时抛错,而不是画出意外的东西。见 elements.md。
- 交给调用的
cx属于那次调用。它携带一个代数(generation),与活着的调用栈对照检查;跨await保留的cx会报出明确错误,而不是触碰一个已死的栈帧。见 state.md。 - 回调属于注册它们的这次 render。下次 render 会整体替换它们,这正是脚本闭包不会在宿主里累积的原因。见 elements.md。
三者都源于"把一个脚本绑定到会消费值的元素模型"这件事本身。
表现层属于脚本
大多数脚本层会给脚本一组现成的控件让它排列。这里没有现成控件可给,因为下面的层同样没有。
gpui-base的控件不携带任何视觉样式。Rust 里的Button::new("save")没有内边距、没有背景、没有圆角、没有尺寸——这就是契约。JavaScript 绑定原样保留:Button.new("save")不写样式就只画它的子元素。
推论就是重点:因为基础层不提供表现层,脚本拥有全部表现——每一种颜色、每一像素间距、每个 hover 状态、每个圆角。这与 Rust 应用选择gpui-base而非gpui-component时做的取舍完全相同;区别在于,在这里这个取舍发生在一个你可以保存并立刻看到结果的文件里,中间不需要cargo build。作为回报,脚本得到的是整个应用层:改一个按钮的圆角,不需要回到 Rust。完整的样式文法(无参反射方法 vs 手工绑定的 57 个参数化方法、长度与颜色语法、语义令牌、状态样式、原生动效)见 styling.md。
它适合放在哪里
- 给现有 GPUI 应用加插件支持——首要场景。插件在宿主进程内运行,权限由宿主逐项授予、从零开始。产品扩展不再意味着 fork 或发新版本:界面和业务逻辑以脚本形式交付,无需重编译或重新分发二进制;出故障的插件表现为可恢复的错误,而不是拖垮宿主。
- 在
gpui-shell上用 JavaScript 写完整应用——次要场景。整个应用层(元素、样式、View 状态、浮层和系统 API)都在,而渲染、文本编辑、虚拟化和每一帧动画都留在 Rust。这也是插件在被挂载进宿主之前编写和验证的地方。
它坐在哪里:分层与定位
JavaScript application main.js · Views · styles · business logic │ import { … } from "gpui-kit" ▼ gpui-shell engine seam · element descriptions · call scope style table · theme tokens · capabilities ShellRoot (dialogs, sheet, toasts) · scheduler │ ▼ gpui-base behavior · state · infrastructure (no style) │ ▼ gpui elements · styling · rendering · GPU · platformgpui-shell与gpui-component是并排关系而非上下关系:两者都是gpui-base的消费者,都提供 Base 没有的表现层。gpui-component用 Rust 提供一套完整自洽的表现;gpui-shell提供的是"让脚本提供自己表现层"的机器。
当前状态:M0 里程碑
该 crate 处于里程碑M0:一个可行性基线,而非稳定接口。它没有发布到 crates.io,脚本 API 预期会变化。本文档所述的内容都存在且可用;缺失的部分在其对应页面上有明确标注。设计文档位于 docs/gpui-shell.md,crate 本体位于 crates/shell。
继续深入:配套文档导航
| 页面 | 覆盖内容 |
|---|---|
| Getting started | 运行示例、最小应用、check与types |
| Examples | 仓库中的两个应用,以及可以从它们复制什么 |
| Elements | 构造器、child/children/when、元素为什么单次使用 |
| Styling | 流畅样式面、长度、颜色令牌与状态样式 |
| State and Views | init/render、cx.notify()、保留状态、异步 |
| Overlays | 对话框、sheet、toasts 与阶段规则 |
| Capabilities | gpui-shell.json、默认拒绝、文件系统、存储、进程与网络 API |
| Dependencies | Shell 包:如何构成、manifest 如何命名与锁定、编辑器得到什么类型 |
| Hosting | 完整的 Rust 侧:挂载、刷新、指标、退出、热重载 |
| HostModule | 把宿主自己的 Rust 借给脚本,以及纯数据边界 |
| Dock and Panels | 脚本 View 作为可停靠面板、你为它画的 chrome、重启后保留什么 |
| Performance | 脚本的成本:失效 vs 描述规模、View 作为边界、计数器 |
| The engine seam | QuickJS、接缝为何存在、区分脚本成本与帧成本的测量 |
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考