iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource - 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI - APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式 - Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎) - 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换) - 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册 - I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键 - ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
This commit is contained in:
@@ -6,3 +6,6 @@ build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
|
||||
# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。
|
||||
Local.xcconfig
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- APNs 推送:注册远程通知需此 entitlement。模拟器(iOS 16+ / Apple Silicon)下 development
|
||||
即可取令牌、走 sandbox;真机须付费 ADP 开 Push 能力配 profile(账号门控)。 -->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<!-- App Group:与 Share Extension 共享待发文件收件箱(见 AppGroup.swift / PLAN §I5)。
|
||||
模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.commilitia.cdrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+79
-31
@@ -1,40 +1,88 @@
|
||||
# cdrop iOS 真机自测手册
|
||||
# cdrop iOS 真机分发手册
|
||||
|
||||
账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单。
|
||||
把 cdrop 装到你自己的 iPhone(开发签名,1 年 profile)。代码侧全部就绪并经模拟器验证;真机只差**账号 + 门户 + 签名**这一层。本手册走**手动签名 + 全 CLI**路线——门户你手动建(一次),构建装机一条命令,**全程不碰 Xcode GUI**。
|
||||
|
||||
## 包名与标识(门户里要用的)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 主 app Bundle ID | `net.commilitia.Commilitia-Drop` |
|
||||
| Share Extension | `net.commilitia.Commilitia-Drop.share` |
|
||||
| 控件扩展 | `net.commilitia.Commilitia-Drop.widgets` |
|
||||
| App Group | `group.net.commilitia.cdrop`(与包名相互独立,刻意不同名) |
|
||||
| APNs topic(服务端 `CDROP_APNS_TOPIC`) | `net.commilitia.Commilitia-Drop` |
|
||||
|
||||
## 前置
|
||||
- 付费 Apple Developer Program($99/年)。
|
||||
- 一台 iPhone(iOS 26),Mac 装 Xcode 26。
|
||||
- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。
|
||||
- 一台 iPhone(iOS 26)+ Mac(Xcode 26 命令行工具)。
|
||||
- 一个已登录的 cdrop(网页 / 桌面)——用来扫码批准本机登录。
|
||||
|
||||
## 出工程 + 签名
|
||||
1. `cd ios/CDrop && xcodegen generate`(从 `project.yml` 生成 `CDrop.xcodeproj`)。
|
||||
2. Xcode 打开 `CDrop.xcodeproj` → 选 `CDrop` target → Signing & Capabilities:
|
||||
- **Team** 选你的 ADP 团队;**Bundle Identifier** 用你账号下可注册的(默认 `net.commilitia.cdrop`,前缀按你的改)。
|
||||
- 让 Xcode 自动管理签名(Automatically manage signing)。
|
||||
3. 连 iPhone、信任、选作运行目标 → Run。
|
||||
- `project.yml` 里 `CODE_SIGNING_ALLOWED: NO` 是给模拟器的;Xcode 真机自动签名会覆盖它,无需手改。
|
||||
---
|
||||
|
||||
## 引擎可达性(让传输真能连)
|
||||
- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。
|
||||
- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。
|
||||
## A. 门户操作(developer.apple.com/account,一次性)
|
||||
|
||||
## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID)
|
||||
- **App Groups**(`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。
|
||||
- **Push Notifications** + APNs `.p8`:传输事件推送。
|
||||
- **Keychain Sharing**:跨 app / 扩展共享登录态。
|
||||
- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。
|
||||
> 入口:登录后左侧 **Certificates, Identifiers & Profiles**(证书 / 标识符 / 描述文件总枢纽)——A1–A5 都在这里面;A6 在 **Keys**;Team ID 在 **Membership details**(10 位,记下)。
|
||||
|
||||
## 只能真机验的清单
|
||||
- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。
|
||||
- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。
|
||||
- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。
|
||||
- [ ] 发送:选文件 → 选设备 → 对端收到。
|
||||
- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。
|
||||
- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。
|
||||
- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4)。
|
||||
1. **证书**(Apple Development):本机「钥匙串访问」可能已有(「我的证书」分类里看)。没有就:钥匙串访问菜单 → 证书助理 → 从证书颁发机构请求证书 → 存到磁盘 → 门户 **Certificates** → + → Apple Development → 传 CSR → 下载 `.cer` 双击安装。
|
||||
2. **App IDs ×3**(**Identifiers** → + → App IDs → App,Bundle ID 选 **Explicit**):
|
||||
- `net.commilitia.Commilitia-Drop` — 勾 **App Groups** + **Push Notifications**
|
||||
- `net.commilitia.Commilitia-Drop.share` — 勾 **App Groups**
|
||||
- `net.commilitia.Commilitia-Drop.widgets` — 勾 **App Groups**
|
||||
3. **App Group**:**Identifiers** 页类型筛选切到 **App Groups** → + → `group.net.commilitia.cdrop`。再回上面 3 个 App ID 各自的 App Groups 能力里 **Edit/Configure** 关联它(三个都要)。
|
||||
4. **设备**:**Devices** → + → 填 UDID(插上手机后 `just ios-devices` 读)。
|
||||
5. **描述文件 ×3**(**Profiles** → + → **iOS App Development**,非 Distribution):每个选对应 App ID + 证书 + 设备,命名清楚(建议 `CDrop Dev` / `CDrop Share Dev` / `CDrop Widgets Dev`)→ 下载 → 双击安装。
|
||||
6. **APNs Auth Key**(**Keys** → + → 勾 Apple Push Notifications service):下载 `AuthKey_XXXXX.p8`(仅一次),记 **Key ID** + **Issuer ID**(服务端发推送用,见 D)。这把 `.p8` 与 A1 的证书是两码事。
|
||||
|
||||
## 已知 TODO(真机阶段一并收)
|
||||
- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。
|
||||
- 发送侧大文件分块流式(R-iOS-4)。
|
||||
- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。
|
||||
> 常见坑:Bundle ID 选 **Explicit** 不是 Wildcard;App Group 务必 3 个 App ID 都关联;Profile 选 **Development** 不要 Distribution。
|
||||
|
||||
---
|
||||
|
||||
## B. 本地签名配置(gitignore,填一次)
|
||||
|
||||
在 `ios/CDrop/Local.xcconfig` 写入(此文件已 gitignore,公开仓库不留你的账号标识):
|
||||
|
||||
```
|
||||
CDROP_TEAM_ID = ABCDE12345
|
||||
CDROP_PROFILE_APP = CDrop Dev
|
||||
CDROP_PROFILE_SHARE = CDrop Share Dev
|
||||
CDROP_PROFILE_WIDGETS = CDrop Widgets Dev
|
||||
```
|
||||
|
||||
- `CDROP_TEAM_ID`=A0 的 Team ID;三个 `CDROP_PROFILE_*`=A5 你起的 profile 名(**名字**,不是文件路径)。
|
||||
- 机制:`Signing.xcconfig`(已提交)仅对 **device SDK(iphoneos)** 套用手动签名 + 这些值;**模拟器**仍走 `project.yml` 的 ad-hoc(`CODE_SIGN_IDENTITY = "-"`),故缺 `Local.xcconfig` 也不影响模拟器构建。
|
||||
|
||||
---
|
||||
|
||||
## C. 构建装机(全 CLI)
|
||||
|
||||
```sh
|
||||
just ios-devices # 手机插 USB,读 UDID(填进门户 A4 + 下行)
|
||||
just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl 装机
|
||||
```
|
||||
|
||||
- `just ios-device` = `xcodegen generate` → `xcodebuild`(device,手动签名走 `Local.xcconfig`)→ `xcrun devicectl device install app`。无 Xcode GUI。
|
||||
- 首次装机后,iPhone 上首启该开发者 app 即可直接跑(付费 ADP 开发证书,无需手动「信任开发者」)。
|
||||
|
||||
---
|
||||
|
||||
## D. 服务端前置
|
||||
|
||||
1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——**该文件随 cdrop 二进制部署到 prod**(`//go:embed` 进 binary,`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。
|
||||
2. **APNs 真发**:把 A6 的 `.p8` 放到服务器,给后端容器配 `CDROP_APNS_KEY_PATH` / `CDROP_APNS_KEY_ID` / `CDROP_APNS_TEAM_ID` / `CDROP_APNS_TOPIC=net.commilitia.Commilitia-Drop` / `CDROP_APNS_ENV`(Xcode 开发构建的 device token 属 **sandbox**,故联调填 `sandbox`)。缺配置则推送惰性关闭,其余功能照常。
|
||||
3. 本机联调可选:环境变量 `CDROP_ENGINE_URL` 指向可达引擎。
|
||||
|
||||
---
|
||||
|
||||
## E. 只能真机验的清单
|
||||
|
||||
代码已实现,下列是真机才能验的点:
|
||||
|
||||
- [ ] 液态玻璃真机渲染(陀螺仪高光 / 动效)。
|
||||
- [ ] 扫码登录:本机显码 → 已登录 cdrop 扫码批准 → 进主界面(强制 full/persist)。
|
||||
- [ ] **本地网络权限 + 同内网直连**(R-iOS-6):首次同内网传输弹 `NSLocalNetworkUsageDescription`;授予后 ICE 收 host 候选、走真直连;拒绝则回退中继仍可传。
|
||||
- [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。
|
||||
- [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。
|
||||
- [ ] **后台续传**(R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。
|
||||
- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 D2 的 `.p8`)。
|
||||
- [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。
|
||||
- [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 与主 app 共享的 App Group:Share Extension 把待发文件搬进收件箱(见 AppGroup.swift)。 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.commilitia.cdrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Commilitia Drop</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>20</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>20</integer>
|
||||
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||
<integer>20</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,77 @@
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再深链唤起主 app
|
||||
// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。无 compose UI——即起即办、
|
||||
// 处理完即返回。
|
||||
final class ShareViewController: UIViewController
|
||||
{
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
Task { await processAndClose() }
|
||||
}
|
||||
|
||||
private func processAndClose() async
|
||||
{
|
||||
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
|
||||
var staged = 0
|
||||
for item in items
|
||||
{
|
||||
for provider in item.attachments ?? []
|
||||
{
|
||||
if await stage(provider) { staged += 1 }
|
||||
}
|
||||
}
|
||||
let ctx = extensionContext
|
||||
guard staged > 0, let url = URL(string: "cdrop://share") else
|
||||
{
|
||||
ctx?.completeRequest(returningItems: nil)
|
||||
return
|
||||
}
|
||||
// 先唤起主 app,等 open 回调拿到结果再 completeRequest——否则立即 completeRequest 会拆掉
|
||||
// 扩展、self 被释放,open 失败时的 openViaResponder 兜底永不触发(审查 L8)。捕获 ctx 使
|
||||
// 扩展存活到回调。
|
||||
ctx?.open(url)
|
||||
{ [weak self] ok in
|
||||
if !ok { self?.openViaResponder(url) }
|
||||
ctx?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 把单个附件落到 App Group 收件箱。用 loadFileRepresentation 取系统给的临时文件表示
|
||||
// (文件 / 图片 / 文档统一走此路,且大文件不整体进内存),在回调内同步拷到收件箱
|
||||
// (系统会在回调返回后删临时文件)。
|
||||
private func stage(_ provider: NSItemProvider) async -> Bool
|
||||
{
|
||||
return await withCheckedContinuation
|
||||
{ cont in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
|
||||
{ url, _ in
|
||||
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
|
||||
else { cont.resume(returning: false); return }
|
||||
do
|
||||
{
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
cont.resume(returning: true)
|
||||
}
|
||||
catch
|
||||
{
|
||||
cont.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// open 失败时的兜底:沿响应链找 UIApplication 唤起主 app(个别系统版本扩展直接 open 不生效)。
|
||||
private func openViaResponder(_ url: URL)
|
||||
{
|
||||
var responder: UIResponder? = self
|
||||
let sel = NSSelectorFromString("openURL:")
|
||||
while let r = responder
|
||||
{
|
||||
if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
|
||||
responder = r.next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
// App Group:主 app 与 Share Extension 共享容器,用于交接待发文件——扩展只把文件搬进收件箱、
|
||||
// 不跑传输引擎(避 120MB jetsam,见 PLAN §I5 / 决策 D)。两 target 共用此文件(xcodegen 多
|
||||
// target 引用)。
|
||||
//
|
||||
// 收件箱布局:每个待发文件放进独立的 <uuid> 子目录,文件本身保留原名(发送时直接用作传输
|
||||
// 文件名);发送 / 取消后删掉该子目录即清理。
|
||||
enum AppGroup
|
||||
{
|
||||
static let identifier = "group.net.commilitia.cdrop"
|
||||
|
||||
// 共享收件箱根目录。容器不存在(App Group 未生效)时返回 nil,调用方降级为静默不交接。
|
||||
static func inboxURL() -> URL?
|
||||
{
|
||||
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||
else { return nil }
|
||||
let inbox = base.appendingPathComponent("SharedInbox", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
|
||||
return inbox
|
||||
}
|
||||
|
||||
// 为一个原名分配新的暂存目标 URL(<inbox>/<uuid>/<originalName>),并建好父目录。
|
||||
static func stagedDestination(originalName: String) -> URL?
|
||||
{
|
||||
guard let inbox = inboxURL() else { return nil }
|
||||
let dir = inbox.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let safeName = originalName.isEmpty ? "file" : originalName
|
||||
return dir.appendingPathComponent(safeName)
|
||||
}
|
||||
|
||||
// 收件箱内现存的待发文件(主 app 处理分享时扫描,发送后逐个 remove)。
|
||||
static func inboxFiles() -> [URL]
|
||||
{
|
||||
guard let inbox = inboxURL() else { return [] }
|
||||
let dirs = (try? FileManager.default.contentsOfDirectory(at: inbox, includingPropertiesForKeys: nil)) ?? []
|
||||
return dirs.compactMap
|
||||
{ dir in
|
||||
guard dir.hasDirectoryPath else { return nil }
|
||||
let inner = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
return inner.first { !$0.hasDirectoryPath }
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 / 取消后清理:删掉该文件所在的 <uuid> 子目录。
|
||||
static func removeStaged(_ fileURL: URL)
|
||||
{
|
||||
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
// 清理陈旧暂存:发送是异步读取暂存文件的,故发送后不立即删;改由此惰性回收超期子目录
|
||||
// (默认 6 小时——远超任何传输时长),避免收件箱无限累积。主 app 每次导入分享时调用。
|
||||
static func pruneStale(olderThan seconds: TimeInterval = 6 * 60 * 60)
|
||||
{
|
||||
guard let inbox = inboxURL() else { return }
|
||||
let dirs = (try? FileManager.default.contentsOfDirectory(
|
||||
at: inbox, includingPropertiesForKeys: [ .creationDateKey ])) ?? []
|
||||
let cutoff = Date().addingTimeInterval(-seconds)
|
||||
for dir in dirs
|
||||
{
|
||||
let created = (try? dir.resourceValues(forKeys: [ .creationDateKey ]).creationDate) ?? .distantPast
|
||||
if created < cutoff { try? FileManager.default.removeItem(at: dir) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
// 主 app 与各扩展共享的后端坐标 + 控件专用设备会话标识。
|
||||
enum CDropAPI
|
||||
{
|
||||
// 后端 / 引擎源(与 EngineController.engineURL 默认同源)。原生扩展无「同源」语境,须绝对 URL。
|
||||
static let base = "https://drop.commilitia.net"
|
||||
|
||||
private static let widgetDeviceIDKey = "cdrop.widget.device_id"
|
||||
|
||||
// 控件专用设备会话的稳定 device_id(dev_ 前缀 + [A-Za-z0-9_-],见后端 validDeviceID)。存
|
||||
// App Group UserDefaults,主 app 代铸控件会话与控件自刷共用同一身份。
|
||||
static func widgetDeviceID() -> String
|
||||
{
|
||||
let defaults = UserDefaults(suiteName: AppGroup.identifier) ?? .standard
|
||||
if let existing = defaults.string(forKey: widgetDeviceIDKey) { return existing }
|
||||
let id = "dev_widget" + UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16)
|
||||
defaults.set(id, forKey: widgetDeviceIDKey)
|
||||
return id
|
||||
}
|
||||
|
||||
// 登出时清掉控件设备 id:下次登录(可能换账号)铸全新控件身份,避免复用旧 device_id 使新
|
||||
// 账号的控件会话无设备行、不可在管理 UI 吊销(见审查 L2)。
|
||||
static func clearWidgetDeviceID()
|
||||
{
|
||||
UserDefaults(suiteName: AppGroup.identifier)?.removeObject(forKey: widgetDeviceIDKey)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
// i18n:读 app bundle 内的 <locale>.json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出,
|
||||
// 强约束②,见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键
|
||||
// 在产出时已并入 zh-CN 值),故 loader 只需选对 locale。
|
||||
// i18n:读 bundle 内的 <locale>.json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出,强约束②,
|
||||
// 见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键在产出时已并入
|
||||
// zh-CN 值),故 loader 只需选对 locale。Shared 文件——主 app / Share Extension / 控件扩展共用;
|
||||
// 各 target 各读自己 bundle 的 i18n JSON(resourceURL 的无子目录回退兼容扩展打平的资源布局)。
|
||||
enum I18n
|
||||
{
|
||||
private static let dict: [String: String] = load()
|
||||
@@ -39,6 +40,10 @@ enum I18n
|
||||
?? Bundle.main.url(forResource: loc, withExtension: "json")
|
||||
}
|
||||
|
||||
// 当前解析出的 locale(zh-CN / zh-TW / en-US),供需向后端登记语言的场景复用
|
||||
// (如 APNs 注册,使推送文案与 UI 语言一致)。
|
||||
static var locale: String { preferredLocale() }
|
||||
|
||||
private static func preferredLocale() -> String
|
||||
{
|
||||
for lang in Locale.preferredLanguages
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
// 控件专用设备会话:主 app 经 /api/auth/device-session 给控制中心控件代铸的独立委派会话
|
||||
// (独立 refresh,不与引擎抢轮换)。存 App Group 共享文件,控件读取后自行调 /api/clipboard、
|
||||
// 401 即自刷并写回;refresh 被拒则清空,待主 app 下次启动重铸。
|
||||
struct WidgetSession: Codable
|
||||
{
|
||||
var accessToken: String
|
||||
var refreshToken: String
|
||||
let userId: String
|
||||
}
|
||||
|
||||
enum WidgetSessionStore
|
||||
{
|
||||
private static func fileURL() -> URL?
|
||||
{
|
||||
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier)
|
||||
else { return nil }
|
||||
return base.appendingPathComponent("widget_session.json")
|
||||
}
|
||||
|
||||
static func load() -> WidgetSession?
|
||||
{
|
||||
guard let url = fileURL(), let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSession.self, from: data)
|
||||
}
|
||||
|
||||
static func save(_ session: WidgetSession)
|
||||
{
|
||||
guard let url = fileURL(), let data = try? JSONEncoder().encode(session) else { return }
|
||||
// 首次解锁后即可读(含再次锁屏后):控制中心控件可从锁屏触发,用 .completeFileProtection
|
||||
// 会在锁屏时读不到 → 控件静默失效。UntilFirstUserAuthentication 仍加密、又覆盖锁屏调用。
|
||||
try? data.write(to: url, options: [ .atomic, .completeFileProtectionUntilFirstUserAuthentication ])
|
||||
}
|
||||
|
||||
static func clear()
|
||||
{
|
||||
guard let url = fileURL() else { return }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 真机签名配置。仅对 device SDK(iphoneos)生效——模拟器仍走 project.yml base 的 ad-hoc
|
||||
// 签名(CODE_SIGN_IDENTITY = "-"),无需 Apple 账号即可构建 / 跑模拟器。
|
||||
//
|
||||
// 账号特定值(Team ID、各 target 的 profile 名)放在同目录 **gitignore** 的 Local.xcconfig
|
||||
// 里(公开仓库不留账号标识)。Local.xcconfig 不存在时下面的可选 include 跳过,device 构建会
|
||||
// 因缺 Team/profile 失败——这正是预期(真机构建须先按 REALDEVICE.md 建好 Local.xcconfig)。
|
||||
#include? "Local.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE[sdk=iphoneos*] = Manual
|
||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Development
|
||||
DEVELOPMENT_TEAM[sdk=iphoneos*] = $(CDROP_TEAM_ID)
|
||||
@@ -0,0 +1,35 @@
|
||||
import UIKit
|
||||
|
||||
// 应用委托:承载须在 launch 完成前注册、或经 UIKit 委托回调送达的平台集成——后台续传任务
|
||||
// 注册(本文件),以及 APNs 远程通知的设备令牌回调(见扩展)。SwiftUI App 经
|
||||
// @UIApplicationDelegateAdaptor 挂接。
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate
|
||||
{
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool
|
||||
{
|
||||
// BGContinuedProcessingTask 的 launch handler 须在 launch 完成前注册。
|
||||
BackgroundTaskManager.shared.registerOnce()
|
||||
return true
|
||||
}
|
||||
|
||||
// APNs 注册成功:拿到设备令牌 → 交 PushRegistry 经引擎桥上报后端。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
)
|
||||
{
|
||||
PushRegistry.shared.didRegister(tokenData: deviceToken)
|
||||
}
|
||||
|
||||
// APNs 注册失败(模拟器无 APNs 支撑 / 未配能力 / 无网络):退化为仅在线 SSE 收事件,非致命。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
)
|
||||
{
|
||||
// 仅记录,不打断。真机配齐 Push 能力后这里不会触发。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import BackgroundTasks
|
||||
import UIKit
|
||||
|
||||
// 后台续传(iOS 26 BGContinuedProcessingTask)。前台发起的传输切到后台时,向系统申请续跑
|
||||
// 任务并展示系统进度 UI(类比 AirDrop)。任务生命周期绑定活跃传输集合:0→>0(且前台)时
|
||||
// 提交、随字节更新 progress、回到 0 时完成。
|
||||
//
|
||||
// 真机验证点(R-iOS-3):BGContinuedProcessingTask 给「应用」后台运行时间 + 系统进度 UI,
|
||||
// 但承载传输的 WKWebView 是独立 WebContent 进程,其 JS / WebRTC 是否随之保活须真机验;若仍
|
||||
// 被挂起,行为退化为「切后台暂停、回前台续传」(R-iOS-1 允许,文件传输本需前台)。
|
||||
//
|
||||
// 全程主线程驱动:register 用 .main 队列、sync 由引擎主线程回调触发,故不引入 actor 隔离。
|
||||
final class BackgroundTaskManager
|
||||
{
|
||||
static let shared = BackgroundTaskManager()
|
||||
private init() {}
|
||||
|
||||
// 标识符通配前缀。Info.plist 的 BGTaskSchedulerPermittedIdentifiers 须含 "<prefix>.*",
|
||||
// 否则注册返回 false、提交抛错——届时仅退化为不申请后台续跑,传输逻辑不受影响。
|
||||
private static let prefix = "net.commilitia.Commilitia-Drop.transfer"
|
||||
private static let wildcard = prefix + ".*"
|
||||
|
||||
private var triedRegister = false
|
||||
private var registerOK = false
|
||||
private var task: BGContinuedProcessingTask?
|
||||
private var pendingId: String? // 已提交、尚未 attach 的请求标识符(用于回到 0 时取消)。
|
||||
private var latestFraction: Double = 0
|
||||
|
||||
// 应用启动时注册一次(须在 launch 完成前——由 AppDelegate.didFinishLaunching 调用)。
|
||||
func registerOnce()
|
||||
{
|
||||
guard !triedRegister else { return }
|
||||
triedRegister = true
|
||||
registerOK = BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.wildcard, using: .main)
|
||||
{ [weak self] task in
|
||||
guard let cpt = task as? BGContinuedProcessingTask
|
||||
else { task.setTaskCompleted(success: false); return }
|
||||
self?.attach(cpt)
|
||||
}
|
||||
}
|
||||
|
||||
// 引擎每次刷新活跃传输时调用:activeCount 活跃传输数,fraction 总进度 [0, 1]。
|
||||
func sync(activeCount: Int, fraction: Double)
|
||||
{
|
||||
latestFraction = fraction
|
||||
if activeCount > 0
|
||||
{
|
||||
if let task { task.progress.completedUnitCount = Int64(fraction * 100) }
|
||||
else { submitIfForeground() }
|
||||
}
|
||||
else
|
||||
{
|
||||
finish(success: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func submitIfForeground()
|
||||
{
|
||||
// 注册失败(未声明权限)/ 已在运行 / 已提交待 attach:均不再提交。
|
||||
guard registerOK, task == nil, pendingId == nil else { return }
|
||||
// 系统拒绝后台提交(且传输入口本就在前台),故仅前台提交。
|
||||
guard UIApplication.shared.applicationState != .background else { return }
|
||||
|
||||
let id = "\(Self.prefix).\(UUID().uuidString.prefix(8))"
|
||||
let request = BGContinuedProcessingTaskRequest(
|
||||
identifier: id, title: t("ios.bg.title"), subtitle: t("ios.bg.subtitle"))
|
||||
request.strategy = .queue
|
||||
do
|
||||
{
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
pendingId = id
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 提交失败(无空位 / 系统负载高):传输仍在前台正常跑,非致命。
|
||||
}
|
||||
}
|
||||
|
||||
private func attach(_ task: BGContinuedProcessingTask)
|
||||
{
|
||||
pendingId = nil
|
||||
self.task = task
|
||||
task.progress.totalUnitCount = 100
|
||||
task.progress.completedUnitCount = Int64(latestFraction * 100)
|
||||
task.expirationHandler = { [weak self] in
|
||||
self?.task?.setTaskCompleted(success: false)
|
||||
self?.task = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(success: Bool)
|
||||
{
|
||||
if let task
|
||||
{
|
||||
task.progress.completedUnitCount = task.progress.totalUnitCount
|
||||
task.setTaskCompleted(success: success)
|
||||
self.task = nil
|
||||
}
|
||||
if let pendingId
|
||||
{
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: pendingId)
|
||||
self.pendingId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
@main
|
||||
struct CDropApp: App
|
||||
{
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var auth = AuthManager()
|
||||
@State private var engine = EngineController()
|
||||
|
||||
@@ -47,5 +48,12 @@ struct AppRoot: View
|
||||
auth.debugSession()
|
||||
}
|
||||
}
|
||||
// Share Extension 深链:cdrop://share 唤起 → 导入收件箱待发文件(RootView 据此弹选设备)。
|
||||
// 放在 AppRoot 以便登录 / 未登录任一界面都接得到;未登录时文件留收件箱,登录后 onAppear 补扫。
|
||||
.onOpenURL
|
||||
{ url in
|
||||
guard url.scheme == "cdrop", url.host == "share" else { return }
|
||||
engine.loadPendingShares()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ final class EngineController: NSObject
|
||||
var presenceCount = 0
|
||||
var lastEngineLog = ""
|
||||
|
||||
// 引擎 JS 是否已就绪(收到 "ready" 通知)。门控 APNs 令牌登记:令牌可能先于引擎到达,
|
||||
// 故 didRegister 与 ready 双触发 flushPushRegistration,任一顺序都登记得上。
|
||||
private var engineReady = false
|
||||
|
||||
// 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。
|
||||
var clipboardStatus = ""
|
||||
var deviceActionStatus = ""
|
||||
@@ -89,6 +93,7 @@ final class EngineController: NSObject
|
||||
override init()
|
||||
{
|
||||
super.init()
|
||||
PushRegistry.shared.engine = self
|
||||
}
|
||||
|
||||
// makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息
|
||||
@@ -126,6 +131,8 @@ final class EngineController: NSObject
|
||||
wv.load(request)
|
||||
webView = wv
|
||||
deviceName = currentDeviceName()
|
||||
// 已登录、引擎启动 → 请求通知授权并注册远程通知(幂等)。令牌回来后经引擎桥上报。
|
||||
PushRegistry.shared.requestAuthorizationAndRegister()
|
||||
return wv
|
||||
}
|
||||
|
||||
@@ -135,11 +142,19 @@ final class EngineController: NSObject
|
||||
{
|
||||
sendCommand("shutdown", payload: [:])
|
||||
webView = nil
|
||||
engineReady = false
|
||||
devices = []
|
||||
transfers = []
|
||||
history = []
|
||||
status = t("ios.engine.disconnected")
|
||||
deviceName = ""
|
||||
PushRegistry.shared.reset()
|
||||
WidgetSessionStore.clear()
|
||||
// L1:释放暂存待发文件的安全作用域访问 + 清空 outgoing,登出不残留访问声明 / 条目。
|
||||
for (_, url) in outgoing { url.stopAccessingSecurityScopedResource() }
|
||||
outgoing.removeAll()
|
||||
// L2:清掉控件设备 id,下次登录铸全新控件身份。
|
||||
CDropAPI.clearWidgetDeviceID()
|
||||
}
|
||||
|
||||
// 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。
|
||||
@@ -185,6 +200,24 @@ final class EngineController: NSObject
|
||||
sendCommand("revokeDevice", payload: [ "name": name ])
|
||||
}
|
||||
|
||||
// APNs 令牌登记:原生拿到令牌(PushRegistry.didRegister)或引擎刚就绪(ready)任一时机调用。
|
||||
// 仅在引擎已就绪 + 有令牌时经桥上报,把令牌交后端 /api/push/apns/register(用新鲜会话 token)。
|
||||
func flushPushRegistration()
|
||||
{
|
||||
guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
|
||||
sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
|
||||
}
|
||||
|
||||
// 控件会话代铸:引擎就绪后,若 App Group 尚无控件会话则让引擎铸一条(独立 device_id +
|
||||
// 「<设备> · 控件」标签)。已有则不重复铸——控件自刷拥有它,重复铸会轮换掉控件的新令牌。
|
||||
func provisionWidgetSessionIfNeeded()
|
||||
{
|
||||
guard engineReady, WidgetSessionStore.load() == nil else { return }
|
||||
let id = CDropAPI.widgetDeviceID()
|
||||
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
|
||||
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
|
||||
}
|
||||
|
||||
// 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。
|
||||
func sendCommand(_ name: String, payload: [String: Any])
|
||||
{
|
||||
@@ -207,6 +240,48 @@ final class EngineController: NSObject
|
||||
])
|
||||
}
|
||||
|
||||
// Share Extension 经 App Group 收件箱交接来的待发文件(经 cdrop://share 唤起后导入)。
|
||||
// 非空即触发主界面的「选设备发送」弹窗。
|
||||
var pendingShareFiles: [URL] = []
|
||||
|
||||
// 导入分享:先回收陈旧暂存,再读收件箱当前文件填入 pendingShareFiles(驱动弹窗)。
|
||||
func loadPendingShares()
|
||||
{
|
||||
AppGroup.pruneStale()
|
||||
pendingShareFiles = AppGroup.inboxFiles()
|
||||
}
|
||||
|
||||
// 把待发的分享文件发往选中设备。先把每个文件从 App Group 收件箱 move 到 app 私有暂存
|
||||
// (同卷重命名,便宜),再发——收件箱当即清空,避免下次 loadPendingShares(再分享 / 重启)
|
||||
// 重列已发文件而重复发送(审查 M1)。传输异步从私有暂存读取,不再依赖收件箱。
|
||||
func sendShares(to device: String)
|
||||
{
|
||||
for url in pendingShareFiles
|
||||
{
|
||||
if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
|
||||
AppGroup.removeStaged(url)
|
||||
}
|
||||
pendingShareFiles = []
|
||||
}
|
||||
|
||||
// 把分享文件移到 app 私有暂存目录(保留原名),供传输异步读取;收件箱不再持有它。
|
||||
private func moveShareToPrivate(_ url: URL) -> URL?
|
||||
{
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let dest = dir.appendingPathComponent(url.lastPathComponent)
|
||||
do { try FileManager.default.moveItem(at: url, to: dest); return dest }
|
||||
catch { return nil }
|
||||
}
|
||||
|
||||
// 取消分享:删掉收件箱里这些待发文件 + 清空待发列表。
|
||||
func dismissShares()
|
||||
{
|
||||
for url in pendingShareFiles { AppGroup.removeStaged(url) }
|
||||
pendingShareFiles = []
|
||||
}
|
||||
|
||||
// 暂存待发文件,返回引擎用的 cdrop-file://<id> 引用。文件选择器给的 URL 是安全作用域
|
||||
// 资源,需 start...Access 才能读。
|
||||
private func stageOutgoingFile(_ url: URL) -> String
|
||||
@@ -278,6 +353,9 @@ extension EngineController: WKScriptMessageHandler
|
||||
{
|
||||
case "ready":
|
||||
status = t("ios.engine.ready")
|
||||
engineReady = true
|
||||
flushPushRegistration()
|
||||
provisionWidgetSessionIfNeeded()
|
||||
case "probe":
|
||||
// 本机 WebRTC-in-WKWebView 探针结果(验 arch A 命门 R-iOS-3)。
|
||||
if let p = payload as? [String: Any]
|
||||
@@ -300,6 +378,7 @@ extension EngineController: WKScriptMessageHandler
|
||||
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
|
||||
{
|
||||
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
|
||||
syncBackgroundTask()
|
||||
}
|
||||
case "transferDone":
|
||||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
||||
@@ -309,6 +388,7 @@ extension EngineController: WKScriptMessageHandler
|
||||
history.removeAll { $0.sessionId == item.sessionId }
|
||||
history.insert(item, at: 0)
|
||||
if history.count > 30 { history.removeLast(history.count - 30) }
|
||||
syncBackgroundTask()
|
||||
}
|
||||
case "sendStarted":
|
||||
// 随后的 transfers 事件会带出该活跃项,这里无需额外处理。
|
||||
@@ -362,6 +442,15 @@ extension EngineController: WKScriptMessageHandler
|
||||
{
|
||||
auth?.updateSession(accessToken: access, refreshToken: refresh)
|
||||
}
|
||||
case "widgetSession":
|
||||
// 引擎代铸的控件专用设备会话 → 存 App Group 供控制中心控件读取 / 自刷。
|
||||
if let p = payload as? [String: Any],
|
||||
let access = p["access_token"] as? String,
|
||||
let refresh = p["refresh_token"] as? String,
|
||||
let userId = p["user_id"] as? String
|
||||
{
|
||||
WidgetSessionStore.save(WidgetSession(accessToken: access, refreshToken: refresh, userId: userId))
|
||||
}
|
||||
case "authExpired":
|
||||
// 引擎确证会话已失效(refresh 过期 / 吊销)→ 清 Keychain 回登录页。
|
||||
auth?.logout()
|
||||
@@ -375,6 +464,16 @@ extension EngineController: WKScriptMessageHandler
|
||||
{
|
||||
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
|
||||
}
|
||||
|
||||
// 活跃传输变化时驱动后台续传任务:transfers 即活跃集(完成项已移入 history),故按其
|
||||
// 数量与聚合字节进度通知 BackgroundTaskManager 提交 / 更新 / 完成(见 R-iOS-1 / R-iOS-3)。
|
||||
private func syncBackgroundTask()
|
||||
{
|
||||
let total = transfers.reduce(0) { $0 + $1.fileSize }
|
||||
let done = transfers.reduce(0) { $0 + ($1.bytesTransferred ?? 0) }
|
||||
let fraction = total > 0 ? Double(done) / Double(total) : 0
|
||||
BackgroundTaskManager.shared.sync(activeCount: transfers.count, fraction: fraction)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 发送侧自定义 scheme(cdrop-file)
|
||||
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
|
||||
extension EngineController: WKURLSchemeHandler
|
||||
{
|
||||
// 发送侧:原生把暂存的待发文件经 cdrop-file://<id> 喂给引擎(JS fetch 它得到字节,
|
||||
// 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块
|
||||
// didReceive 流式以避 jetsam = R-iOS-4 优化点。
|
||||
// 见 PLAN §2 / R-iOS-4)。引擎(rangeSource)按块带 `Range` 头拉取——这里 seek 文件
|
||||
// 仅读该区间、回 206 Partial Content,整文件永不入内存,避大文件 jetsam。无 Range
|
||||
// 头时回退整文件(防御路径;引擎正常总带 Range)。
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
|
||||
{
|
||||
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
|
||||
@@ -392,16 +492,48 @@ extension EngineController: WKURLSchemeHandler
|
||||
respond(urlSchemeTask, status: 404)
|
||||
return
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL)
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||
let total = attrs[.size] as? Int,
|
||||
let handle = try? FileHandle(forReadingFrom: fileURL)
|
||||
else
|
||||
{
|
||||
respond(urlSchemeTask, status: 404)
|
||||
return
|
||||
}
|
||||
defer { try? handle.close() }
|
||||
|
||||
if let range = Self.parseByteRange(urlSchemeTask.request.value(forHTTPHeaderField: "Range"), total: total)
|
||||
{
|
||||
let data: Data
|
||||
do
|
||||
{
|
||||
try handle.seek(toOffset: UInt64(range.lowerBound))
|
||||
data = (try handle.read(upToCount: range.count)) ?? Data()
|
||||
}
|
||||
catch
|
||||
{
|
||||
respond(urlSchemeTask, status: 500)
|
||||
return
|
||||
}
|
||||
let resp = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": "\(data.count)",
|
||||
"Content-Range": "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(total)",
|
||||
"Accept-Ranges": "bytes",
|
||||
])!
|
||||
urlSchemeTask.didReceive(resp)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
let data = (try? handle.readToEnd()) ?? Data()
|
||||
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": "\(data.count)",
|
||||
"Accept-Ranges": "bytes",
|
||||
])!
|
||||
urlSchemeTask.didReceive(resp)
|
||||
urlSchemeTask.didReceive(data)
|
||||
@@ -410,7 +542,23 @@ extension EngineController: WKURLSchemeHandler
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
|
||||
{
|
||||
// 无长任务(v1 同步返回),无需处理。
|
||||
// 每次请求同步返回(一块即一次 read),无长任务需取消。
|
||||
}
|
||||
|
||||
// 解析 HTTP `Range: bytes=start-end`(end 含端)。返回半开区间 [lowerBound, upperBound)。
|
||||
// end 缺省("bytes=start-")取到 EOF;越界裁剪到文件尾;非法 / 不可满足返回 nil。
|
||||
static func parseByteRange(_ header: String?, total: Int) -> Range<Int>?
|
||||
{
|
||||
guard let header, header.hasPrefix("bytes="), total > 0 else { return nil }
|
||||
let spec = header.dropFirst("bytes=".count)
|
||||
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard parts.count == 2, let start = Int(parts[0]), start >= 0, start < total else { return nil }
|
||||
let endInclusive: Int
|
||||
if parts[1].isEmpty { endInclusive = total - 1 }
|
||||
else if let e = Int(parts[1]) { endInclusive = min(e, total - 1) }
|
||||
else { return nil }
|
||||
guard endInclusive >= start else { return nil }
|
||||
return start ..< (endInclusive + 1)
|
||||
}
|
||||
|
||||
private func respond(_ task: any WKURLSchemeTask, status: Int)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>net.commilitia.Commilitia-Drop.transfer.*</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh-Hans</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,6 +28,17 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>net.commilitia.Commilitia-Drop</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>cdrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
@@ -41,6 +56,10 @@
|
||||
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
// APNs 设备令牌的交汇点:解耦 AppDelegate(经 UIKit 回调拿到令牌)与 EngineController(持
|
||||
// 新鲜会话 token,经引擎桥把令牌上报后端 POST /api/push/apns/register)。令牌走引擎桥注册
|
||||
// ——与剪贴板 / 吊销同范式,避免原生直连用陈旧 token。
|
||||
//
|
||||
// 真机 / 账号门控:真正收到推送需 Push Notifications 能力(aps-environment entitlement;真机
|
||||
// 须付费 ADP 配 profile)与后端 .p8。模拟器(iOS 16+ / Apple Silicon)可注册取令牌、并经
|
||||
// `simctl push` 验原生处理;真发真测随付费账号落地。
|
||||
@MainActor
|
||||
final class PushRegistry: NSObject
|
||||
{
|
||||
static let shared = PushRegistry()
|
||||
private override init() { super.init() }
|
||||
|
||||
weak var engine: EngineController?
|
||||
private(set) var deviceTokenHex: String?
|
||||
private var requested = false
|
||||
|
||||
// 登录后调用:请求通知授权,授权通过即注册远程通知(触发 AppDelegate didRegister 回调)。
|
||||
// 幂等——重复调用只在首次生效。
|
||||
func requestAuthorizationAndRegister()
|
||||
{
|
||||
guard !requested else { return }
|
||||
requested = true
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
|
||||
{ granted, _ in
|
||||
guard granted else { return }
|
||||
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
|
||||
}
|
||||
}
|
||||
|
||||
// AppDelegate 回调(主线程):APNs 下发设备令牌(Data)→ 十六进制 → 经引擎桥上报。
|
||||
func didRegister(tokenData: Data)
|
||||
{
|
||||
deviceTokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
|
||||
engine?.flushPushRegistration()
|
||||
}
|
||||
|
||||
// 登出复位:清状态,允许下次登录重新请求授权 / 注册(令牌按会话归属)。
|
||||
func reset()
|
||||
{
|
||||
requested = false
|
||||
deviceTokenHex = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PushRegistry: UNUserNotificationCenterDelegate
|
||||
{
|
||||
// 前台收到推送也展示横幅 + 声音(系统默认前台不展示),与「收到文件」即时提示一致。
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions
|
||||
{
|
||||
return [ .banner, .sound ]
|
||||
}
|
||||
}
|
||||
@@ -354,5 +354,11 @@
|
||||
"ios.files.empty": "No files received yet",
|
||||
"ios.files.share": "Share",
|
||||
"ios.files.done": "Done",
|
||||
"ios.detail.openFile": "Open File"
|
||||
"ios.detail.openFile": "Open File",
|
||||
"ios.bg.title": "Transferring files",
|
||||
"ios.bg.subtitle": "Continues in the background",
|
||||
"ios.share.title": "Send {{n}} file(s) to a device",
|
||||
"ios.control.upload": "Upload Clipboard",
|
||||
"ios.control.pull": "Pull Clipboard",
|
||||
"ios.control.sessionLabel": "{{name}} · Control"
|
||||
}
|
||||
|
||||
@@ -354,5 +354,11 @@
|
||||
"ios.files.empty": "还没有收到文件",
|
||||
"ios.files.share": "分享",
|
||||
"ios.files.done": "完成",
|
||||
"ios.detail.openFile": "打开文件"
|
||||
"ios.detail.openFile": "打开文件",
|
||||
"ios.bg.title": "正在传输文件",
|
||||
"ios.bg.subtitle": "切到后台仍继续传输",
|
||||
"ios.share.title": "发送 {{n}} 个文件到设备",
|
||||
"ios.control.upload": "上传剪贴板",
|
||||
"ios.control.pull": "拉取剪贴板",
|
||||
"ios.control.sessionLabel": "{{name}} · 控件"
|
||||
}
|
||||
|
||||
@@ -354,5 +354,11 @@
|
||||
"ios.files.empty": "尚未收到檔案",
|
||||
"ios.files.share": "分享",
|
||||
"ios.files.done": "完成",
|
||||
"ios.detail.openFile": "開啟檔案"
|
||||
"ios.detail.openFile": "開啟檔案",
|
||||
"ios.bg.title": "正在傳輸檔案",
|
||||
"ios.bg.subtitle": "切到背景仍繼續傳輸",
|
||||
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
|
||||
"ios.control.upload": "上傳剪貼簿",
|
||||
"ios.control.pull": "拉取剪貼簿",
|
||||
"ios.control.sessionLabel": "{{name}} · 控制項"
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ struct RootView: View
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Share Extension 交接:cdrop://share 唤起后导入收件箱待发文件,弹出选设备发送。
|
||||
.onAppear { engine.loadPendingShares() }
|
||||
.confirmationDialog(
|
||||
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
|
||||
isPresented: Binding(
|
||||
get: { !engine.pendingShareFiles.isEmpty },
|
||||
set: { if !$0 { engine.dismissShares() } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
)
|
||||
{
|
||||
ForEach(engine.sendableDevices())
|
||||
{ dev in
|
||||
Button(dev.name) { engine.sendShares(to: dev.name) }
|
||||
}
|
||||
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-8
@@ -1,11 +1,22 @@
|
||||
# cdrop iOS 待办(择期处理)
|
||||
# cdrop iOS 待办
|
||||
|
||||
2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。
|
||||
## 计划主体(I1–I7):已实现(2026-06-27)
|
||||
|
||||
1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现。
|
||||
2. **设备 / session 管理非空壳**(数据侧已做,管理操作未做):列表数据已由“A”接真实 presence(`DeviceListView` 读 `engine.devices`)。剩余:会话管理操作(查看 / 吊销其他会话等)。
|
||||
3. **禁止临时登录**(未做):iOS app 不应允许 guest / 临时(once)登录;扫码登录应强制 full / persist(“信任此设备”)scope。当前后端 qr 默认 guest、`AuthManager` 也写死 `scope: "guest"`——需 iOS 侧走 full。
|
||||
4. **退出登录**(已做):设置页“账户”区已加登出(destructive),清 Keychain 会话 + 复位引擎 + 回登录页(`SettingsView.logout` → `auth.logout()` + `engine.reset()`)。
|
||||
5. **传输详情**(已做):`TransferDetailView` 按 sessionId 实时查引擎态,展示方向 / 对端 / 状态 / 阶段 / 通道 / 大小 / 进度 / 速度;卡片可点入。剩余可选:ICE 候选明细(`transfer.debug.*` catalog 已有,未接)。
|
||||
传输(含发送端流式 R-iOS-4)/ 设备 + 会话管理 / 强制 full 登录 / 登出 / 传输详情 / 后台续传(BGContinuedProcessingTask)/ APNs(后端 + 原生注册)/ Share Extension / 控制中心剪贴板两控件——全部落地、模拟器验、经 ultracode 审查修讫。详见 `ios/PLAN.md` 里程碑 + `ios/PARITY.md` 漂移日志。
|
||||
|
||||
> **A 已完成**:引擎 presence / 传输 → 原生 UI 数据绑定(三屏全替 demo)+ `TransferDetailView` + Keychain 会话持久化 + 登出 + i18n 插值。闭合 #4 / #5 与 #2 数据侧;prod 已部署 `engine.html`(presence 生效)。**剩余择期**:#1(device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。
|
||||
## 早期 5 项(2026-06-24 列)状态
|
||||
|
||||
1. **登录会话列表显示**:iOS 设备在(web)「登录会话」列表的标签——broker 迁移后 sessions 已按设备 cache 叠加 type(device-session 铸入 `device_type:"ios"`),**疑已解决,待真会话核**。
|
||||
2. 设备 / 会话管理:✅(设备页吊销 + 后端 list/revoke/rename)。
|
||||
3. 强制 full 登录:✅(iOS 拒非 full + 后端对非 browser 兜底 full/persist)。
|
||||
4. 登出:✅。
|
||||
5. 传输详情:✅(`TransferDetailView`;可选 ICE 明细已接)。
|
||||
|
||||
## 真机阶段(账号门控,见 REALDEVICE.md)
|
||||
|
||||
真机签名跑通 + 同内网直连 / 本地网络权限(R-iOS-6)+ 后台挂起行为(R-iOS-3)+ APNs 真发 + Share Extension / 控件真机交互验证。代码就绪,等付费 ADP + 门户 + 设备。
|
||||
|
||||
## 余项(择期)
|
||||
|
||||
- 控件「添加控件」库名 + Shortcuts 标题随设备语言(需为 `CDropWidgets` 加 `.xcstrings`;当前简体静态,审查 L7,平台专属可接受)。
|
||||
- 发送端 `outgoing` 暂存 + 安全作用域访问按传输完成逐项释放(当前登出统一清,审查 L1 余量)。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 与主 app 共享的 App Group:控件读取 widget_session.json(主 app 代铸的控件会话)。 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.commilitia.cdrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
// 控制中心控件用的原生剪贴板客户端:用控件专用设备会话(WidgetSessionStore)直连
|
||||
// /api/clipboard 读写,access 过期(401)即调 /api/auth/refresh 自刷并写回。控件在独立进程
|
||||
// 运行、不开主 app,故不能复用引擎桥——这里走纯原生 REST(PLAN §I4)。独立会话使自刷不与
|
||||
// 引擎抢 refresh 轮换。
|
||||
enum ClipboardClient
|
||||
{
|
||||
enum ClientError: Error { case noSession, http(Int) }
|
||||
|
||||
// 上传本机剪贴板文本到云剪贴板(PUT /api/clipboard)。
|
||||
static func upload(_ text: String) async throws
|
||||
{
|
||||
let body = try JSONSerialization.data(withJSONObject: [
|
||||
"content": text,
|
||||
"content_type": "text/plain",
|
||||
"origin_ts": Int(Date().timeIntervalSince1970 * 1000),
|
||||
])
|
||||
_ = try await authed(method: "PUT", path: "/api/clipboard", body: body)
|
||||
}
|
||||
|
||||
// 拉取云剪贴板文本(GET /api/clipboard)。
|
||||
static func pull() async throws -> String
|
||||
{
|
||||
let data = try await authed(method: "GET", path: "/api/clipboard", body: nil)
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return (obj?["content"] as? String) ?? ""
|
||||
}
|
||||
|
||||
// 带鉴权 + 401 自刷重试一次的请求。
|
||||
private static func authed(method: String, path: String, body: Data?) async throws -> Data
|
||||
{
|
||||
guard var session = WidgetSessionStore.load() else { throw ClientError.noSession }
|
||||
do
|
||||
{
|
||||
return try await send(method: method, path: path, body: body, token: session.accessToken)
|
||||
}
|
||||
catch ClientError.http(401)
|
||||
{
|
||||
session = try await refresh(session)
|
||||
WidgetSessionStore.save(session)
|
||||
return try await send(method: method, path: path, body: body, token: session.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
private static func send(method: String, path: String, body: Data?, token: String) async throws -> Data
|
||||
{
|
||||
var req = URLRequest(url: URL(string: CDropAPI.base + path)!)
|
||||
req.httpMethod = method
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let body
|
||||
{
|
||||
req.httpBody = body
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
// 200 与 409(PUT 时云端有更新的 LWW 冲突)都算成功;其余抛对应码(401 触发自刷)。
|
||||
guard code == 200 || code == 409 else { throw ClientError.http(code) }
|
||||
return data
|
||||
}
|
||||
|
||||
private static func refresh(_ session: WidgetSession) async throws -> WidgetSession
|
||||
{
|
||||
let body = try JSONSerialization.data(withJSONObject: [ "refresh_token": session.refreshToken ])
|
||||
var req = URLRequest(url: URL(string: CDropAPI.base + "/api/auth/refresh")!)
|
||||
req.httpMethod = "POST"
|
||||
req.httpBody = body
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard code == 200,
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let access = obj["access_token"] as? String
|
||||
else
|
||||
{
|
||||
// 刷新被拒(refresh 过期 / 吊销)→ 清掉控件会话,待主 app 下次启动重铸。
|
||||
// CAS-before-clear:仅当磁盘上的 refresh 仍是本次所用那条时才清——否则说明另一个并发
|
||||
// 控件 intent 已轮换出新会话,别把它刚写入的新令牌误删(审查 L3)。
|
||||
if WidgetSessionStore.load()?.refreshToken == session.refreshToken
|
||||
{
|
||||
WidgetSessionStore.clear()
|
||||
}
|
||||
throw ClientError.http(401)
|
||||
}
|
||||
let newRefresh = (obj["refresh_token"] as? String) ?? session.refreshToken
|
||||
return WidgetSession(accessToken: access, refreshToken: newRefresh, userId: session.userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// 控制中心两控件(iOS 18+ ControlWidget,PLAN 决策 C / §I4):上传 / 拉取云剪贴板,零推送、
|
||||
// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。
|
||||
@main
|
||||
struct CDropWidgetBundle: WidgetBundle
|
||||
{
|
||||
var body: some Widget
|
||||
{
|
||||
UploadClipboardControl()
|
||||
PullClipboardControl()
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadClipboardControl: ControlWidget
|
||||
{
|
||||
var body: some ControlWidgetConfiguration
|
||||
{
|
||||
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.upload")
|
||||
{
|
||||
ControlWidgetButton(action: UploadClipboardIntent())
|
||||
{
|
||||
Label(t("ios.control.upload"), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.displayName("上传剪贴板")
|
||||
}
|
||||
}
|
||||
|
||||
struct PullClipboardControl: ControlWidget
|
||||
{
|
||||
var body: some ControlWidgetConfiguration
|
||||
{
|
||||
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.pull")
|
||||
{
|
||||
ControlWidgetButton(action: PullClipboardIntent())
|
||||
{
|
||||
Label(t("ios.control.pull"), systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
.displayName("拉取剪贴板")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import AppIntents
|
||||
import UIKit
|
||||
|
||||
// 「上传剪贴板」控件动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。
|
||||
struct UploadClipboardIntent: AppIntent
|
||||
{
|
||||
static var title: LocalizedStringResource = "上传剪贴板"
|
||||
static var description = IntentDescription("把本机剪贴板内容上传到 Commilitia Drop 云剪贴板")
|
||||
|
||||
func perform() async throws -> some IntentResult
|
||||
{
|
||||
let text = await MainActor.run { UIPasteboard.general.string ?? "" }
|
||||
if !text.isEmpty { try? await ClipboardClient.upload(text) }
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// 「拉取剪贴板」控件动作:拉云剪贴板内容写入本机剪贴板。
|
||||
struct PullClipboardIntent: AppIntent
|
||||
{
|
||||
static var title: LocalizedStringResource = "拉取剪贴板"
|
||||
static var description = IntentDescription("把 Commilitia Drop 云剪贴板内容写入本机剪贴板")
|
||||
|
||||
func perform() async throws -> some IntentResult
|
||||
{
|
||||
if let text = try? await ClipboardClient.pull(), !text.isEmpty
|
||||
{
|
||||
await MainActor.run { UIPasteboard.general.string = text }
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Commilitia Drop</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
+88
-2
@@ -7,19 +7,37 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
# 模拟器用 ad-hoc 签名(身份 "-",无需 Apple 账号),使 App Group / Push 等 entitlement 在
|
||||
# 模拟器实际生效(否则共享容器不创建、远程通知注册失败)。真机签名须付费 ADP 配
|
||||
# team / provisioning profile(账号门控,见 REALDEVICE.md)。
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
CODE_SIGNING_ALLOWED: "YES"
|
||||
targets:
|
||||
CDrop:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: Shared
|
||||
dependencies:
|
||||
- target: CDropShare
|
||||
embed: true
|
||||
- target: CDropWidgets
|
||||
embed: true
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Commilitia Drop
|
||||
# cdrop:// URL scheme:Share Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: net.commilitia.Commilitia-Drop
|
||||
CFBundleURLSchemes:
|
||||
- cdrop
|
||||
# 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符
|
||||
# 串)跟随设备语言本地化,而非永远英文。开发区域设简体。
|
||||
CFBundleDevelopmentRegion: zh-Hans
|
||||
@@ -28,6 +46,13 @@ targets:
|
||||
- zh-Hant
|
||||
- en
|
||||
UILaunchScreen: {}
|
||||
# 后台续传:BGContinuedProcessingTask 的通配标识符须在此声明,系统才允许注册 / 提交
|
||||
# (见 BackgroundTaskManager)。默认资源(CPU + 网络)无需额外 entitlement。
|
||||
BGTaskSchedulerPermittedIdentifiers:
|
||||
- "net.commilitia.Commilitia-Drop.transfer.*"
|
||||
# 远程通知后台模式:APNs 推送送达 / 后台处理(如「收到文件」通知)。
|
||||
UIBackgroundModes:
|
||||
- remote-notification
|
||||
# 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下
|
||||
# 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。
|
||||
UIFileSharingEnabled: true
|
||||
@@ -40,9 +65,70 @@ targets:
|
||||
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# APNs 推送的 aps-environment entitlement(见 CDrop.entitlements)。模拟器取令牌可用;
|
||||
# 真机签名须付费 ADP 开 Push 能力(账号门控)。
|
||||
CODE_SIGN_ENTITLEMENTS: CDrop.entitlements
|
||||
# 真机:用门户 profile 手动签名(仅 device SDK;模拟器走 base ad-hoc)。profile 名取自
|
||||
# gitignore 的 Local.xcconfig(见 Signing.xcconfig / REALDEVICE.md)。
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_APP)"
|
||||
# Share Extension:抓分享文件 → App Group 收件箱 → 深链主 app 选设备发送(只交接、不跑引擎,
|
||||
# 见 PLAN §I5)。只编 ShareViewController + 共享的 AppGroup,绝不拉主 app Sources(避超 120MB)。
|
||||
CDropShare:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Share
|
||||
- path: Shared
|
||||
info:
|
||||
path: Share/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Commilitia Drop
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
||||
NSExtensionAttributes:
|
||||
NSExtensionActivationRule:
|
||||
NSExtensionActivationSupportsFileWithMaxCount: 20
|
||||
NSExtensionActivationSupportsImageWithMaxCount: 20
|
||||
NSExtensionActivationSupportsMovieWithMaxCount: 20
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.share
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_ENTITLEMENTS: Share/CDropShare.entitlements
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_SHARE)"
|
||||
# 控制中心剪贴板两控件(WidgetKit ControlWidget,iOS 18+,PLAN §I4)。纯原生 REST + 控件专用
|
||||
# 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。
|
||||
CDropWidgets:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Widgets
|
||||
- path: Shared
|
||||
- path: Sources/Resources/i18n
|
||||
info:
|
||||
path: Widgets/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Commilitia Drop
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_WIDGETS)"
|
||||
schemes:
|
||||
CDrop:
|
||||
build:
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
|
||||
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
|
||||
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
|
||||
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -58,3 +59,4 @@
|
||||
| 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**:web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) |
|
||||
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
|
||||
| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
|
||||
| 2026-06-27 | source.ts(新 FileSource)+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client);EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3–I7)**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3)/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**:engine.html(新桥)+ cdrop binary(APNs 端点 + platform 列) |
|
||||
|
||||
@@ -111,6 +111,8 @@ 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`(手动签名 + `just ios-device` 全 CLI 装机,门户手动建)。包名 `net.commilitia.Commilitia-Drop`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
|
||||
|
||||
Reference in New Issue
Block a user