设备名与令牌解耦 + 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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user