# 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 需重连 SSE（web `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） | iOS（WKWebView 壳） |
|---|---|---|
| UI | 复用 `web/src` | 复用 `web/src` |
| refresh_token | Go keyring（AES-GCM） | iOS Keychain |
| 登录 | loopback PKCE（Go 内） | `ASWebAuthenticationSession`（PKCE） |
| 后台通知 | Go 常驻进程 + 原生 API（窗口非前台才弹） | **服务端 APNs**（app 挂起无常驻进程） |
| 剪贴板 | Go 原生 Monitor 双向实时 | `UIPasteboard`，前台 / 手势（后台受限） |
| 平台判定 | `isDesktop()`（`window.runtime`） | `isIOSShell()`（注入标识） |
