OpenTray v0.24~0.28:单窗口支持 Multi-WebView 排布,WebView-Navigation-API,内核性能优化
一个窗口会话可以持有任意数量的原生 webview,声明式布局在原生侧求解;导航工具栏是它的第一个应用,GitHub、Google 这类拒绝被嵌入的网站也能用。0.28 为 WebView 导航补上观察与拦截的 API(navigationAction 事件、声明式 block 规则、原生 favicon 观察)。内核侧,16 ms 事件轮询退役,扩展改为推送,空闲窗口的命令数从每 2 秒 121 条降到 0。
$ npm view create-opentray version
0.28.0
OpenTray 0.24 到 0.28 走了这一程。三个重点:一个窗口里可以排布多个原生 WebView;WebView 导航有了观察与拦截的 API;内核从轮询改成推送,空闲时不再发出任何命令。其余是浏览器兼容、稳定性修复和九语言向导。本文按这个顺序讲。
单窗口多 WebView 排布
一个窗口会话可以持有任意数量的 sibling webview:createWebview 创建、destroyWebview 销毁、listWebviews 列出;每个 webview 有自己的导航(back / forward)、focus 和 getUrl / getTitle 查询。窗口不再是「一个 WebView 加一层壳」,而是一个可以自由排布的容器。
布局
布局的理念是:JS 只声明「想要什么」,不算坐标。布局是一份数据,原生是求解器。
接口三层。win.setLayout(document) 提交整份布局文档;win.layout.update(id, patch) 做单节点增量(比如只把工具栏从 44px 改到 52px);文档用 row / column / view / fixed / grow 这些糖函数组装,fixed(id, height) 钉住尺寸,grow(id) 填满剩余空间,box 是纯涂绘原语(画背景色、圆角,不承载页面)。
原理上,一份文档是「有序图层 + 每图层一棵独立 flex 树」。数组顺序就是 z-order,没有 zIndex 字段。提交后 Taffy 在原生侧一次事务里求解:算出每个视图的 frame、应用、重算 overlay 安全区投影、重注册拖拽区域。窗口缩放触发原生重解并直接应用新 frame,全程不回 JS。替换布局时,新树仍按 id 引用的 webview 原地保留,页面不重载。
import { column, fixed, grow } from "@opentray/ext-webview";
const win = tray.createWebviewWindow({ windowOnly: true, width: 1024, height: 720 });
await win.show();
const toolbar = await win.createWebview({
id: "toolbar",
url: "http://127.0.0.1:5173/toolbar.html",
bridge: { webviewId: true, messageChannels: true },
});
const content = await win.createWebview({ id: "content", url: "https://news.ycombinator.com" });
await win.setLayout(column([fixed("toolbar", 44), grow("content")]));
await win.layout.update("toolbar", { height: 52 }); // 单节点增量
一条排他规则:影响半透明的窗口样式(frameless、材质)不能承载多 webview 组合;对已持有多个 webview 的窗口应用这类样式同样被拒。两者都在任何状态变更前以 multiwebview_unsupported_style 拒绝。
第一个应用:原生导航工具栏
--toolbar 过去把目标页面套进一层 iframe 包装。GitHub、Google、YouTube 的响应带 X-Frame-Options 或 CSP frame-ancestors,创建流程探测到就直接退回不带工具栏的直连窗口。登录也有问题:包装的顶层来源是 127.0.0.1,目标站点在它眼里是第三方,macOS 的 WKWebView 默认开启智能防跟踪,第三方 cookie 被拒,需要登录的站点走进登录回路。
现在工具栏就是上面布局代码里的两个 webview:一条固定 44px 高的工具栏 webview 由应用自己提供,带后退/前进/重载/地址栏。下面的内容 webview 以顶层上下文加载目标地址。GitHub、Google、YouTube 直接能用,登录态存在内容 webview 自己的第一方存储里,重启后仍在。地址栏以内容 webview 的 urlChange 事件为唯一真值来源,后退和前进操作原生会话历史。新窗口意图(a[target] 与 window.open、中键、右键菜单)打开同一会话持有的弹出窗口。
iframe 浏览包装与 frameEmbeddable 探测随之退役,--toolbar 即原生工具栏。旧配置字段 showAddressBar 解析时被忽略,不报错。
npx create-opentray create --url https://news.ycombinator.com --toolbar

向导把这个开关叫「导航工具栏」,放在高级选项里,默认关闭。URL 应用与命令应用都可以开启。
照这个原理自己做工具栏
官方工具栏没有用任何私有接口,下面四步都能照抄。
建一个
windowOnly窗口,放两个 webview:工具栏 webview 带bridge: { webviewId: true, messageChannels: true },内容 webview 不带 bridge(任意网页默认拿不到任何能力)。column([fixed("toolbar", 44), grow("content")])一次性排好。宿主订阅内容 webview 的
urlChange/titleChange/loadState,把 URL、标题、加载进度经消息通道channel.post推给工具栏页面。地址栏不自维护状态,只渲染urlChange推来的值;进度条由loadState驱动。工具栏页面里的输入和按钮经通道发命令回宿主,宿主调
content.navigate(url)/content.back()/content.forward()。导航、前进、后退都是内容 webview 的原生会话历史,不是页面自己维护的那份。
换个布局(左 sidebar 右内容、顶部加一条状态条)只是换一份布局文档;加更多视图只是多几个 createWebview 和更多通道目标。
每视图事件
每个 webview 有一组纯推送事件:urlChange, titleChange, focused, geometryChange, loadState, navigationAction, faviconChange。其中 loadState 是导航生命周期:started / finished / failed 三相,带目标 URL,失败带 errorCode,可测得的阶段带 progress(0 到 1)。navigationAction 在每个原生导航决策点推一帧(点击链接、提交表单、重定向都能在发生前看到),faviconChange 在站点换图标时推一帧。无重放、无轮询,每帧带 { windowId, webviewId, seq, ... }。当前值来自查询命令:先订阅、再查询,然后丢弃 seq 不大于查询值的事件,这解决了订阅竞态。overlay 与标题栏安全区按 webview 投影,在布局提交内重算。
导航观察、拦截与 favicon
navigationAction 在导航发生之前推送:{ url, navigationType, isUserInitiated? }。navigationType 是 link / form / backForward / reload / redirect / other 的平台如实投影——Windows 能精确区分重定向,把用户发起的导航归为 link;macOS 不区分重定向(归入 other)。
不止观察,还能拦截。createWebview 传一组声明式规则,原生 UI 线程同步求值,命中即取消导航并推送 loadState failed,错误码是稳定的 4500001:
const content = await win.createWebview({
id: "content",
url: "https://example.org",
favicon: true,
navigationRules: [{ pattern: "*://*.tracker.example/*", action: "block" }],
});
content.onNavigationAction((event) => { /* 每个决策点 */ });
await content.setNavigationRules([]); // 之后整组替换
模式是 URL 通配:* 匹配包括分隔符在内的任意字符段,其余全是字面量。被拦截的导航恰有一帧终态(navigationAction 后跟 failed(4500001),没有 started),页面不跳走,后续导航不受影响。同步求值意味着没有 IPC 往返、没有竞态窗口。
favicon 同样是每个视图的能力开关:favicon: true 开启后,faviconChange 推送每次实际变化的绝对 http(s) 地址,getFavicon() 返回 { value: { href } | null, seq } 查询对。站点脚本动态换图标与初始设置走同一条路。没开启的视图调用查询会被 favicon_disabled 拒绝。无 bridge 的内容视图保持零 bridge 面:观察脚本不给页面任何能力。
content.onFaviconChange((event) => { /* event.href 是绝对地址 */ });
const current = await content.getFavicon();
工具栏因此可以自己显示站点图标:订阅 faviconChange,按 href 取图缓存即可。
消息通道
宿主和带 bridge 的页面之间用消息通道通信,createMessageChannel({ target }) 创建一条定向直连。生命周期是 created → open → closed(reason) → destroyed,onClose 单次观察。队列边界精确:每个端点最多 1000 条消息、累计 1 MiB 载荷,按每条载荷的 RFC 8785 规范序列化字节数计,同一个值在每个平台计数一致。
const channel = await win.createMessageChannel({ target: "toolbar" });
channel.onMessage((payload) => { /* 已解析,无需手动 JSON */ });
await channel.post({ kind: "navigate", url: "https://example.org" });
每 webview 的 bridge 策略默认全关:一个不带策略的子视图没有任何 bridge 能力,任意内容页面默认拿不到通道。
会话所有权
会话所有权以 (appId, trayId, sessionId) 三元组为键。同一 tray 的第二个窗口会话被 tray_session_active 类型化拒绝。关闭一个会话只销毁它自己的窗口、webview、通道和弹出窗口,不动别的会话。
内核性能优化
16 ms 轮询退役,扩展改走推送
过去宿主按 16 ms 定时器向原生队列询问有没有新事件,每个空闲窗口每 2 秒发出 121 条 drain 命令,两个平台一样。
现在换成宿主持有的异步上报通道(EventPort)。每个 ext-* 扩展收到一个不可变端口,其线程安全的 try_submit 不阻塞原生 UI 与传输。事件分为 Latest(合并状态,序列跳变触发查询 resync)、Edge(背压重试,绝不静默丢弃)与 BestEffort。按源与全局的字节/记录预算加公平轮转排干,一个扩展抢不走另一个的份额。
五个每视图事件族与窗口族(focus/blur/visible/style/交互/download)在两个平台上都走这条端口。一次实测:12 个事件、0 条消费者命令,broker 指标与 tap 计数精确一致。drainWindowEvents、它的原生队列与 facade interval 已删除,空闲窗口的 drain 命令从 121 降到 0。
urlChange 或 titleChange 出现序列跳变时,facade 用 getUrl / getTitle 恢复,Latest 合并向地址栏真值收敛。
消息即时投递
页面发往宿主的消息(工具栏的后退/前进/重载/地址栏跳转)进入 broker 里的一个队列。这个队列过去的唯一出口是下一笔宿主命令的响应,而轮询退役后空闲会话一笔命令都不发,消息就无限等待。症状是工具栏按钮全部失灵,直到点击 Dock 图标恰好触发一笔命令把队列冲出去。
现在页面发起的通道命令和文档导航关闭,把发往宿主的消息立即经 EventPort 推出,不需要任何在途命令。消息是用户数据,不静默丢弃:端口无法保证的记录(超过单记录上限/重试队列满/端口未附)留在队列里,按原路径搭下一笔命令响应。刷新工具栏页面也走同一条路:通道关闭立即通知宿主,重建通道(300 ms 防抖)、重装命令面并重新播种地址栏。过去一次刷新会让所有窗口按钮静默死亡直到应用重启。
稳定性修复
同一批里修了一组让应用死亡或卡住的故障。
kill -9 之后启动不再卡住。 bundle 锁与 launch 锁带 PID 和 token,启动时自动回收 owner 已死的锁,无需手工清理。
迟到的清理不能销毁新会话。 窗口与通道的销毁路径校验 owner 三元组,收集了陈旧条目的清理不会再误伤中途上位的新会话。
broker 死亡后不再留僵尸。 等待中的命令请求 reject,事件订阅收到终态通知,生成的应用以非零码退出,不再服务一个后端已死的 shell。终态消息在所有平台一致(Linux 的
ECONNRESET与 macOS 的 close 顺序不同;现在都报broker connection closed,transport 错误挂在cause。)每个应用一个端点。 broker 端点从
appIdslug 派生,显示名不进路径段;同一个应用从 CLI open/直接node/carrier 冷启动都到达同一个 broker。启动向 app.log 写叙事。 每个引导步骤记录一行结构化日志,启动失败时日志指明失败步骤。
其他变更
webview 默认以标准浏览器身份标识。 裸 WKWebView 的 UA(
…AppleWebKit/…里没有浏览器 token)会让基于 UA 探测的门户进入同 URL 重载循环,每秒约 10 次导航;baidu.com 是被报告的案例。默认附加标准Version/… Safari/…token 后同一窗口正常稳定。新增每 webview 的browser选项:userAgent完整覆盖;browserlikeUserAgent默认true(Windows 本来就是完整 Edge UA,此处为空操作);incognito默认false;autoplay默认false。工具栏页面的右键菜单。
browser.contextMenu选项默认按 bridge 面积判定:工具栏这类可信 shell UI 隐藏引擎右键菜单,Reload 和 Inspect 不出现在 shell 界面上;无 bridge 的内容子视图保留普通浏览器菜单。最初的 macOS 实现曾导致启动即崩溃,现已改为在页面内压制:工具栏上任意位置右键无反应,地址栏 input/textarea/contenteditable 保留带复制、粘贴的原生菜单。--open替换在运行实例。 open 现在按 argv 身份探测同一应用的在运行实例,停止进程树,有界等待 PID 释放后再启动,不再与它竞争 broker 会话。生成的入口被冷启动顶替时干净让位:证据进app.log,以 0 退出,不留空 tray shell。向导支持九种语言。 简体中文/日语/韩语/英语/阿拉伯语/法语/西班牙语/德语/俄语,每份目录是完整的类型化对象,
{token}占位符对等由测试强制。服务端发出的校验错误、固定提示、PTY 指引也走同一语言通道(x-opentray-locale/lang=)。阿拉伯语界面镜像为 RTL。

WebviewWindowHandle.setTitle已实现。 生成的命令应用为(detached)服务窗口标记调用它,这个方法过去在句柄上不存在,每次调用都静默抛错。打包运行时的图标生成已修复。 字形字体(
inter-glyph.ttf及其 OFL 声明)与图标合成用的背景 PNG 随发布包分发,WASM 图像编解码依赖已声明;registry 安装无需本地检出即可生成图标。opentray-spec的两个裸指针 FFI 辅助函数标记unsafe并补充安全文档,清除了一条 error 级 clippy lint。
升级
npm i create-opentray@0.28.0
npm i opentray@0.28.0 # SDK
使用官方扩展时固定一条协议线:pnpm add opentray@stable-A-B @opentray/ext-webview@stable-A-B(线标签由 @opentray/spec 发布;不要与 latest 混用)。
链接
Changelog:GitHub Release opentray@0.28.0 · opentray@0.27.7 · opentray@0.27.4 · opentray@0.27.3 · opentray@0.27.2 · opentray@0.27.1 · opentray@0.27.0 · opentray@0.26.0 · opentray@0.25.0 · opentray@0.24.0 · packages/create/CHANGELOG.md
升级:见上一节
npm:opentray · create-opentray
本站相关:OpenTray v0.23.0(2026-09-10,iframe 工具栏版本) · OpenTray v0.21.1
English version: /blog/2026-09-16-opentray-v0-24-v0-28/
