Skip to content

OpenTray v0.24~0.28:改进窗口体验,扩展导航控制,提升稳定性

jixoai v0.28.0 opentray

一个窗口会话可以持有任意数量的原生 WebView,布局声明成数据、在原生侧求解;导航工具栏改为原生组合,拒绝被嵌入的网站和需要登录的站点都能用;导航可以在发生前观察与拦截(navigationAction 事件、声明式 block 规则、站点图标观察)。内核事件改推送后,空闲窗口的命令数从每 2 秒 121 条降到 0。

$ npm view create-opentray version
0.28.0

OpenTray 0.24.0 到 0.28.0 十个版本合成一篇,三个主题:一个窗口可以排布多个原生 WebView,导航工具栏随之改为原生组合;WebView 导航可以在发生前观察与拦截;内核事件从轮询改成推送,空闲窗口不再发出任何命令。其余是浏览器兼容、稳定性修复和九语言向导。上一篇讲到 0.23.0,本篇按主题归组覆盖区间全部变更,链接栏列出每个版本的 release 永链。

一个窗口里排布多个页面(0.25.0)

一个窗口会话可以持有任意数量的 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 是纯涂绘原语(画背景色、圆角,不承载页面)。

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 });   // 单节点增量

提交后 Taffy 在原生侧一次事务里求解;窗口缩放触发原生重解并直接应用新 frame,全程不回 JS。替换布局时,新树里按 id 引用的 webview 原地保留,页面不重载。

求解机制
  • 一份文档是「多图层」:有序图层数组,每图层一棵独立 flex 树;数组顺序就是 z-order,没有 zIndex 字段。

  • 求解事务内一并完成:算出每个视图的 frame、应用、重算 overlay 与标题栏安全区按 webview 的投影、重注册拖拽区域。

  • 一条排他规则:影响半透明的窗口样式(frameless、材质)不能承载多 webview 组合,对已持有多个 webview 的窗口应用这类样式同样被拒。两者都在任何状态变更前以 multiwebview_unsupported_style 拒绝(走查实证 2026-09-12)。

拒绝被嵌入的网站也能用工具栏

--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 和更多通道目标。

页面与宿主用定向通道对话

宿主和带 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、通道和弹出窗口,不动别的会话。

每个视图自己推事件

每个 webview 有一组纯推送事件,事件族共七种:urlChange, titleChange, focused, geometryChange, loadState, navigationAction, faviconChange。loadState 是导航生命周期:started / finished / failed 三相,带目标 URL,失败带 errorCode,可测得的阶段带 progress(0 到 1)。

事件无重放、无轮询,每帧带 { windowId, webviewId, seq, ... }。当前值来自查询命令:先订阅、再查询,然后丢弃 seq 不大于查询值的事件,这解决了订阅竞态。

导航发生前能看到,也能拦掉

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 取图缓存即可。

内核改推送,空闲窗口零命令(0.27.0)

16 ms 轮询退役,扩展改走推送

过去宿主按 16 ms 定时器向原生队列询问有没有新事件,每个空闲窗口每 2 秒发出 121 条 drain 命令,两个平台一样。

现在换成宿主持有的异步上报通道(EventPort)。一次实测:12 个事件、0 条消费者命令,broker 指标与 tap 计数精确一致。drainWindowEvents、它的原生队列与 facade interval 已删除,空闲窗口的 drain 命令从 121 降到 0。urlChange 或 titleChange 出现序列跳变时,facade 用 getUrl / getTitle 恢复,合并状态向地址栏真值收敛。

EventPort 的传输机制
  • 每个 ext-* 扩展收到一个不可变端口,其线程安全的 try_submit 不阻塞原生 UI 与传输;端口身份宿主绑定,扩展只报 tray 路由。

  • 事件分为 Latest(合并状态,序列跳变触发查询 resync)、Edge(背压重试,绝不静默丢弃)与 BestEffort。

  • 按源与全局的字节/记录预算加公平轮转排干,一个扩展抢不走另一个的份额。

  • 五个每视图事件族与窗口族(focus/blur/visible/style/交互/download)在两个平台上都走这条端口;应用重开的最近使用跟踪与应用模式对账也改为推送。

  • 旧扩展保持旧的响应捎带冲刷,宿主上报当前投递模式,分阶段发布不破坏 lockstep。

工具栏按钮不再失灵

页面发往宿主的消息(工具栏的后退/前进/重载/地址栏跳转)进入 broker 里的一个队列。这个队列过去的唯一出口是下一笔宿主命令的响应,而轮询退役后空闲会话一笔命令都不发,消息就无限等待。症状是工具栏按钮全部失灵,直到点击 Dock 图标恰好触发一笔命令把队列冲出去。

现在页面发起的通道命令和文档导航关闭,把发往宿主的消息立即经 EventPort 推出,不需要任何在途命令。消息是用户数据,不静默丢弃:端口无法保证的记录(超过单记录上限/重试队列满/端口未附)留在队列里,按原路径搭下一笔命令响应。刷新工具栏页面也走同一条路:通道关闭立即通知宿主,重建通道(300 ms 防抖)、重装命令面并重新播种地址栏。过去一次刷新会让所有窗口按钮静默死亡直到应用重启(走查实证 2026-09-15)。

启动不再卡死,退出不留僵尸

同一批修了一组让应用死亡或卡住的故障。

  • 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 没有浏览器 token,基于 UA 探测的门户会进入同 URL 重载循环,每秒约 10 次导航(baidu.com 是被报告的案例);默认附加标准 Version/… Safari/… token 后同一窗口稳定。新增每 webview 的 browser 选项:

    • userAgent 完整覆盖

    • browserlikeUserAgent 默认 true(Windows 本来就是完整 Edge UA,空操作)

    • incognito 与 autoplay 默认 false

  • 工具栏页面的右键菜单。 browser.contextMenu 选项默认按 bridge 面积判定:工具栏这类可信 shell UI 隐藏引擎右键菜单,Reload 和 Inspect 不出现在 shell 界面上;无 bridge 的内容子视图保留普通浏览器菜单。最初的 macOS 实现曾导致启动即崩溃,现已改为在页面内压制:工具栏上任意位置右键无反应,地址栏 input/textarea/contenteditable 保留带复制、粘贴的原生菜单。

  • --open 创建后启动,并替换在运行实例。 create --open 创建成功后启动生成的应用(默认只创建,--dry-run 不变)。open 按 argv 身份探测同一应用的在运行实例,停止进程树,有界等待 PID 释放后再启动,不再竞争 broker 会话(走查实证 2026-09-14:Dock 固定的 carrier 冷启动复活旧入口,迟到的 open 死掉并留下死按钮空 shell)。被冷启动顶替的生成入口干净让位:证据进 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。

0.23.0 之后 183 个提交:35 feat / 45 fix / 62 docs / 2 test / 28 chore,另有 8 个 merge、2 个 refactor、1 个 style(口径为 conventional 提交前缀计数)。区间内唯一带 ! 标记的提交是 16 ms drain 退役(contract-3);iframe 浏览包装的退役写在 0.25.0 的变更说明里,未带 !。

升级

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 混用)。

破坏性变更是 --toolbar 从 iframe 包装切换为原生工具栏:旧配置字段 showAddressBar 被忽略不报错,无需迁移动作;已冻结的生成物保持旧行为,重新生成即得新载体。

链接