349d94aa8f
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/) - UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型 - 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber - 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md - 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主 - 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate - 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方 - 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
317 lines
35 KiB
Markdown
317 lines
35 KiB
Markdown
# cdrop iOS 客户端实施计划
|
||
|
||
> 原生 SwiftUI 界面(液态玻璃)· 离屏无头 WebView 复用传输引擎 · 控制中心剪贴板 + Share Extension + APNs
|
||
> 状态:**架构已定(2026-06-24 修订)**,账号无关部分即刻可开工;真机 / 推送 / 扩展实测硬等 Apple Developer 账号。
|
||
> 关联:根 `README.md`、`desktop/PLAN.md`、`internal/push`、`ios/PARITY.md`(web↔iOS 同步检查单)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。
|
||
|
||
---
|
||
|
||
## 0. 定位与关键决策(2026-06-24 修订)
|
||
|
||
定位变更:iOS 客户端**不再**是“web UI 装进 WebView”,而是“**原生 SwiftUI 界面 + 复用的无头传输引擎**”。起因——新增“服从 iOS 设计语言(液态玻璃)”硬要求,与“WebView 里跑 React UI”直接冲突:液态玻璃是 iOS 26 系统级材质,只有原生 SwiftUI 的 `.glassEffect` 拿得到,网页只能 CSS 仿。故 UI 转原生,但**保留引擎复用**。
|
||
|
||
### 决策 A(修订):架构 = 原生 SwiftUI 壳 + 离屏无头 WebView 引擎
|
||
|
||
取代 2026-06-18 的“WebView 复用 React UI”决策。
|
||
|
||
- 所有可见 UI 用**原生 SwiftUI**(iOS 26 液态玻璃;旧版优雅回落,见 §7)。
|
||
- 文件传输的 **P2P + relay + 会话 + 信令逻辑 = 现有 `web/src` 的 JS 引擎本体**,跑在**离屏(无 UI)WKWebView** 里,原生经桥驱动。零重写、保住同内网 P2P、行为与 web 天然同构(上轮刚修的“按已交付字节算进度”“抽干再标完成”等自动一致)。
|
||
- **兜底方案 B**:若真机上无头 WKWebView 跑 WebRTC DataChannel 不稳,退“全原生 + relay-only 重写 + 放弃 P2P”。该验证留到真机可测时优先做(R-iOS-3)。
|
||
|
||
**桥方向与桌面相反(关键,易踩坑)**:
|
||
|
||
- 桌面(Wails):React UI 在 WebView 顶层,调 DOWN 到 Go 拿原生能力。
|
||
- iOS(arch A):SwiftUI 原生在顶层,调 DOWN 到离屏 WebView 的 JS 引擎跑传输。
|
||
- 即 iOS 的 WebView 是“**无头 worker**”,不是“UI 容器”。需要一个**独立的无头引擎入口**(不加载 React、只装传输模块 + RPC 桥),而非复用 React app 再隐藏 UI。
|
||
|
||
### 决策 B(修订):分发 = 非 App Store,但仍守苹果开发标准
|
||
|
||
- **必须付费 Apple Developer Program(ADP,$99/年)**:cdrop 四项能力——APNs、App Groups、Keychain 共享、Associated Domains——**免费 Personal Team 一律不支持**(账号策略,非 OS 封锁),且免费证书 7 天过期。故“等账号”=等**付费 ADP**。
|
||
- **分发走 Ad Hoc**(最贴近桌面 Dropbox 旁加载的等价物):100 台 / 年上限、设备 UDID 须预先登记、profile 年度更新。自用(自己几台设备)足够;比桌面“下载即用”多一道 UDID 登记摩擦——iOS 无更省的合规途径。
|
||
- 备选 **TestFlight**:体验最好(无需 UDID、可达万级),但**需经 Apple Beta 审核**、build 90 天过期——与“不上架”初衷部分相悖,留作扩面再议。
|
||
- **Enterprise(ADEP $299/年)**:无设备上限,但仅限“内部员工”,对外分发违反协议、证书可被吊销——不取。
|
||
- **不过审 ≠ 不守标准**:仍遵守 HIG、液态玻璃规范、App Intents / WidgetKit 约定、隐私(权限说明 / just-in-time 请求)、entitlements 最小化。原 R-iOS-5(Guideline 4.2 过审风险)在 Ad Hoc 下作废;若走 TestFlight 则 Beta 审核仍在。
|
||
|
||
### 决策 C(新):剪贴板 = 控制中心两控件,零推送
|
||
|
||
- 通道用**持久的 `/api/clipboard`**(REST、LWW、版本探针、作用域令牌可访问),不用 ephemeral 的 `/api/message`。
|
||
- iOS 18 控制中心控件(`ControlWidget` + App Intent)提供“上 / 下”两件:**上** = 读 `UIPasteboard` → `PUT /api/clipboard`;**下** = `GET` → 写 `UIPasteboard`。用户主动点、**零推送**(频繁剪贴板变动推送干扰极大)。
|
||
- 平台天花板:程序化**读**剪贴板弹一次系统横幅(写不弹);后台无法监听复制。故“后台自动同步”降级为“一键发送 / 一键拉取”。
|
||
- 这条链**纯原生 URLSession,不依赖无头 WebView**——与文件传输引擎彻底解耦,互不拖累。
|
||
|
||
### 决策 D(新):视图层分叉,先吃强约束、残余进检查单
|
||
|
||
接受 web(React)与 iOS(SwiftUI)视图层是两套实现(液态玻璃决定不可能像素一致)。一致性按两层管,详见 §5:
|
||
|
||
- **强约束同步**(机制保证、漂移不可能):① 传输行为靠 arch A 共享引擎;② i18n 文案单一源(iOS 读 web 那份 JSON);③ 品牌资产 + 主色单一源;④ 后端 API 即共享契约。
|
||
- **检查单**(机制管不住、人判定的残余):元素 / 屏 / 功能存在性对等、非引擎驱动的新流程、后端字段变更提醒、允许分叉登记。
|
||
- **明确不做**:设计令牌 codegen 管线(过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲)——间距 / 圆角 / 字阶归“允许分叉”,仅品牌主色 + logo 作共享源。
|
||
|
||
### 决策 E(新):权限策略 = 持久权限首启显式询问 + 缺失则降级或阻塞提示
|
||
|
||
- **持久型权限(本地网络、相机)在首次启动的引导中显式询问**:先用一屏说明用途、再触发系统弹窗——既满足“首启显式获取”,又不踩 HIG 的“无上下文裸弹窗”反模式。这类权限**一次授予长期持久**(iOS 18 有重装后状态不重置的已知 bug,需 UI 兜底)。
|
||
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
|
||
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
|
||
|
||
### 决策 F(新,2026-06-28):iOS P2P 数据面下沉原生 libwebrtc(与桌面 pion 对称)
|
||
|
||
修订决策 A 的“数据面亦跑在无头 WebView JS 里”。**P2P DataChannel 的字节收发(数据面)已从无头 WebView 的 JS 引擎移到原生 libwebrtc**(`Sources/Engine/LibWebRtcEngine.swift`,`RTCPeerConnection`),与桌面把数据面移到 Go/pion **完全对称**。
|
||
|
||
- **无头 WebView 此后只跑编排**:信令转发 / 会话 / presence / 消息 / 状态机 / relay(`hub.ts` + `transfer.ts`),**不再承载 P2P 数据面**。决策 A 的“原生壳 + 无头引擎”仍成立,只是引擎的职责收窄到编排层。
|
||
- **缘由(U1→U2 翻案)**:先试 gomobile+pion(与桌面同引擎,U1),真机 POC **证伪**——Go raw BSD socket 不与 iOS Network framework 集成(连接时好时坏 + 中途断流)。按闸退到 **libwebrtc.framework**(U2,stasel/WebRTC M149 经 SPM、pin `exactVersion 149.0.0`)。完整决策依据与实测证据见 `desktop/NATIVE-TRANSFER.md` §7/§8。
|
||
- **收益**:libwebrtc 原生集成 iOS 网络栈 + DcSCTP 默认 ~5MB rwnd——摆脱 WebKit 写死的 256KB 接收窗(R-iOS-3 的吞吐残差)对数据面的钳制,连通性与吞吐一并根治。
|
||
- **互通靠线协议逐字节对齐**:`LibWebRtcEngine` 是 `desktop/engine` 的 `engine.go`/`session.go` 线协议的忠实 Swift 端口——DataChannel `cdrop-file`(ordered)、`meta`/二进制分片(64KB)/`done`/`ack` 帧、16MB/4MB 水位背压、ack 追平完成、不冲突落盘。故 iOS↔桌面(pion)、iOS↔浏览器(JS)三端互通。
|
||
- **桥与 JS 路由零改动**:`IOS_NATIVE` 开关、`p2pIos.ts`/`net/ios.ts` 桥协议原样;只在 Swift 侧新增一条原生传输 RPC(`startOutgoing`/`startIncoming`/`handleSignal`/`cancel`,completion-based,经 `resolveOnMain` 跳主线程 resolve)。**iOS 定向的引擎配置绝不回流 `desktop/engine` 共享包**。
|
||
- **验证**:环回端到端(两个引擎同进程交叉连、20MB 经完整线协议传输后逐字节比对)✅;模拟器全 app 编译 ✅;web typecheck/build ✅。真机 / 真网吞吐互通为剩余闸(须 deploy prod 让设备拿到 `IOS_NATIVE=true` 引擎 + 真机装机,二者 Touch ID 门控)。
|
||
|
||
---
|
||
|
||
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
|
||
|
||
| 能力 | 结论 | 对 iOS 的含义 |
|
||
|---|---|---|
|
||
| 文件传输 | 纯实时(P2P / 实时 relay,relay 是内存环 64MiB、2 分钟空闲释放),**零落盘暂存** | Share Extension 不靠服务端暂存;接收方须同时在线 |
|
||
| 剪贴板 `/api/clipboard` | 持久 LWW + REST,作用域令牌可访问 | 决策 C 的纯原生链路 |
|
||
| 消息 `/api/message` | 临时、不持久、不可轮询 | 文本同步走剪贴板通道,消息仅尽力而为实时 |
|
||
| 作用域令牌 | 仅 `clipboard` 一个 scope,`rejectScoped` 挡死 `/transfer` | 发送须完整会话;Share Extension 用交接而非自带令牌 |
|
||
| 推送 | 仅 Web Push(VAPID),无 APNs | APNs 须新建(§3) |
|
||
| 原生鉴权 | IdP bearer(RS256)/ 扫码自签会话;设备隐式登记(`X-Device-Name`) | 原生 app 可正常登录持会话 |
|
||
|
||
Share Extension 落法(据上):**抓文件 → 写 App Group 容器 → 深链唤起主程序**选设备发送(接收方须在线、大文件主程序稳跑、扩展不持完整令牌更安全)。
|
||
|
||
---
|
||
|
||
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
|
||
|
||
> **状态(2026-06-28,决策 F 后)**:本节描述的桥是**编排与控制面**(信令 / 会话 / presence / 消息 / 接收回退字节通道)。**P2P 数据面(含其磁盘 I/O)现走原生 libwebrtc**(决策 F),不再经此桥的 `sendFile` / `Begin`-`Append`-`Finalize` 路径;那条接收字节通道仅保留给 relay / 非原生回退。下文桥语义对编排层仍准确。
|
||
|
||
原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
|
||
|
||
- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
|
||
- **注入**:原生在加载 bundle 前经 WKUserScript 注入 `window.__CDROP_BOOT__ = { session, device_name, api_base, device_type:"ios" }`,store 在 import 时即水合(沿桌面同一注入范式);JS 永不持有长寿命密钥(refresh_token 只在原生 Keychain)。
|
||
- **原生 → 引擎命令**(原生 `evaluateJavaScript` 调 `window.__cdropEngineEvent(name, payload)`):`sendFile { target, url, name, size, type? }`、`cancelTransfer { sessionId }`、`switchToRelay { sessionId }`、`session { access_token, user }`(续期令牌)、`shutdown`。
|
||
- **引擎 → 原生**:① 单向通知(`postMessage({ notify, payload })`):`ready` / `transfers` / `transferDone` / `sendStarted` / `error`;② 请求 / 响应 RPC(`postMessage({ id, method, payload })`,原生回调 `window.__cdropEngineResolve(id, ok, value)`):接收落盘 `saveDownload` / `beginDownload` / `appendDownload` / `finalizeDownload` / `abortDownload`(data 为 base64,复用桌面同形)。剪贴板不走此桥(决策 C 纯原生)。
|
||
- **复用边界**:接收侧字节桥沿用桌面已验证的 base64 分批、有界内存(iOS 上限更小,64MB/16MB 避 jetsam);**发送侧经 `url` 取回文件为唯一新实现点**——当前整文件 fetch 进内存(小 / 中文件可行),大文件按 slice 向原生拉取是真机优化点(R-iOS-4)。
|
||
- **已落地(web 侧,账号无关、`tsc`/`vite build` 双过、对现网零影响)**:`net/ios.ts`(桥 + `isIOSShell` + 接收封装 + `notifyNative`)、`engine/main.ts`(无头入口)、`incomingSink.ts`(通用混合槽路由 iOS)、`engine.html` + `vite.config.ts` 多入口。
|
||
- **加载源(关键)**:无头引擎**必须从安全上下文加载**——`RTCPeerConnection` 在非安全 origin(如自定义 `cdrop://` scheme,WebKit 视作 non-secure context)下可能被禁。故引擎页走 `https://`:线上 `drop.commilitia.net` 或应用内本地 https 服务的静态 bundle;**自定义 scheme 仅用于 `WKURLSchemeHandler` 给 JS 流式喂文件字节**,不可用它直载引擎页。见 R-iOS-3。
|
||
|
||
---
|
||
|
||
## 3. 服务端 APNs 通道(账号无关的结构,`.p8` 到位再落代码)
|
||
|
||
新增 `internal/apns` 与 `internal/push` 并列,只换发送后端:
|
||
|
||
- **库**:APNs 走 HTTP/2 + JWT(`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2`,或自手写(与现有 go-jose 一致)。
|
||
- **配置**(koanf,`CDROP_APNS_*`,全可选,缺失即 iOS 推送惰性关闭):`APNS_KEY_PATH`、`APNS_KEY_ID`、`APNS_TEAM_ID`、`APNS_TOPIC`(bundle id)、`APNS_ENV`。
|
||
- **存储**:`push_subscriptions` 加 `platform` 列,iOS 行存 APNs token。`SubscriptionID` 仍用 token 哈希保幂等 upsert。
|
||
- **端点**:`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
|
||
- **触发**:在 `transfer:incoming` / `transfer:done|failed` / `message` 的“未投递”分支按 `platform` 分流。文案复用 `internal/push` 的 `localize`。**注意——剪贴板不触发推送**(决策 C 走控制中心拉取)。
|
||
|
||
代码结构账号无关,但真发 / 真测要 `.p8`,故与 iOS 客户端同期落(账号到位后),避免悬空实现。
|
||
|
||
---
|
||
|
||
## 4. 里程碑(修订)
|
||
|
||
| 里程碑 | 内容 | 账号依赖 |
|
||
|---|---|---|
|
||
| **I0** 账号与证书前置 | **付费 ADP($99/年,4 项能力全要)**;主 app + 各扩展 App ID(扩展 id 须主 app 前缀);开启 Push / App Groups / Keychain / Associated Domains;注册 App Group ID;APNs Auth Key `.p8`(**仅一次下载**);Distribution 证书 + Ad Hoc Profile(登记设备 UDID) | 硬等付费账号 |
|
||
| **I1** 原生骨架 + 无头引擎装配 | Xcode 工程(SwiftUI);离屏 WKWebView 载无头引擎入口;`isIOSShell` 注入桥 | 模拟器可跑 UI;引擎入口 web 侧账号无关 |
|
||
| **I2** 登录态 | `ASWebAuthenticationSession` PKCE / 扫码;refresh_token 存 Keychain;session 注入 JS | 模拟器可跑 |
|
||
| **I3** 传输(核心) | 无头引擎桥(send 自定义 scheme 流式 / receive 复用 `Begin`-`Append`-`Finalize`)+ 原生传输 UI(液态玻璃、含进度 / 速度 / 状态) | 真机硬等账号 |
|
||
| **I4** 剪贴板 | 控制中心两控件 + App Intents;纯原生 `/api/clipboard` 读写 + 版本探针 | 控件真机硬等账号;REST 链路可先写 |
|
||
| **I5** Share Extension | 抓文件 → App Group → 深链主程序选设备发送 | 真机硬等账号 |
|
||
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
|
||
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
|
||
|
||
> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`,App Group `group.net.commilitia.Commilitia-Drop`。
|
||
|
||
> **更新(2026-06-28)**:① **P2P 数据面改原生 libwebrtc**(决策 F;完整依据 `desktop/NATIVE-TRANSFER.md` §7/§8)——已真机装机、用户反馈“传输总体良好”,真网吞吐互通为剩余闸;② **UX 改造**(发送预览直入 / 消息按设备会话 / 删除粒度,§10);③ **品牌与字体系统**(logo + Orbit Gothic / Maple Mono / Fredoka,§11)。
|
||
|
||
---
|
||
|
||
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
|
||
|
||
**检查单是兜底,不是默认。** 先尽量用强约束(单一真源、两端机械消费、漂移不可能),管不住的残余才进检查单(见 `ios/PARITY.md`)。
|
||
|
||
### 强约束(机制保证)
|
||
|
||
1. **传输行为 → arch A 共享 JS 引擎。** 整套状态机 / 进度 / 完成 / 错误 / 协议无法漂移,零额外成本。
|
||
2. **i18n 文案 → 单一源。** iOS 原生侧薄 loader 读 web 那份 i18n JSON(随 bundle 或从服务端取),不写转换器、不做 `.xcstrings` 镜像。缺键即露。
|
||
3. **品牌资产 + 主色 → 单一源。** 资产沿用 Dropbox 那套(web+desktop 已接);品牌主色 + logo 作小共享常量。
|
||
4. **后端 API → 后端即共享契约。** 两端瘦客户端对齐同一 JSON 形状,不加 codegen(过度工程);字段变更的人工残余进检查单。
|
||
|
||
### 检查单(人判定的残余)
|
||
|
||
见 `ios/PARITY.md`:① 元素 / 屏 / 功能存在性对等;② 非引擎驱动的新流程 / 状态;③ 后端 API 字段变更提醒;④ 允许分叉登记。
|
||
|
||
### 明确不做
|
||
|
||
设计令牌 codegen 管线——过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲。间距 / 圆角 / 字阶归“允许分叉”。
|
||
|
||
---
|
||
|
||
## 6. 现在能做 vs 等账号(修订)
|
||
|
||
**账号无关、即刻可做**:
|
||
|
||
- 本计划 + `ios/PARITY.md` 同步检查单(✔ 本轮)。
|
||
- web 侧**无头引擎入口**(✔ 已实现,账号无关):`net/ios.ts`(桥 + `isIOSShell()` + 接收封装 + `notifyNative`)、`engine/main.ts`(不加载 React 的独立入口:水合 store → `startHub` → 暴露 RPC + 订阅推事件)、`incomingSink.ts`(通用混合槽路由 iOS,内存上限 64MB/16MB 避 jetsam)、`engine.html` + `vite.config.ts` 多入口。`tsc` / `vite build` 双过、对现网 web / 桌面零影响。
|
||
- 桥协议契约冻结(§2,✔ 已与实现对齐)。
|
||
- i18n 单一源:现有 catalog 已是扁平 `{ key: string }` 结构(`src/i18n/locales/*.ts`),数据形态即可导出 JSON;原生侧薄 loader + 构建期 emit 待原生工程就位时同期落(避免向无消费方空导出)。
|
||
- 剪贴板 REST 链路(`/api/clipboard` 读写 + 版本探针)的数据流设计(控件实现等账号,协议可先定)。
|
||
- **iOS 原生 scaffold**(✔ 已实现,账号无关):`ios/CDrop/`(xcodegen `project.yml`)——液态玻璃壳(TabView + `.tabBarMinimizeBehavior` + `.buttonStyle(.glassProminent)`)+ 离屏引擎 `WKWebView` 宿主 + 桥 `EngineController`(对侧契约同 `net/ios.ts`)+ `DownloadManager`(落沙盒)。**已编过 `iphonesimulator26.5`(iOS 26 SDK,含液态玻璃)**——证 arch A Swift 侧成立。**启动 / 截图待 iOS 26 模拟器运行时(约 7GB,账号无关)下载**(`xcodebuild -downloadPlatform iOS`)。
|
||
- **明确不做**(避免悬空 / 过度工程):设计令牌 codegen;APNs 发送代码(`.p8` 到位再落)。(注:原“Xcode 工程等账号”已作废——**模拟器构建不需签名 / Provisioning**,工程已建且 compile-verified;只有真机签名 / APNs 真发 / 分发才等付费 ADP。)
|
||
|
||
**硬等付费 ADP 账号**:真机签名 / 跑、APNs 真发、Share Extension、控制中心控件真机、旁加载分发。
|
||
|
||
---
|
||
|
||
## 7. 风险与未决(修订)
|
||
|
||
- **R-iOS-1 后台挂起 / JS 全停**:app 退后台即挂起、WKWebView JS(含 WebRTC 事件循环)停摆,DataChannel 无媒体轨后台不保活。文件传输本需前台(双方在线、用户看进度),可接受;用户主动触发的传输用 iOS 26 `BGContinuedProcessingTask` 续跑(带系统进度 UI)。剪贴板走控制中心 App Intent,不依赖常驻 WebView。
|
||
- **R-iOS-2 剪贴板后台限制**:读弹横幅、后台不可监听 → 控制中心手动两控件已据此设计;app 内读用 `UIPasteControl`、探测用 `hasStrings`(均不弹横幅)。
|
||
- **R-iOS-3 无头 WebRTC 真机稳定性 + 安全上下文**:arch A 最大未验证点——① `RTCPeerConnection` 须在安全 origin;② `BGContinuedProcessingTask` 能否让 WebView JS 不被挂起待验。**2026-06-24 模拟器已部分证实**:iOS 26 WKWebView 从 `http://127.0.0.1`(`isSecureContext=true`,loopback = 潜在可信源)跑 `RTCPeerConnection` + DataChannel + `createOffer` = **RTC:ok**、STUN srflx 候选正常、原生↔JS 桥往返正常 → **arch A 核心成立、不转 B**。注:安全源不止 https,loopback 亦可(可选应用内本地 https/loopback 服务内嵌 bundle,离线可用)。**余真机验**:host / 同内网直连候选 + 本地网络权限(R-iOS-6)、后台挂起续传(BGContinuedProcessingTask)。
|
||
- **R-iOS-4 字节桥**:接收侧复用桌面 `Begin`/`Append`/`Finalize`(base64 分批、有界)已验证;发送侧自定义 scheme(`WKURLSchemeHandler`)流式喂 JS 为新实现点。
|
||
- **R-iOS-5**(原 App Store 过审)在 Ad Hoc 旁加载下作废;若改走 TestFlight 则 Beta 审核仍在。
|
||
- **R-iOS-6 本地网络权限(同内网 P2P 硬墙)**:WebRTC host 候选 / mDNS 触发 `NSLocalNetworkUsageDescription`(须配 `NSBonjourServices`),拒绝即只剩中继候选 → 回退 relay(cdrop 已有兜底);mDNS 失败不可区分“无服务”,UI 须降级。**不可拿它当核心功能门槛**。
|
||
- **R-iOS-7 付费账号 + 分发摩擦**:4 项能力 + 分发全需付费 ADP;Ad Hoc 100 台 + UDID 预登记是旁加载硬约束——比桌面 Dropbox“下载即用”多一道摩擦。
|
||
|
||
---
|
||
|
||
## 8. iOS 设计规范要点(HIG · 液态玻璃)
|
||
|
||
抓取自 Apple HIG / WWDC25(2026-06-24),只取对 cdrop 界面有约束力的重点。
|
||
|
||
### 8.1 液态玻璃(材质用法)
|
||
|
||
- **两层模型**:玻璃只用于**功能 / 导航层**(Tab bar / Nav bar / Toolbar / 浮动控件 / sheet 边框);**内容层禁用**——列表、设备卡、**传输卡属内容层,不加玻璃**。
|
||
- 用 **iOS 26 SDK 编译,Tab bar / Nav bar / Toolbar / sheet 自动玻璃化**;同时**移除所有自定义 bar 背景 / 装饰色**(否则破坏系统玻璃)。
|
||
- 自定义悬浮控件(如传输卡上的取消 / 暂停 pill)才手动 `.glassEffect(.regular.interactive())`,多块玻璃须放进 `GlassEffectContainer` 共享采样;主操作用 `.buttonStyle(.glassProminent)`。
|
||
- **禁忌**:玻璃叠玻璃、滚动内容区铺玻璃、大面积内容玻璃背景、非主操作滥用 tint。
|
||
- Tab bar 滚动收起 `.tabBarMinimizeBehavior(.onScrollDown)`;全局传输状态可置 `.tabViewBottomAccessory`(类 Apple Music 迷你条)。圆角走同心 `.rect(cornerRadius: .containerConcentric)`,sheet / popover 系统自动同心。
|
||
|
||
### 8.2 基础(色彩 / 字体 / 图标 / 布局)
|
||
|
||
- **色彩**:用**语义色**(`label` / `secondaryLabel`、`systemBackground` 层级、`systemFill`、`separator`),**不硬编码 hex**——暗色 / 对比度 / vibrancy 免费。**品牌主色以 Asset Catalog 的 Color Set(含 Dark 变体)接入**(`Color("BrandPrimary")`)——这正是强约束③(§5)在 iOS 侧的落地形态。
|
||
- **字体 / Dynamic Type**:系统 text styles(Body 17 / Headline / Title…)+ **必须支持 Dynamic Type**(正文 / 标题 / 主按钮可缩放)。与“字体归允许分叉”不矛盾——分叉的是**字体选择**,Dynamic Type 支持是**硬要求**。
|
||
- **SF Symbols**:优先系统符号(自动与文字对齐 / 字重匹配),四种渲染模式(mono / hierarchical / palette / multicolor);品牌专属才自定义 symbol(须注解四模式以获 Dynamic Type / 无障碍)。
|
||
- **布局**:内容 / 控件留安全区,**最小命中区 44×44pt**,对比度 **4.5:1**(正文)/ **3:1**(大字 / 非文字),不硬编码宽度。
|
||
|
||
### 8.3 控件 / App Intents(剪贴板两控件落地)
|
||
|
||
- 剪贴板“上 / 下”用 **`ControlWidgetButton`**(一次性动作);**SF Symbol 必备**(锁屏 / 操作按钮只显示图标,须独立达意)。跨设备状态靠**推送 reload**(`ControlCenter.reloadControls`)、**不轮询**;控件内**不发网络请求**,经 App Group 容器读主 app 写入。
|
||
- **App Intent**:动词+宾语命名、`title` 编译期常量本地化串、**默认后台执行**(返回 dialog / snippet、不开 app);iOS 26 **Interactive Snippets** 可在不开 app 下做“选设备”二级操作。
|
||
|
||
### 8.4 分享(Share Extension)
|
||
|
||
- 扩展只做轻:内容预览 + 选设备 + 验证 → 写 **App Group 容器** + 交接主 app;**不在扩展内跑传输引擎**(内存 ~120MB,见 §9.1)。宽度系统固定不可改;完成即“已提交”退出,不等传输完成。
|
||
|
||
### 8.5 通知(APNs)
|
||
|
||
- 文案具体(“文件 X 已接收(3.2MB)”);中断级别用 **`.active`**(传输完成 / 失败),**不滥用 `.timeSensitive`**;`threadIdentifier` 按“完成 / 失败 / 消息”分组;**首个有价值操作后**再 just-in-time 请求权限,按类别细分开关。**剪贴板不推送**(决策 C)。
|
||
|
||
### 8.6 无障碍(玻璃语境)
|
||
|
||
- Reduce Transparency / Increase Contrast / Reduce Motion:系统对 Tab / Nav 自动回落;**自定义玻璃控件须自行提供不透明回落 + 关动效**。传输进度动画检测 `accessibilityReduceMotion` 降级为渐隐 / 变色。VoiceOver:图标按钮 `accessibilityLabel` 用动词(“发送文件”而非“箭头向上”)。
|
||
|
||
### 8.7 App 图标(iOS 26)
|
||
|
||
- 用 **Icon Composer** 做分层图标(logo 前景 + 品牌色渐变背景),系统材质自动出高光 / 折射 / 阴影——源稿**不要**烘焙圆角 / 阴影 / 斜面。须验明 / 暗 / clear / tinted 各变体(暗背景下 logo 填充色可辨)。接品牌资产源(强约束③)。
|
||
|
||
---
|
||
|
||
## 9. 开发禁区 / 平台约束(硬性 vs 审核 / 账号)
|
||
|
||
分级:**【OS 硬】**=系统强制、绕不过;**【账号】**=付费 / 账号策略;**【审核】**=仅 App Store / TestFlight 审核约束(Ad Hoc 旁加载不触发)。
|
||
|
||
### 9.1 运行 / 生命周期
|
||
|
||
- **【OS 硬】后台一律挂起、JS 全停**:app 退后台即被挂起,WKWebView 的 JS(含 WebRTC 事件循环)停摆,DataChannel(无媒体轨)后台不保活 → 传输**必须前台发起**;用户主动触发的传输用 iOS 26 **`BGContinuedProcessingTask`** 续跑(带系统进度 UI,能否让 WebView JS 不被挂起须真机验,R-iOS-3)。
|
||
- **【OS 硬】无后台常驻监听**:剪贴板 / 消息**不能**后台轮询监听 → 决策 C 的控制中心手动触发正据此。
|
||
- **【OS 硬】自定义 scheme = 非安全上下文 + OPFS 单文件 ~10MB**:故引擎从 https 安全源加载(§2)、大文件**不走 OPFS**、经原生桥 `Begin`/`Append`/`Finalize` 落沙盒(接收侧已是此设计)。
|
||
- **【OS 硬】Share Extension ~120MB jetsam**:扩展内**装不下** WKWebView + WebRTC 引擎 → 只做交接(§8.4)。
|
||
|
||
### 9.2 权限 / 隐私 / 网络(落实决策 E 的逐权限映射)
|
||
|
||
- **【OS 硬】本地网络权限(持久)**:同内网 P2P 直连(WebRTC host 候选 / mDNS)触发 `NSLocalNetworkUsageDescription`,**须配 `NSBonjourServices`**;一次授予长期持久 → **首启引导显式询问**(决策 E)。缺失静默失败、拒绝则只剩中继候选 → **降级回退 relay 并提示“仅中继”**(cdrop 已有兜底;mDNS 失败不可区分“无服务”,UI 须降级)。
|
||
- **【OS 硬】相机权限(持久,扫码登录)**:`NSCameraUsageDescription` 缺失即崩;首启 / 首次扫码时显式询问。**无兜底 → 拒绝则提示“无权限”并阻塞“扫码”特性**(跳系统设置);登录可走 OIDC 等其他途径,阻塞的是扫码、非整个 app。
|
||
- **【OS 硬】Info.plist 用途说明缺失即崩**:相机、相册键(若选图)必声明;剪贴板**无** iOS plist key(横幅系统自动、关不掉)。
|
||
- **【OS 硬】ATS 强制 HTTPS / TLS1.2+**:cdrop 已全 https,无需例外。
|
||
- **【OS 硬】剪贴板**:后台不可读、读内容弹横幅(关不掉)、写不弹、`hasStrings` 探测不弹、`UIPasteControl` 读不弹 → “上”控件接受一次横幅、app 内读用 `UIPasteControl`、“下”控件写无感。
|
||
- **【审核】隐私清单 `PrivacyInfo.xcprivacy` + Required Reason API**(UserDefaults `CA92.1` 等):上架 / TestFlight 才强制;Ad Hoc 非硬性,但第三方 WebRTC SDK 自带清单仍建议补。
|
||
|
||
### 9.3 能力 / 扩展 / 签名 / 分发
|
||
|
||
- **【账号】4 项能力全需付费 ADP**:APNs / App Groups / Keychain 共享 / Associated Domains——免费 Personal Team 一律不支持(决策 B / I0)。
|
||
- **【OS 硬】扩展归属**:Share Extension / ControlWidget 的 bundle id 须**主 app 前缀**、同 Team 签名;与主 app 共享数据**唯一合法途径 = App Group 容器**。
|
||
- **【OS 硬 + 账号】Ad Hoc 100 台 / 年 + UDID 预登记**:旁加载的硬上限与摩擦(决策 B)。
|
||
- **【OS 硬】APNs 环境隔离**:dev profile=sandbox、ad-hoc / 分发=production,token 不互通。
|
||
|
||
---
|
||
|
||
## 10. UX 改造(2026-06-28,真机反馈驱动)
|
||
|
||
真机使用后落地的三块结构性改造,均属决策 D 的“非引擎驱动 UI 流程”(进 `ios/PARITY.md` 残余项 1/2,已登记)。
|
||
|
||
### 10.1 发送预览界面(取代即弹的设备选择框)
|
||
|
||
- 选文件 / 选图后**不再**直接弹设备选择,而是进入**发送预览**(`Sources/SendComposeSheet.swift`):列出待发文件名 + 字节大小,下方列表选目标设备。手动发送与分享发送**共用**此界面。
|
||
- 手动入口支持**多选**:`fileImporter(allowsMultipleSelection: true)` + `photosPicker(selection:)` 数组;`RootView` 的 `present`/`sendPicked`/`clearPicked`/`loadPhotos` 串起选取→预览→发送。
|
||
|
||
### 10.2 分享直入预览(修“必须重启 app”)
|
||
|
||
- Share Extension 抓取 → 暂存 App Group → 唤起主程序,主程序**直入发送预览**(同 `SendComposeSheet`)。
|
||
- 修复“分享后必须重启 app 才出现传输”:唤起宿主 app 经响应链 IMP 调现代 `openURL:options:completionHandler:`(旧单参 `openURL:` 在 iOS 18+ 已成空壳),**并以 `scenePhase` 回 `.active` 时 `loadPendingShares()` 兜底**——回前台即扫 App Group 暂存、补出预览,不依赖那条 iOS 灰区的扩展→宿主唤起是否可靠。分享引导界面见 §11.3。
|
||
|
||
### 10.3 消息按设备会话聚合 + 删除粒度
|
||
|
||
- 消息从扁平流改为**按设备聚合的会话列表**(社交 app 模型,`Sources/MessagesView.swift`):会话列表项 = 有历史**或**当前可达的设备;点进 `ConversationView` 看与该设备的线程。非 APNs 离线设备**不能**作为发送对象,但历史**可查看**(`canMessage()` 判定)。
|
||
- **每设备未读分桶**:`EngineController` 的 `unreadByPeer: [String: Int]` + `activeConversation`;每设备会话显示各自未读红点,“消息”标签显示总未读(`unreadMessages = unreadByPeer.values.reduce(0,+)`)。进会话即 `markConversationRead` + `setActiveConversation`。
|
||
- **任意删除**:可删**单条消息**与**整个设备的历史**(`deleteConversation()`),避免废 session 堆积。
|
||
|
||
---
|
||
|
||
## 11. 品牌与字体系统(2026-06-28,对齐 web)
|
||
|
||
iOS 界面引入与 web 一致的品牌资产与字体栈——这是强约束③(§5,品牌资产 + 主色单一源)在视觉层的延伸。
|
||
|
||
### 11.1 品牌 logo
|
||
|
||
- `Shared/Brand.swift`:`BrandMark`(吉祥物,资产 `Shared/Brand.xcassets/LogoMark`,源 `web/public/logo-mark.png`)、`Wordmark`(“Commilitia” 主色 + “Drop” `cdropAmber`,Fredoka SemiBold)、`BrandLockup`、`BrandHero(tagline:)`。
|
||
- 接入点:登录页(`BrandMark` + `Wordmark` 取代纯文字标题)、传输空态(`BrandHero`)、设置页顶部横向品牌头、控制中心控件重设计(吉祥物 + 品牌渐变 + 全宽按钮,最大化空间)。`Theme.swift` 加 `Color.cdropAmber`(#DB9410 / #F8C637)。
|
||
|
||
### 11.2 字体栈(Orbit Gothic + Maple Mono + Fredoka)
|
||
|
||
- 与 web `fonts.css` 同源:Sans = **Orbit Gothic CJK SC**(Regular + SemiBold)、Mono = **Maple Mono**、字标 = **Fredoka SemiBold**。
|
||
- **woff2 运行期注册**:Orbit / Maple 以 woff2 内嵌(约 TTF 的 1/3 体积),`UIAppFonts` 历史只认 ttf/otf,故经 `CTFontManagerRegisterFontsForURL` 运行期注册(iOS 13+ Core Text 原生支持 woff2,实测 register→true)——见 `Sources/BrandFonts.swift` 的 `BrandFonts.register()`(`AppDelegate` 启动调)。Fredoka 是 ttf,走 `UIAppFonts`。
|
||
- **助手 + sweep**:`BrandFonts` 暴露 `Font.oBody/.oHeadline/…`(Orbit,对位 SF 语义样式)、`Font.maple(…)`(Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit;7 个视图的 `.font(.语义)` → `.font(.o语义)` sweep。
|
||
- **仅主 app**:CJK 字体大,不进 Share / 控件扩展(它们保持系统字 + 仅 Fredoka 字标);生僻字超 Orbit 字汇时 iOS 字体级联自动回退苹方,不出豆腐块。
|
||
- **字体不入库**:Orbit Gothic 为专有 / 定制字体(公开仓库不分发),Maple / Fredoka 虽开源也统一不入库保持精简;经 `.gitignore` 排除,构建前 `just ios-fonts`(已 chain 进 `ios-device` / `ios-sim-build`)从私有 CDN / Google Fonts 幂等拉取。详见 `Sources/Fonts/README.md`。
|
||
|
||
### 11.3 分享引导界面
|
||
|
||
- `Share/ShareGuideView.swift`(`ShareModel: ObservableObject`):扩展内呈现吉祥物 + Wordmark + “已就绪 N 个文件” + “打开 Commilitia Drop” 主按钮 + 取消,引导用户回到主程序完成发送(扩展内不跑传输引擎,§8.4)。取消则删暂存;`started` 标志防重入。
|
||
|
||
---
|
||
|
||
## 12. 全平台平均速度(2026-06-28)
|
||
|
||
传输完成后,速度列**不再显示最后一次瞬时速率,而显示平均速度**(总字节 / 总耗时)。三端一致:web `store/slices/transfer.ts` 的 `completeTransfer` 在 DONE 时置 `bytesPerSec = total / (elapsedMs/1000)`、`TransferRow.tsx` 在 DONE 读 `avgRate`;iOS `RootView` 卡片 / 详情完成态显平均速度;i18n 加 `transfer.avgRate`(web 3 locale + ios 3 JSON)。桌面(Wails)共享 web store,逻辑自动生效,但需 `just desktop-build-{mac,win}` 重建 + 重分发方落到已装客户端。
|
||
|
||
---
|
||
|
||
## 附:与桌面端策略对照(修订)
|
||
|
||
| 维度 | 桌面(Wails) | iOS(arch A) |
|
||
|---|---|---|
|
||
| UI | 复用 `web/src` | 原生 SwiftUI(分叉,PARITY 管) |
|
||
| 引擎 | Go 原生 | 复用 `web/src` JS(无头) |
|
||
| 桥方向 | JS-UI 顶层 → Go | SwiftUI 顶层 → 无头 JS |
|
||
| refresh_token | Go keyring | iOS Keychain |
|
||
| 登录 | loopback PKCE | `ASWebAuthenticationSession` / 扫码 |
|
||
| 后台通知 | Go 常驻进程 + 原生 API | 服务端 APNs |
|
||
| 剪贴板 | Go Monitor 实时双向 | 控制中心手动两控件(纯原生 REST) |
|
||
| 平台判定 | `isDesktop()`(`window.runtime`) | `isIOSShell()`(`window.webkit.messageHandlers`) |
|
||
| 分发 | Dropbox 旁加载 | 旁加载(非 App Store) |
|