Skip to content

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-srctauri.conf.json 配的是 "csp": null,不注入任何 CSP
白名单漏配了 Windows 的 origin从线上 bundle 里挖出实际值,http://tauri.localhost 明明白白在里面

排除完反而更没方向——因为剩下的可能性看起来都不该产生平台差异。这一段是整个排查里最耗时的部分。

真正定位到它靠的是两步实证:

  1. 从线上产物里挖白名单的实际值,而不是看源码里「应该是什么」。构建期注入的变量,只有产物里的才算数。
  2. 把那三个 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 没加载」和「加载了但没握手」会让下次快很多,尚未处理。