Files
Commilitia-Drop/ios/PLAN.md
T
admin eda23f79b1 docs:iOS 客户端实施计划(ios/PLAN.md)
- WKWebView 原生壳复用 web bundle + APNs 推送 + 原生剪贴板 / 分享扩展的架构与里程碑
  I0–I6;明确哪些账号无关可先做、哪些硬等 Apple Developer 账号
- APNs 服务端通道复用本次 Web Push 的「未投递才推 + 按 locale 渲染」模型,设计冻结、
  待 .p8 同期落地
2026-06-18 16:56:52 +08:00

120 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# cdrop iOS 客户端实施计划
> WKWebView 原生壳 · 复用 `web/` bundle · APNs 推送 + 原生剪贴板 / 分享
> 状态:**规划中,待 Apple Developer 账号**。本文是“账号到位即可开工”的准备文档——把架构、里程碑、以及哪些活现在就能干 / 哪些硬等签名讲清楚。
> 关联:根 `README.md`、`desktop/PLAN.md`(同“原生壳复用同一份 UI”思路的先例)、`internal/push`Web Push 服务端,APNs 通道与之并列)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。
---
## 0. 范围、定位与关键决策
**定位**:与桌面端同理——iOS 客户端不是“另写一套 UI”,而是“带原生外壳的同一份 UI”。UI 复用 `web/src` 的 React 应用(WKWebView 加载),价值全在原生层补浏览器在 iOS 上做不到的事。
**为什么要原生壳(而非仅靠 PWA**:任务 2 的 Web Push 已让 iOS 16.4+ 已安装的 PWA 能收推送,所以“通知”本身不必等原生。原生壳的真正价值在三处 iOS 网页端做不到的能力:
1. **原生剪贴板**iOS 对网页 `navigator.clipboard` 限制极严(仅用户手势触发、无法后台监听),而 cdrop 的核心是剪贴板同步。原生 `UIPasteboard` 才能做到顺畅读写。
2. **Share Extension**:从任意 app 的分享菜单直接“发送到 cdrop”,网页无法注册。
3. **后台与通知**:原生 APNs + 后台执行窗口,配合通知点击深链回 app。
**范围**:常驻 iOS 客户端,复用 web UI + 原生层(剪贴板 / 分享 / APNs / 登录态 Keychain)。
**非范围(推后)**iPad 专属布局、Apple Watch、App Clip、原生 SwiftUI 重写 UI(明确不做——UI 复用 web)、Android。
### 决策 A:架构 = WKWebView 原生壳 + APNs(已定)
用户在 2026-06-18 的选型中选定**WKWebView 原生壳 + APNs**(而非 PWA-only,也非全原生 SwiftUI)。理由:复用现有 web UI、与桌面瘦客户端架构一致、工作量中等,同时拿到原生剪贴板 / 分享 / 后台。
### 决策 B:硬等 Apple Developer 账号
iOS 的真机调试、TestFlight / App Store 分发、APNs 全部要求 Apple Developer Program 成员资格 + 签名证书 + Provisioning Profile。**账号到位前**只能:写服务端 APNs 通道(账号无关的代码结构)、scaffold Xcode 工程、在模拟器跑 UI(模拟器不收 APNs)。**账号到位后**才能:真机推送、剪贴板 / 分享扩展真机验证、分发。
---
## 1. 与任务 2(Web Push)服务端模型的关系
任务 2 已落地服务端推送底座,iOS 的 APNs 通道**复用同一套设计**,只换发送后端:
| 维度 | Web Push(已建) | iOS APNs(待建) |
|---|---|---|
| 凭据 | 自签 VAPID 公私钥 | Apple APNs Auth Key`.p8` + Key ID + Team ID |
| 订阅令牌 | endpoint + p256dh/auth | APNs device token(每安装一枚) |
| 存储 | `push_subscriptions` 表 | 同表加 `platform` 列区分,或新 `apns_tokens` 表 |
| 收件定位 | `(user_id, device_name)` | 同上——iOS 设备登记 `device_type=ios` |
| 触发门控 | `Hub.SendTo` 未投递才推 | **完全相同**——前台 WebView 在线即走 SSE,挂起 / 关闭才 APNs |
| 文案渲染 | 服务端按订阅 `locale` 渲染 | 同上,复用 `internal/push``localize` |
**关键**:“页面打开用应用内提示,否则系统通知”的判定在 iOS 上=**app 是否前台**。前台时 WebView 的 SSE 在线、`Hub.SendTo` 投递成功 → 应用内呈现;app 挂起 / 退后台 → SSE 断 → `SendTo` 失败 → 服务端发 APNs。与 Web 端逻辑同构,只是“在线”的物理含义不同。
> ⚠️ iOS WebView 退后台后会被系统挂起,SSE 必断。所以 iOS 的后台通知**只能**靠服务端 APNs,**不能**像桌面那样靠常驻进程里的 JS(桌面 Go 进程常驻、iOS 没有等价物)。这正是必须建 APNs 通道、而非复用桌面 `notifyNativeIfBackground` 思路的原因。
---
## 2. 里程碑
| 里程碑 | 内容 | 账号依赖 |
|---|---|---|
| **I0** 账号与证书前置 | Apple Developer Program 加入;App ID`net.commilitia.cdrop`,与桌面 bundle id 一致或加 `.ios` 后缀);APNs Auth Key `.p8`Provisioning Profile | **硬等账号** |
| **I1** WKWebView 壳骨架 | Xcode 工程;WKWebView 加载 `https://drop.commilitia.net`(或内嵌 `web/dist`);注入 `X-Device-Type: ios`;基础生命周期 | 模拟器可跑;真机等账号 |
| **I2** 登录态 | `ASWebAuthenticationSession` 跑 OIDC PKCE(系统级安全浏览器,复用 web client);refresh_token 存 **iOS Keychain**,JS 永不持有(与桌面 Go keyring、web HttpOnly cookie 同策略) | 模拟器可跑 |
| **I3** APNs 推送(核心) | 原生注册 APNs device token → 上报 `POST /api/push/apns/register`;**服务端 APNs 发送通道**(见 §3);通知点击深链回 app | 真机硬等账号 |
| **I4** 原生剪贴板 | `UIPasteboard` 读(用户手势 / 前台)+ 写;与云剪贴板 LWW 模型对接(复用 `/api/clipboard`);说明 iOS 后台无法监听的限制 | 模拟器部分可跑 |
| **I5** Share Extension | 独立 extension target:从任意 app 分享文件 → 唤起 cdrop 发送流程 | 真机硬等账号 |
| **I6** 打磨与分发 | 后台执行窗口;图标 / 启动屏(复用品牌资产);TestFlight → App Store | 硬等账号 |
---
## 3. 服务端 APNs 通道(账号无关的结构,可先设计)
新增 `internal/push` 的兄弟实现 `internal/apns`(或 `push.Sender` 旁加一个 APNs 后端),与 Web Push 并列:
- **库**APNs 走 HTTP/2 + JWT`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2` 之类,或自手写(与现有 go-jose 一致)。
- **配置**koanf`CDROP_APNS_*`,全可选——缺失即 iOS 推送惰性关闭,类同 VAPID):`APNS_KEY_PATH``.p8`)、`APNS_KEY_ID``APNS_TEAM_ID``APNS_TOPIC`bundle id)、`APNS_ENV`sandbox / production)。
- **存储**`push_subscriptions``platform TEXT NOT NULL DEFAULT 'web'` 列 + iOS 行存 APNs token(复用 `endpoint` 字段存 token,或加专列)。`SubscriptionID` 仍用 token 的哈希保持幂等 upsert。
- **端点**`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
- **发送**:在 `transfer:incoming` / `message` / `transfer:state(DONE|FAILED)` 的“未投递”分支里,按 `platform` 分流到 Web Push 或 APNs。文案复用 `internal/push``localize`(已支持 zh-CN / zh-TW / en-US)。
> 这部分**代码结构账号无关**,但 APNs 发送要 `.p8` 才能真发 / 真测,故**与 iOS 客户端同期做**(账号到位后),避免落一坨无法验证的代码。设计先定在此。
---
## 4. 复用边界与平台注入
- **UI 全复用** `web/src`iOS 壳不写业务 UI。
- **平台判定**web 现有 `isDesktop()``window.runtime` + `window.go`(Wails 注入)。iOS 壳需类似注入一个标识(如 `window.__CDROP_IOS__``WKScriptMessageHandler` 桥),前端加 `isIOSShell()`,让原生剪贴板 / 分享走桥、Web Push 入口隐藏(iOS 用 APNs 不用 VAPID)。
- **JS↔原生桥**`WKScriptMessageHandler`JS→Swift+ `evaluateJavaScript`Swift→JS),类比 Wails 的 `EventsEmit` / bound methods。需要的桥方法初稿:`requestApnsToken``readClipboard` / `writeClipboard``saveFile``showNotification`(前台)。
- **登录回跳**`ASWebAuthenticationSession` 的 callback URL scheme 注册(如 `cdrop://oauth/callback`),与 web 的 `redirect_uri` 并存(后端 `/api/auth/config` 已可按 client 下发;或复用 web client 的 loopback 思路)。
---
## 5. 风险与未决
- **R-iOS-1 WebView 后台挂起**app 退后台 SSE 必断 → 后台通知只能靠 APNs(已纳入 I3)。前台恢复时 WebView 需重连 SSEweb `startHub` 的重连循环已具备)。
- **R-iOS-2 剪贴板后台限制**:iOS 不允许后台读剪贴板(隐私);只能前台 / 用户手势。同步语义要据此降级(前台进入时同步一次,而非桌面式实时监听)。iOS 14+ 还有“粘贴需用户确认”横幅。
- **R-iOS-3 远端 vs 内嵌 bundle**WKWebView 加载线上 `drop.commilitia.net`(部署即更新、但离线不可用)vs 内嵌 `web/dist`(随 app 版本、可离线壳)。倾向**内嵌 bundle + API 指向线上**(与桌面一致:`go:embed` 的对应物是 app bundle 里的静态文件),首屏快且可控。
- **R-iOS-4 App ID 与 audience**iOS client 若用独立 OAuth client_id,后端 `CDROP_OIDC_AUDIENCE` 需纳入(已支持逗号分隔多值);若复用 web client(如桌面),则无需改。倾向复用 web client(最省)。
- **R-iOS-5 审核**App Store 审核对“仅是网页壳”的 app 有顾虑(Guideline 4.2)。原生剪贴板 + 分享扩展 + APNs 提供了足够的原生价值,过审风险可控,但需在审核说明里强调。
---
## 6. 现在能做 vs 等账号
**账号到位前可做(账号无关)**
- 本计划(✔ 本文)。
- 服务端 APNs 通道的**设计冻结**(§3)——代码待 `.p8` 同期落,避免无法验证的悬空实现。
- 前端 `isIOSShell()` 平台注入点的预留(可与 iOS 壳同期,改动小)。
**硬等 Apple Developer 账号**:Xcode 工程真机签名、APNs 真发真测、Share Extension、TestFlight / App Store 分发。
---
## 附:与桌面端的策略对照(一以贯之)
| 维度 | 桌面(Wails | iOSWKWebView 壳) |
|---|---|---|
| UI | 复用 `web/src` | 复用 `web/src` |
| refresh_token | Go keyringAES-GCM | iOS Keychain |
| 登录 | loopback PKCEGo 内) | `ASWebAuthenticationSession`PKCE |
| 后台通知 | Go 常驻进程 + 原生 API(窗口非前台才弹) | **服务端 APNs**app 挂起无常驻进程) |
| 剪贴板 | Go 原生 Monitor 双向实时 | `UIPasteboard`,前台 / 手势(后台受限) |
| 平台判定 | `isDesktop()``window.runtime` | `isIOSShell()`(注入标识) |