设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复

设备名与令牌解耦(presence 按 device_id 键控):
- hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表
- 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机
- 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常
- revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick

iOS Broker 应用内登录(免扫码):
- BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复
- LoginView 主登录改 broker 账号登录、扫码降级为可展开备选

iOS 功能补全:
- 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪)
- 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription)
- 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认)
- 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令)
- 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」)
- 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选

iOS 发送 / 启动修复:
- 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存
- 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态

桌面 bug 修复:
- 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态)
- 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向)
- macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription)

web / 跨端:
- presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗

分发 / 签名:
- 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements)
- hub 测试加 device_id 键控 + 改名解耦回归(-race)
This commit is contained in:
2026-06-27 03:50:04 +08:00
parent 44096069a7
commit 127070e226
51 changed files with 1655 additions and 227 deletions
+182 -8
View File
@@ -9,16 +9,30 @@ import WebKit
// (对齐 web hub.ts handlePresence 的防御式取值)。
struct DeviceItem: Identifiable, Equatable
{
// deviceID 是稳定标识(broker meta):设备名与令牌解耦后,去重 / 判本机 / 跨端吊销都按它,
// 改名只换 name 而 deviceID 不变。后端 presence 总会带;偶发缺失(代铸前的连接)回落 name。
let deviceID: String
let name: String
let type: String
let online: Bool
let lastSeen: Double
var id: String { name }
var id: String { deviceID.isEmpty ? name : deviceID }
}
// 引擎过桥推来的设备间文本消息(对应 web store 的 MessageRecord)。原生侧持久化(见 RecordsStore),
// 故重启存活、可手动删除——不再「关 app 即清」。
struct MessageItem: Identifiable, Equatable, Codable
{
let id: String
let direction: String // incoming | outgoing
let peerName: String
let text: String
let sentAt: Double
}
// 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选
// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。
struct TransferItem: Identifiable, Equatable
struct TransferItem: Identifiable, Equatable, Codable
{
let sessionId: String
let direction: String
@@ -52,6 +66,7 @@ final class EngineController: NSObject
var devices: [DeviceItem] = [] // 在线 / 离线设备 presence(每次 presence 事件整组替换)
var transfers: [TransferItem] = [] // 活跃传输(每次 transfers 事件整组替换)
var history: [TransferItem] = [] // 已完成传输(transferDone 逐条前插,限长 30)
var messages: [MessageItem] = [] // 设备间文本消息(引擎每次 message 事件整组替换;最新在前)
// 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎
// 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。
@@ -80,9 +95,13 @@ final class EngineController: NSObject
return URL(string: "https://drop.commilitia.net/engine.html")!
}
// 会话来源(登录后注入引擎 boot)。由 app 在启动时接好(AppRoot.onAppear)。
// 会话来源(登录后注入引擎 boot)。在 EngineWebView.makeUIView 里 boot 注入前同步接好(#11)。
var auth: AuthManager?
// 本机稳定设备 id(broker meta):判本机(presence 里哪台是自己)与即时改名(PATCH
// /api/devices/{device_id})都用它,与设备名解耦——改名不变。
var selfDeviceID: String { auth?.session?.deviceId ?? "" }
private var webView: WKWebView?
private let downloads = DownloadManager()
@@ -90,10 +109,17 @@ final class EngineController: NSObject
// 回取字节(下方 WKURLSchemeHandler 供给)。
private var outgoing: [String: URL] = [:]
// 历史 / 消息限长(原生持久化,跨重启存活)。
private static let historyCap = 50
private static let messagesCap = 200
override init()
{
super.init()
PushRegistry.shared.engine = self
// 加载上次持久化的传输历史 / 消息记录,修「重启清空」。新事件经引擎桥增量追加。
history = RecordsStore.load([TransferItem].self, "history") ?? []
messages = RecordsStore.load([MessageItem].self, "messages") ?? []
}
// makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息
@@ -133,6 +159,8 @@ final class EngineController: NSObject
deviceName = currentDeviceName()
// 已登录、引擎启动 → 请求通知授权并注册远程通知(幂等)。令牌回来后经引擎桥上报。
PushRegistry.shared.requestAuthorizationAndRegister()
// 触发本地网络权限弹窗(#5):使 WKWebView 内的 WebRTC 能收集 host 候选、同内网直连。
LocalNetworkPermission.trigger()
return wv
}
@@ -146,6 +174,10 @@ final class EngineController: NSObject
devices = []
transfers = []
history = []
messages = []
// 登出清空持久记录,不跨账号残留(重启保活仅针对同一登录态)。
RecordsStore.clear("history")
RecordsStore.clear("messages")
status = t("ios.engine.disconnected")
deviceName = ""
PushRegistry.shared.reset()
@@ -157,6 +189,32 @@ final class EngineController: NSObject
CDropAPI.clearWidgetDeviceID()
}
// 删除一条传输历史记录(D)/ 清空全部(持久化同步)。仅删本地展示记录,不影响已落盘文件。
func deleteTransferRecord(_ sessionId: String)
{
history.removeAll { $0.sessionId == sessionId }
RecordsStore.save(history, "history")
}
func clearHistory()
{
history.removeAll()
RecordsStore.clear("history")
}
// 删除一条消息(H)/ 清空全部(持久化同步)。
func deleteMessage(_ id: String)
{
messages.removeAll { $0.id == id }
RecordsStore.save(messages, "messages")
}
func clearMessages()
{
messages.removeAll()
RecordsStore.clear("messages")
}
// 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
@@ -193,11 +251,44 @@ final class EngineController: NSObject
sendCommand("clipboardPull", payload: [:])
}
// 设备管理:移除 / 吊销一台设备。
func revokeDevice(_ name: String)
// 设备管理:移除 / 吊销一台设备。按稳定 device_id(DELETE /api/devices/{device_id})——
// 之前传设备名致后端找不到而「移除失败」(#2)。
func revokeDevice(_ deviceID: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "name": name ])
sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
}
// 主动取消一个进行中的传输(O)。引擎置该会话为 CANCELLED,随后 transfers / transferDone 回推。
func cancelTransfer(_ sessionId: String)
{
sendCommand("cancelTransfer", payload: [ "sessionId": sessionId ])
}
// 立即切到中继(O):跳过等待 ICE 直连的窗口,直接走 relay(同内网直连建不起来 / 不想等时用)。
func switchToRelay(_ sessionId: String)
{
sendCommand("switchToRelay", payload: [ "sessionId": sessionId ])
}
// 设备间文本消息(发送):把 { to=对端设备名, text } 交引擎经 POST /api/message 转发。乐观回显
// 由引擎侧 store.addMessage 触发的 message 事件整组回推(含本条 outgoing),故这里不本地插。
func sendMessage(to peerName: String, text: String)
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !peerName.isEmpty else { return }
sendCommand("sendMessage", payload: [ "to": peerName, "text": trimmed ])
}
// 本机即时改名(设备名与令牌解耦):把 { device_id=本机, name } 交引擎经 PATCH /api/devices/
// {device_id} 只改名字——不换 token、不重连、不产生重复项;成功后引擎回报 "renamed",本端据此
// 落本地名 + 更新 Keychain 会话名(见 handleNotify)。无 device_id(异常)则不发。
func renameSelf(to name: String)
{
let id = selfDeviceID
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !id.isEmpty, !trimmed.isEmpty else { return }
sendCommand("renameSelf", payload: [ "device_id": id, "name": trimmed ])
}
// APNs 令牌登记:原生拿到令牌(PushRegistry.didRegister)或引擎刚就绪(ready)任一时机调用。
@@ -282,6 +373,20 @@ final class EngineController: NSObject
pendingShareFiles = []
}
// 图库选择(#14):PhotosPicker 给的是 PhotosPickerItem,须先 loadTransferable 取字节,再落
// 一个临时文件,才能复用既有 cdrop-file scheme 发送链路(按 Range 读盘、整文件不入内存)。
// 返回临时文件 URL(在 temporaryDirectory,系统回收);写盘失败回 nil。
func stagePhotoData(_ data: Data, suggestedName: String) -> URL?
{
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("PhotoOutbox/\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let name = suggestedName.isEmpty ? "photo.jpg" : suggestedName
let dest = dir.appendingPathComponent(name)
do { try data.write(to: dest); return dest }
catch { return nil }
}
// 暂存待发文件,返回引擎用的 cdrop-file://<id> 引用。文件选择器给的 URL 是安全作用域
// 资源,需 start...Access 才能读。
private func stageOutgoingFile(_ url: URL) -> String
@@ -291,6 +396,24 @@ final class EngineController: NSObject
outgoing[id] = url
return "cdrop-file://\(id)"
}
// 发送侧按 [start,end) seek 读暂存的待发文件、回 base64(供 bridgeFileSource 经桥取片)。
// url 形如 cdrop-file://<id>,id 即 stageOutgoingFile 的暂存键。无该文件 / 读失败抛错。
fileprivate func readOutgoingSlice(url: String, start: Int, end: Int) throws -> String
{
guard let id = URL(string: url)?.host, let fileURL = outgoing[id]
else
{
throw NSError(domain: "cdrop.engine", code: 404,
userInfo: [NSLocalizedDescriptionKey: "no staged file: \(url)"])
}
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(max(0, start)))
let count = max(0, end - start)
let data = (try handle.read(upToCount: count)) ?? Data()
return data.base64EncodedString()
}
}
// MARK: - JS → 原生(RPC + 通知)
@@ -337,6 +460,12 @@ extension EngineController: WKScriptMessageHandler
case "abortDownload":
downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
case "readFileSlice":
// 发送侧取片:原生 seek 暂存的待发文件读 [start,end)、回 base64(见 bridgeFileSource)。
let b64 = try readOutgoingSlice(url: payload["url"] as? String ?? "",
start: Self.intOf(payload["start"]),
end: Self.intOf(payload["end"]))
resolve(id: id, ok: true, value: b64)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
@@ -380,6 +509,21 @@ extension EngineController: WKScriptMessageHandler
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
syncBackgroundTask()
}
case "message":
// 引擎推来的单条新消息(增量):前插 + 去重 + 限长 + 落盘。原生为持久真源,故不被
// 引擎重启后的空列表覆盖(重启清空问题的根因)。
if let p = payload as? [String: Any],
let m = p["message"] as? [String: Any],
let item = Self.parseMessage(m)
{
messages.removeAll { $0.id == item.id }
messages.insert(item, at: 0)
if messages.count > Self.messagesCap
{
messages.removeLast(messages.count - Self.messagesCap)
}
RecordsStore.save(messages, "messages")
}
case "transferDone":
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
@@ -387,8 +531,9 @@ extension EngineController: WKScriptMessageHandler
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) }
syncBackgroundTask()
RecordsStore.save(history, "history") // 持久化,跨重启存活
}
case "sendStarted":
// 随后的 transfers 事件会带出该活跃项,这里无需额外处理。
@@ -420,6 +565,23 @@ extension EngineController: WKScriptMessageHandler
clipboardStatus = t("ios.clipboard.uploaded")
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
case "identityUpdated":
// 引擎据 /api/me 取到真实显示名(QR 登录注入的是 UUID 名)→ 更新会话身份 + Keychain,
// 设置页据此显示真名而非 UUID(#12)。
if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty
{
auth?.updateIdentity(name: name, avatar: p["avatar"] as? String)
}
case "renamed":
// 本机即时改名成功(PATCH /api/devices/{device_id} 已落库 + 重广播 presence)→ 落本地名
// 并更新 Keychain 会话名,使重启后 boot 注入新名。
if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty
{
DeviceNameStore.value = name
auth?.updateDeviceName(name)
deviceName = name
deviceActionStatus = t("ios.settings.deviceName.success")
}
case "error":
// 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。
if let p = payload as? [String: Any]
@@ -580,12 +742,24 @@ extension EngineController
static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
return DeviceItem(name: name,
// 引擎推来的是 web store 的 DeviceInfo(camelCase:deviceId / lastSeen)。
return DeviceItem(deviceID: d["deviceId"] as? String ?? "",
name: name,
type: type,
online: boolOf(d["online"]),
lastSeen: doubleOf(d["lastSeen"]))
}
static func parseMessage(_ m: [String: Any]) -> MessageItem?
{
guard let id = m["id"] as? String, let text = m["text"] as? String else { return nil }
return MessageItem(id: id,
direction: m["direction"] as? String ?? "incoming",
peerName: m["peerName"] as? String ?? "",
text: text,
sentAt: doubleOf(m["sentAt"]))
}
static func parseTransfer(_ t: [String: Any]) -> TransferItem?
{
guard let sessionId = t["sessionId"] as? String else { return nil }
@@ -6,9 +6,14 @@ import WebKit
struct EngineWebView: UIViewRepresentable
{
let controller: EngineController
// 会话源:在构建 WebView(注入 __CDROP_BOOT__)之前确定性接好。冷启动重开时,本视图的
// makeUIView 可能先于 AppRoot.onAppear 跑——若那时 controller.auth 仍为 nil,boot 注入
// session:null → 引擎报「missing injected session」整个 boot 死(#11)。故在此同步接好。
let auth: AuthManager
func makeUIView(context: Context) -> WKWebView
{
controller.auth = auth
return controller.makeWebView()
}