给站点加一个只对白名单设备可见的管理入口,看似是一次简单的权限收敛,却在 iOS Web Clip 上牵出三层叠加的假象:一个永不 resolve 的 Promise、一个卡死在 installing 的 Service Worker、一个被远程调试污染的权限判断。记录完整的排查链路和每一步的证据。
前两篇解决的是「站内交互的连续性」(下拉刷新)和「站外触达的可能性」(Web Push 通知)。这篇要解决的是如何把管理页面的入口「安全地」暴露在站点上,但只对特定设备可见。
问题不难,方案也很快定了下来。真正花时间的是把方案落地后,在 iOS Web Clip 上完全无法验证成功,排查过程比功能本身长得多,牵出了三个叠在一起的假象,最终定位到一处从项目初期就存在、但从未被真正测试到的疏漏。
由于 iOS Web Clip 没有地址栏,如果一个页面没有入口链接,就只能永远打不开。管理页面本身已经有独立的密码机制,缺的只是「怎么导航到那里」。
最终选择直接复用 Web Push 的订阅 endpoint 作为设备标识(Chen 注:试想过用一个专门生成的设备 token(存在 localStorage 里)来做这件事,比复用推送订阅更稳定,推送订阅的 endpoint 会在重装、清缓存时失效,需要手动更新白名单。但我更想复用「已经在做的事」,接受这个偶尔要维护的代价。),而不是另起一套 token 机制:
数据模型很简单,一个 Redis hash,key 是 endpoint,value 是这台设备可见的页面 key 数组:
const DEVICE_PAGES_KEY = "push:device-pages";
export const DEVICE_PAGE_KEYS = ["push", "todo", "comments", "annex"] as const;
export type DevicePageKey = typeof DEVICE_PAGE_KEYS[number];
export async function getDevicePages(endpoint: string): Promise<DevicePageKey[]> {
const raw = await redis.hget<DevicePageKey[] | string>(DEVICE_PAGES_KEY, endpoint);
if (!raw) return [];
const parsed = typeof raw === "string" ? JSON.parse(raw) as unknown : raw;
return Array.isArray(parsed) ? parsed.filter(isDevicePageKey) : [];
}配合一个不需要鉴权的公开接口,只返回「这个 endpoint 能看哪些页面 key」,不泄露任何其他设备信息:
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const endpoint = body?.endpoint;
if (typeof endpoint !== "string" || !endpoint) {
return Response.json({ pages: [] });
}
const
首页这边,一个 client 组件在 useEffect 里拿当前设备的订阅 endpoint,查一次白名单,命中就渲染入口,不命中什么都不渲染。没有 loading 占位,非白名单用户看到的首页在 DOM 结构上跟命中前完全一样,避免暴露「这里本该有什么」的痕迹:
export function AdminEntryLinks() {
const [pages, setPages] = useState<DevicePageKey[]>([]);
useEffect(() => {
if (!isMobile()) return;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
白名单本身的维护也不新开页面,直接复用现有的订阅设备列表里。这一层功能写完、类型检查通过、部署上线,看起来是一次很平常的小改动。
serviceWorker.ready 永远 pending部署后第一件事是确认白名单生效,但在 iOS Web Clip 里打开则什么都没出现。
先用最直接的方式确认:远程连上 Mac Safari 的 Web Inspector(Chen 注:iOS 本身不带开发者工具,调试 Web Clip/PWA 只能借道 Mac,在 iOS 和 Mac 上的 Safari 都打开「Web 检查器」,之后 Mac Safari 就能看到这台设备,选中对应页面或 Web Clip 实例,就能打开一个连着真机、实时同步的 Web Inspector。Console、Network、Elements 面板跑在真实的 iOS WebKit 环境里,不是模拟器,这次能在真机上直接跑 `register()`、`getRegistrations()` 观察 Service Worker 状态,靠的就是这条链路。),在 Console 里手动跑一遍订阅状态判断的逻辑。
navigator.serviceWorker.ready返回的是一个 Promise {status: "pending"},不是慢,是永远不会 resolve。这一下解释了当时观察到的所有现象:
usePushSubscription 的初始 useEffect 里,serviceWorker.ready.then(...) 永远不会触发,2 秒兜底 timeout 先到,把状态强行设成 "unsubscribed",铃铛按钮显示成「订阅更新通知」,即便这台设备实际上一直在收推送。subscribe() 分支,函数体内 await navigator.serviceWorker.ready 卡住,永远不会往下执行到 pushManager.subscribe(),也就永远不会发出网络请求,这和跟观察到的「Console 无输出、Network 无请求」完全吻合。serviceWorker.ready 的语义是「等到有一个 active 的 Service Worker 时才 resolve」。第一个合理的怀疑方向是:这个 Promise 之所以卡住,可能是当时还没有任何 Service Worker 注册过。用 getRegistrations() 确认了这一点:数组是空的。
修复思路是不要依赖一个「可能永久悬挂」的 Promise 来判断状态,换成立即返回结果的 getRegistration():
// 之前
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => setState(sub ? "subscribed" : "unsubscribed"));
// 之后:getRegistration 立即返回已存在的 registration 或 undefined,不会悬挂
navigator.serviceWorker.getRegistration()
.then((reg) => reg ? reg.pushManager.getSubscription
subscribe() 和 unsubscribe() 内部同样用到 serviceWorker.ready 的地方也一并替换。部署、重新打开 Web Clip,但问题依旧存在。
换掉 API 之后毫无改善,说明问题比「选错了哪个 Promise」更底层。回到远程调试,直接调用 register(),绕开所有业务逻辑,看最原始的注册行为:
navigator.serviceWorker.register("/sw.js")
.then(reg => console.log("registered:", reg))registered: ServiceWorkerRegistration
installing: ServiceWorker
waiting: null
active: null
scope: "https://whchen.dev/"注册本身成功了,sw.js 文件没问题,scope 也对。但 active: null,installing 里挂着一个 ServiceWorker 对象,但它从未真正激活过,卡死在最初的安装阶段。这就是 serviceWorker.ready 永远 pending 的根本原因:ready 的定义是「等到 active」,而这个 SW 永远不会变成 active,ready 自然永远不会 resolve。之前换 API 只是绕开了症状,没碰到病灶。
Service Worker 的标准生命周期是 installing → installed → activating → activated,中间那一步默认要等到「所有由旧版本 SW 控制的页面都关闭」才会往下走。查看 public/sw.js,里面只有 push 和 notificationclick 两个事件监听器,完全没有处理 install / activate 事件:
self.addEventListener("push", (event) => { /* ... */ });
self.addEventListener("notificationclick", (event) => { /* ... */ });这不是代码写错了,是缺了显式的生命周期控制(Chen 注:没有显式调用 `skipWaiting()` 时,浏览器默认让新 SW 停留在 waiting 状态,等所有现有 client(打开的标签页/窗口)都关闭并重新打开后才激活,这是为了避免同一个页面在生命周期中被不同版本的 SW 混合控制。iOS WebKit 在 standalone 模式下,这个「等待所有 client 关闭」的条件判断存在已知的实现差异,实际观察到的行为是即使只有一个 client(这个 Web Clip 本身),SW 依然可能长期卡在 installing,不会自然推进到 activated。)。补上 skipWaiting() 和 clients.claim(),让新 SW 装好立刻激活、激活后立刻接管所有页面,不依赖「等旧 tab 关闭」这个在 Web Clip 场景下很难自然满足的条件:
// Service Worker for Web Push Notifications
// 立即激活,不等待旧 client 关闭——iOS standalone 下否则可能永久卡在 installing
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event)
部署,完全退出 Web Clip 重新打开,再跑一次 getRegistrations():
[{ scope: "https://whchen.dev/", active: true, waiting: false, installing: false }]active: true。这一层问题解决了。
SW 激活之后,点击订阅按钮,还是没有反应。Console 依旧空白,Network 依旧没有请求。
这时候怀疑的方向变了:SW 已经不是瓶颈,问题可能出在事件本身有没有真的到达处理函数。用远程调试的元素选择工具点按钮,Elements 面板准确定位到了 <button aria-label="订阅更新通知">,按钮本身没错位、没被盖住,aria-label 显示的是「未订阅」态,跟设备实际收得到推送的事实矛盾,但暂且按下不表,先确认订阅状态:
navigator.serviceWorker.getRegistration()
.then(r => r.pushManager.getSubscription())
.then(s => console.log('sub:', s))
// sub: null真的是 null。于是继续往前查权限:
Notification.requestPermission().then(p => console.log('permission:', p))
// permission: "denied"「denied」。看起来问题揭晓了,通知权限被拒绝,subscribe() 函数第一行 await Notification.requestPermission() 直接返回 "denied",函数走 return,不会有任何后续请求。
但这个结论站不住:设备一直能收到推送,iOS 系统设置里通知开关也确认是打开的。「系统层面允许,API 却报告 denied」,两者不可能同时为真,除非查错了对象。
问题出在查证方式本身:Notification.requestPermission() 是一个方法调用,而不是单纯读取状态。iOS WebKit 对这个方法有一条特殊限制,必须由真实的用户手势同步触发,否则可能返回一个不代表真实系统状态的保护性结果。而这次调用是在 Mac 远程调试的 Console 里手动敲的,不带任何用户手势上下文。换成只读属性重新确认:
console.log('actual permission:', Notification.permission)
// actual permission: "denied"依然是 denied。这次排除了「方法调用被手势限制污染」的可能,矛盾还在——系统设置允许,属性却是拒绝。回头再看一遍整个调试对象:整个过程连的确实是「添加到主屏幕」的 Web Clip 实例,不是平时用 Safari App 打开的普通标签页。
到这里,唯一能同时解释「系统允许」和「API 报告拒绝」的可能,是这两者本来就不是同一份状态:iOS 上通知权限的系统开关,和某个 Web App 实例首次请求权限时被缓存下来的 Notification.permission 值,在特定情况下会不同步,如果这个实例最初请求权限时曾经被拒绝过(哪怕是很早以前的一次失败请求),之后即使去系统设置里手动打开,网页 JS 侧读到的值也可能不会自动刷新。
这不是能在代码层面修的问题,需要清掉这个实例的历史状态,重新走一次完整流程:删除 Web Clip → 清 Safari 里这个站点的网站数据 → 重新添加到主屏幕 → 重新订阅。这次订阅弹出的是一次全新的系统权限请求,Notification.permission 变成 granted,订阅按钮恢复正常响应。
回头看整个排查链路,三个问题互相掩盖:
ready → getRegistration())解决的是「怎么正确地问问题」,没有触碰到「SW 为什么从未激活」这个真正的病灶。skipWaiting() 解决的是 SW 生命周期本身的缺陷。这是三层里唯一改了生产代码的一环,也是唯一称得上「bug」的部分。前两次尝试都停留在症状层面,直到确认 active: null 才找到了需要动代码的地方。调试工具本身的行为特性,也可能成为排查过程里的一个变量(Chen 注:这次排查里最容易走偏的一步,是在 Console 里手动调用 requestPermission() 来验证权限——这个动作本身会因为缺少用户手势而返回不可信的结果,等于是用一把不准的尺子去量一个已经不准的东西,两次误差叠加后指向了完全错误的结论。后续验证权限状态改用只读属性 Notification.permission,避免再触发这个副作用。),而不只是一个透明的观察窗口。
修复落地在两个文件:public/sw.js 加了 install/activate 的生命周期控制,PushSubscribeButton.tsx 和 AdminEntryLinks.tsx 里所有 serviceWorker.ready 换成 getRegistration()。设备侧的动作是删除重装一次 Web Clip、重新走一遍订阅流程。
功能本身(白名单模型、条件渲染入口)从设计到实现只用了很短时间,验证阶段反而牵出了一处从系列第一篇上线起就存在、但从未被真正触发过的生命周期缺陷:因为这个站点此前的 Service Worker 更新场景很少(sw.js 几乎没改过),「新 SW 装好后卡在 installing」这条路径一直没被走到过,直到这次改动第一次触发了完整的重新注册流程,才第一次暴露出来。
三篇系列文章合起来,覆盖了 iOS Web Clip 场景下三类不同性质的问题:交互连续性(下拉刷新)、站外触达(Web Push 的实现)、以及触达能力本身的可靠性(Service Worker 生命周期)。后者往往是最容易被忽略的一层,功能「能用」和功能「在所有场景下都能正确进入可用状态」之间,中间隔着一整套生命周期假设,而这类假设不触发一次真实的冷启动,很难被测试到。