外观
Windows 桌面壳白屏:postMessage 的 targetOrigin 在 Chromium 会抛错
2026-08-22 · 涉及
nexo-web-sdk、nexo-im-pc· 修复见 nexo-web-sdk#5、nexo-im-pc#27
一句话:向壳广播 app:ready 的 for 循环没有隔离异常,而 tauri://localhost 这个 targetOrigin 在 Chromium 下会直接抛 SyntaxError,导致排在它后面的、真正该送达的 origin 一次都没发出去。
1. 现象
Windows 桌面壳(Tauri)登录后,IM 区域一片空白,15 秒后弹出「IM 加载失败 —— 无法加载 https://nexo-im-pc.pages.dev,请检查网络连接后重试」。
同时成立的三件事让它显得很矛盾:
- macOS 桌面壳完全正常,同一个 tag 构建出的同一批产物
- 同一个 IM 地址在那台 Windows 机器的 Edge 里能正常打开,会话、消息都在
- 壳自身是正常的——顶栏的用户名、退出登录都渲染出来了,说明壳的登录流程走通了,只有 iframe 里是空的
2. 根因
一个为「一份构建同时装进 Web 壳和桌面壳」而设计的广播循环:
ts
// nexo-web-sdk/src/shell-bridge.ts —— 修复前
const targets = shellOrigins.length > 0 ? shellOrigins : ['*'];
for (const target of targets) {
window.parent.postMessage({ event: 'app:ready', payload: null }, target);
}设计意图本身没问题:壳的 origin 随载体而变,所以把所有候选列进白名单,逐个各发一次,只有匹配的那次会被真正投递。
问题出在它建立的那个假设上——targetOrigin 不匹配时浏览器会静默丢弃。
这个假设只对「能解析但不匹配」成立。对「根本解析不出有效 origin」不成立:Chromium 要求 targetOrigin 能解析成非 opaque 的 tuple origin,而 tauri:// 是它不认识的 scheme,解析结果是 opaque origin,于是直接抛 SyntaxError。
在 Chrome 151 里逐个实测线上白名单的三个值:
| targetOrigin | 结果 |
|---|---|
tauri://localhost | 抛 SyntaxError: Invalid target origin |
http://tauri.localhost | 正常 |
https://nexo-app-2d3.pages.dev | 正常 |
把三个放进原来那个循环跑,成功发出的次数是 0——第一项就抛,后面两项根本没执行到。
3. 为什么只有 Windows
Tauri 在两个平台上给壳分配的 origin 不一样,而 tauri://localhost 恰好排在白名单第一位:
| 壳的 origin | 遇到白名单第 1 项 tauri://localhost | 结果 | |
|---|---|---|---|
| macOS(WKWebView) | tauri://localhost | 认得这个自定义 scheme,且正好匹配 | 第一发即命中 ✅ |
| Windows(WebView2 / Chromium) | http://tauri.localhost | 不认得 → 抛错 → 循环中断 | 第 2 项从未发出 ❌ |
同一份代码,在两个引擎里执行到的行数不同。
macOS 的正常是巧合:如果当初白名单顺序反过来写,出问题的就会是 macOS,而 Windows 一切正常。顺序在这里成了隐藏的耦合,而没有任何一处代码或注释表明它是有意义的。
4. 为什么是白屏,而不只是握手失败
announceReady() 在 createAuthClient() 里是同步调用的,而子应用在模块顶层创建客户端:
ts
// nexo-im-pc/src/services/auth-client.ts
export const authClient = createAuthClient(authConfig); // ← 模块顶层于是异常抛在 import 阶段,早于 React 挂载,ErrorBoundary 根本够不着——整个 iframe 白屏,而不是「页面起来了但握手失败」。
值得一提的是,nexo-im-pc 的 config/env.ts 里为这个坑专门写过一大段注释:三处配置校验原本在模块顶层 throw,因为「import 阶段的异常 ErrorBoundary 抓不到,最终表现是白屏」而改成了收集 missingEnvKeys、由 main.tsx 在挂载前统一处理。
坑填了,但这次从 SDK 那一侧又掉进来一次。 一个模块自己不在顶层抛异常,不代表它顶层调用的第三方函数不抛。
5. 排查中走过的弯路
壳侧唯一能观测到的信号是「15 秒内没等到 app:ready」。这个信号分不清两种完全不同的情况:
- iframe 压根没加载出来
- 加载出来了,但消息发不出去
而失败文案写的是**「请检查网络连接」**,直接把排查方向推向网络层。三个最像的嫌疑因此被逐个验证,全都不成立:
| 嫌疑 | 验证结果 |
|---|---|
pages.dev 禁止被 iframe 嵌入 | 响应里没有 X-Frame-Options,也没有 frame-ancestors |
Tauri 的 CSP 缺 frame-src | tauri.conf.json 配的是 "csp": null,不注入任何 CSP |
| 白名单漏配了 Windows 的 origin | 从线上 bundle 里挖出实际值,http://tauri.localhost 明明白白在里面 |
排除完反而更没方向——因为剩下的可能性看起来都不该产生平台差异。这一段是整个排查里最耗时的部分。
真正定位到它靠的是两步实证:
- 从线上产物里挖白名单的实际值,而不是看源码里「应该是什么」。构建期注入的变量,只有产物里的才算数。
- 把那三个 origin 丢进真实 Chrome 逐个
postMessage。第一个就报SyntaxError,完整循环成功次数为 0——一秒钟的事。
跨引擎行为差异这类问题,靠读规范推理是不够的:按 HTML 规范,targetOrigin 走 URL parser,tauri://localhost 能解析成功、不该抛错;Chromium 多加了一层「不能是 opaque origin」的检查。必须在真实引擎里跑一次。
6. 修复
同样的写法在两个仓库各有一份,都得改:
| 位置 | 影响面 |
|---|---|
nexo-web-sdk 的 announceReady() | 握手本身——白屏就是它造成的 |
nexo-im-pc 的 sendToShell() | 未读上报、系统通知、IM 内退出登录 |
改法是每次 postMessage 单独 try,一个 origin 发不出去不影响其余;三个候选全失败时打一条 console.error:
ts
let delivered = 0;
for (const target of targets) {
try {
window.parent.postMessage(message, target);
delivered += 1;
} catch {
// 当前引擎无法解析这个 targetOrigin,跳过它继续发下一个
}
}
if (delivered === 0) {
console.error('[nexo-sdk] app:ready 未能发往任何壳 origin,握手将超时。候选:', targets);
}那条日志和修复本身同样重要:这次的失败是完全静默的,子应用侧不留任何痕迹,壳侧只看到一次超时。
两处都配了回归测试,并且都验证过「把修复回退 → 测试变红 → 恢复 → 变绿」,确认不是假绿。
上线顺序是 nexo-web-sdk 发 0.3.1 → nexo-im-pc 升依赖 → 部署 Pages。桌面壳自身一行没改,不需要重新打包:它加载的是线上 IM,Pages 一更新,现有安装包重开即可。
7. 可推广的规则
一、「逐个尝试、总有一个会成功」的循环,必须假设单次会抛。
否则失败项的位置就成了隐藏的顺序耦合:排在第一个是灾难,排在最后一个毫无影响。这类广播式写法在跨环境适配里很常见,凡是遇到都该检查一遍异常隔离。
二、「浏览器会忽略」这类假设,要按引擎分别验证。
规范说「忽略」,不代表每个引擎的实现都是忽略;Chromium 在这里就比规范多了一层检查。桌面壳天生跨两个引擎(macOS 是 WKWebView、Windows 是 WebView2),是这类差异的高发区。
三、模块顶层不做可能抛异常的初始化。
抛了就是白屏,应用内任何错误边界都救不了。这条在 nexo-im-pc 里已经栽过一次,第二次是从依赖的 SDK 那边进来的——审查这条时要连顶层调用的第三方函数一起看。
四、超时类的失败提示,不要替用户猜原因。
「请检查网络连接」这句话本身就是一次误导。等待超时只能说明「没等到」,说不出「为什么没等到」;文案应该区分它能区分的(比如 iframe 是否触发过 load),说不清的部分就别猜。
8. 遗留
nexo-app 的握手失败提示(workbench-page.tsx)仍然写着「请检查网络连接」。它这次实打实地把排查带偏了一轮,改成区分「iframe 没加载」和「加载了但没握手」会让下次快很多,尚未处理。