---
url: /wave-agent/specs/remote/remote-pwa.md
description: 把远控页装到主屏：主屏元信息（图标与具名）、接收推送的 Service Worker，以及锁屏也能收到的两条通知
---

# 功能规格说明：PWA 集成

**创建日期**：2026-10-13（2026-10-30 从 `ui/remote-control.md` 拆出）

本文只讲**装到主屏之后**的那一层：主屏上的图标与名字、接收推送的 Service Worker、以及锁屏时也能到达的两条通知。页面本身见 `remote-web.md`，触控设备的尺寸与字号见 `remote-mobile.md`。

## 用户场景与测试 *（必填）*

### 用户故事：PWA 主屏元信息——真实图标与具名（优先级：P1）

作为把页面「添加到主屏幕」的用户，我希望主屏上出现的是一个**可辨识的图标和名字**，而不是灰色默认图标加一段截断的域名。

**为什么是这个优先级**：主屏入口是这个功能在手机上的主要形态；没有元信息，「添加到主屏幕」这个动作产出的东西既认不出也不愿点。图标只有**一份**、不分机器——机器的区分是页面内机器列表的事（见 `remote-machine.md` 的「机器列表与切换」故事），这正是「一个图标管所有机器」的字面义。

**独立测试**：在 iPhone Safari 打开页面地址 → 分享 → 「添加到主屏幕」，得到具名图标（非灰默认）与名字；点开以 standalone（无浏览器工具栏）形态打开；构建产物里含 manifest 与真实 PNG 图标（内容哈希命名，随 zip 走既有上传通道）。

**验收场景**：

1. **假设** 页面被加到主屏幕，**则** 产物提供全部元信息：manifest（`name` / `short_name`、`display: standalone`、`start_url` 指页面根——**不带查询参数**，机器的选择属于页面内的状态而不是入口的身份；主题色取页面深色主题）、真实 PNG 图标（以品牌应用图标为源，一份内容哈希文件同时充当 favicon 与 `apple-touch-icon`，替换掉现在的空图标 `data:,`）、`apple-mobile-web-app-title`（短名，主屏宽度内不截断，取品牌名）。iOS 用 `apple-touch-icon` 与这个短名渲染主屏条目；standalone 用 manifest 与 meta 两条路都声明——各代 iOS 各取其一。
2. **假设** 浏览器要取 manifest 与图标，**则** CSP 必须放行：`manifest-src` 缺省回落到 `default-src 'none'`，必须显式加 `'self'`；图标走 `img-src 'self'`（已有）。
3. **假设** 产物走既有上传通道，**则** 不需要收端配合：`.png` 与 `.json`（manifest 以 `manifest.json` 命名，`application/json` 浏览器接受）都已在运营平台上传接口的扩展名白名单与 MIME 表里（2026-10-15 对收端源码核实）。
4. **假设** 用户在电脑侧开启了远程控制、或在页面上看到机器列表，**则** 两处各有一句引导：**用相机扫码或在 Safari 里打开，再「添加到主屏幕」；不要用控制中心的扫码器**——它把链接开在沙箱容器里，没有 Safari 的会话存储，既不保留页面也加不了主屏（2026-10-14 真机实测）。iOS 没有 `beforeinstallprompt`，引导只能是文案。

***

### 用户故事：锁屏也能知道——会话等着我、或者跑完了（优先级：P1）

作为把页面加到主屏、然后锁屏揣兜里的人，我希望「某条对话在等我确认」和「某条对话已经跑完」这两件事能作为系统通知推到我手机上，点开直达那条对话，以便不必一直盯着页面。

**为什么是这个优先级**：页面在后台会被 iOS 冻结、长连必断（`remote-channel.md` 的「断线自愈」故事已把接回做掉），没有推送就只剩「主动打开页面才知道」。而「人不在电脑前也能把卡住的会话推下去」正是远程控制不可替代的那一半——中继侧的推送基建（VAPID 密钥、订阅登记、`requires_action` 触发与限流）已经就绪，缺的只是页面这半。**跨仓依赖**：两条都已在中继侧落地——待确认那条本来就有；「已完成」那条 2026-10-16 放行（判据＝真正的 `running → idle` 跳变，文案与载荷见 codechat `!193`，已按该分支构建部署到测试环境、MR 未合），属 codechat 仓库的改动。

**独立测试**：手机把页面添加到主屏并授权通知 → 电脑侧触发一次工具审批 → 锁屏收到「有会话等待确认」，点开直达该对话的审批卡；批准后这一轮跑完，再收到「有会话已完成」，点开仍直达该对话；页面在前台且正显示该对话时，同样两件事都**不弹横幅**（页面内的卡片与会话列表标记照常）。

**验收场景**：

1. **假设** 某会话进入待确认——工具审批、`AskUserQuestion` 提问、或 `ExitPlanMode` 计划待批准，**则** 手机收到一条推送「`<机器名>` · 有会话等待确认」，点开直达该对话。**载荷不含会话标题、也不含命令或消息内容**：中继只拿得到「哪个会话、什么状态」这两个非内容字段（见边界情况「不解析内容」）。这三类共用同一条信号——`requires_action` 的判据就是该会话有待确认请求（`listSessionStatuses`），而提问与计划批准即使在 `bypassPermissions` 下也照样提示（`permissionManager` 的 `USER_INTERACTION_REQUIRED_TOOLS`）。
2. **假设** 某会话从「运行中」变为「空闲」（一轮生成结束、后台任务收尾——`running` 本就含后台任务），**则** 收到一条推送「`<机器名>` · 有会话已完成」，点开直达该对话。**依据**：桌面端把「已完成」交给侧栏绿点（2026-09-10 拍板移除「已完成」toast），但那条理由在这里不成立——页面没有已读跟踪（`remote-web.md` 的「待确认只在它自己的对话里弹」故事场景 6 已明确不做绿点），且页面不可见时用户眼前什么都没有，「跑完了」与「等着我」一样是只有推送能送达的信息。**这是与桌面端的刻意不一致，理由在此。**
3. **假设** 同一会话重复上报同一状态（仍是 `requires_action`、或仍是空闲），**则** 不重复推送——只在「进入」该状态的那一次推。**假设** 同一台机器 60 秒内推送超过 30 次，**则** 超出部分只更新状态、不投递（中继既有兜底：`PUSH_RATE_MAX = 30` / `PUSH_RATE_WINDOW_SECONDS = 60`；机器级天花板不变，只是「已完成」另有一层更严的子限＝一半预算，免得一轮一条的它把「待确认」挤掉——判据在被挡掉时不消耗共用桶）。
4. **假设** 页面此刻**可见**（Service Worker 的 `clients` 里有可见页面），**则** 不弹系统横幅，只在页面内呈现（该弹的卡照弹、会话列表标记照标）；页面不可见（后台、锁屏、已关闭）时才弹。**依据**：与桌面端「会话已显示在某个分屏就不弹 toast」同款口径（`../desktop/desktop-sessions.md`「后台会话确认提醒」场景 2）——用户正看着那一页时，横幅是重复打扰。
5. **假设** 用户点开推送，**则** 打开 `/remote/?host=<hostId>&session=<sessionId>` 并落在该对话上（与扫码入口、中继落地页模板同一个形状）；该会话若已不存在，**则** 落在会话列表并给出明确状态，不白屏。
6. **假设** 用户在页面上订阅或注销通知，**则** 浏览器订阅经中继登记（`POST /api/relay/push/subscriptions`）与删除（`DELETE /api/relay/push/subscriptions/:id`），VAPID 公钥取自 `GET /api/relay/push/public-key`。页面同时自己注册那个接收推送的 Service Worker（与其余资源一样哈希命名、由构建把名字填进去，scope 落在页面自己的目录），因此 CSP 要显式写 `worker-src 'self'`——缺省时它回落到 `script-src 'self'`，这一行是把那条回落写明白而不是放宽；注册不依赖通知权限、也不依赖中继或登录态，所以它在页面启动时完成，不带任何请求。
7. **假设** 运行在 iOS 且页面**尚未添加到主屏**，**则** 页面给出「添加到主屏幕后才能开启通知」的引导——iOS 16.4 起 Web Push 只对主屏 web app 可用，普通 Safari 标签页拿不到 `PushManager`，不静默失败；**假设** 浏览器不支持推送、或用户拒绝通知权限，**则** 同样明确呈现，页面其余功能不受影响。

**边界**：不做 APNs、不做原生 App（形状已定）。推送里只有机器名、会话 id 与状态词这些**非内容**信息——会话标题**不**进推送：要带标题就得让本机把内容上报给中继，与「中继不落内容」的隐私边界冲突（2026-10-16 拍板）。

## 未落地与待决

* \***\*Web Push 推送（2026-10-16 拍板并实现页面侧，真机未验）**：**页面侧已落地**——`remote/sw.js`（Service Worker：只做推送，不缓存页面）由 shim 在 `boot()` 里注册（名字经 `build.mjs` 填成哈希名），`MoreMenu` 的「通知」入口按 shim 回的四个事实渲染（可开 / 可关 / 三条「用不了」的说明），订阅经 `GET /api/relay/push/public-key` + `POST /api/relay/push/subscriptions` 登记、在 `DELETE /api/relay/push/subscriptions/:id` 注销（收端三端点与载荷形状已对 `codechat` `origin/main` 核实：`{publicKey}`、`{endpoint, keys{p256dh,auth}}` → `{id}`、载荷 `{title, body, url, data{hostId, sessionId, state}}`）。用例见 `packages/webview/tests/remote/shimPush.test.ts`（订阅生命周期）、`packages/webview/tests/remote/sw.test.ts`（可见页面不弹横幅、点击直达会话）与 `packages/webview/tests/webview/remotePagePushToggle.test.tsx`（入口文案：四档可操作的开关态 + 三条「用不了」的说明）。口径见「锁屏也能知道」故事：四类待确认（工具审批 / 提问 / 计划批准）共用一条推送、「已完成」也推、**不带会话标题**、页面可见时不弹横幅。**仍未做的**：ⓐ 真机（真中继 + 真浏览器 + 锁屏）确认整条链路，尤其 iOS 只在「添加到主屏幕」后才拿得到 `PushManager`——中继那半 2026-10-16 已放行（`running → idle` 也推、`data.state` 改传真实状态，属 codechat `!193`，已按该分支构建部署到测试环境、MR 未合），本机那半仍待把页面产物上传后才能一起验。
