Skip to content

OpenTray v0.24~0.28:单窗口支持 Multi-WebView 排布,WebView-Navigation-API,内核性能优化

jixoai v0.28.0 opentray

一个窗口会话可以持有任意数量的原生 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)、focusgetUrl / 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 应用与命令应用都可以开启。

照这个原理自己做工具栏

官方工具栏没有用任何私有接口,下面四步都能照抄。

  1. 建一个 windowOnly 窗口,放两个 webview:工具栏 webview 带 bridge: { webviewId: true, messageChannels: true },内容 webview 不带 bridge(任意网页默认拿不到任何能力)。

  2. column([fixed("toolbar", 44), grow("content")]) 一次性排好。

  3. 宿主订阅内容 webview 的 urlChange / titleChange / loadState,把 URL、标题、加载进度经消息通道 channel.post 推给工具栏页面。地址栏不自维护状态,只渲染 urlChange 推来的值;进度条由 loadState 驱动。

  4. 工具栏页面里的输入和按钮经通道发命令回宿主,宿主调 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? }navigationTypelink / 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) → destroyedonClose 单次观察。队列边界精确:每个端点最多 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。

urlChangetitleChange 出现序列跳变时,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 端点从 appId slug 派生,显示名不进路径段;同一个应用从 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 默认 falseautoplay 默认 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 混用)。

链接