From eda23f79b16094d255abd6006d474cce0200ee0d Mon Sep 17 00:00:00 2001 From: commilitia Date: Thu, 18 Jun 2026 16:56:52 +0800 Subject: [PATCH] =?UTF-8?q?docs=EF=BC=9AiOS=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=88ios/PLAN.md?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WKWebView 原生壳复用 web bundle + APNs 推送 + 原生剪贴板 / 分享扩展的架构与里程碑 I0–I6;明确哪些账号无关可先做、哪些硬等 Apple Developer 账号 - APNs 服务端通道复用本次 Web Push 的「未投递才推 + 按 locale 渲染」模型,设计冻结、 待 .p8 同期落地 --- ios/PLAN.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 ios/PLAN.md diff --git a/ios/PLAN.md b/ios/PLAN.md new file mode 100644 index 0000000..c219309 --- /dev/null +++ b/ios/PLAN.md @@ -0,0 +1,119 @@ +# 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()`(注入标识) |