iOS 与传输修复:取消即时落终态 + 行操作统一 + 记录按用户分桶 + 发送端进度/完成对齐接收端

- 取消传输无效:web cancelTransfer 拆除本端 p2p/relay 后立即 completeTransfer(sessionId, "CANCELLED") 落终态,不再仅依赖服务端 transfer:state CANCELLED 回显——回显延迟、或因设备名变更致路由不中而未送达时,旧实现使记录永停“进行中”,即用户所见“取消无效”。completeTransfer 幂等,随后回显安全重入。
- 进行中/已完成传输行操作统一:进行中行新增滑动“取消”+ 长按菜单“取消/切中继”,与历史行滑动+长按“删除”对称。取消即时无确认、随 transferDone 移入 history,析构滑动动画与现实一致不抖。详情页取消/切中继入口保留。
- 传输历史/消息持久化:记录按登录用户 userId 分桶持久化(EngineController recordKey/activateRecords/persist*,含旧无桶文件一次性回退)。reset() 改为只清内存、不再 RecordsStore.clear 磁盘——会话失效(authExpired/被吊销)属凭证层事件,不应连用户数据一并销毁;同一账户重登经 activateRecords 即恢复,换账号各读各桶天然防跨账号泄露。CDropApp 在 onAppear 与 onChange(session.user.id) 激活记录。
- 发送端进度/完成对齐接收端:接收端经数据通道节流(200ms)+finalize 即时回传已收字节(ack 帧);发送端进度改用 ackedBytes(单调取大),取代仅反映本地 SCTP 缓冲抽干的 senderDeliveredBytes——避免 Windows→iOS 等慢接收端组合下发送端进度虚高领先、完成早十数秒(4MB)。streamFile 抽干 buffer 后再等 ack 追平总量才宣告完成;ackedBytes==0(旧版本接收端)走旧“抽干即完成”行为、30s 超时兜底、接收端权威 /done 经 SSE 独立完成。
This commit is contained in:
2026-06-27 20:15:08 +08:00
parent 2caf1ab921
commit c1dda6759c
5 changed files with 192 additions and 26 deletions
+8
View File
@@ -57,6 +57,14 @@ struct AppRoot: View
{
auth.debugSession()
}
// userId reset()
// authExpired / #3
if let uid = auth.session?.user.id { engine.activateRecords(userId: uid) }
}
// session nil authExpired /
.onChange(of: auth.session?.user.id)
{ _, uid in
if let uid { engine.activateRecords(userId: uid) }
}
// Share Extension cdrop://share RootView
// AppRoot 便 / onAppear
+67 -17
View File
@@ -159,16 +159,65 @@ final class EngineController: NSObject
private static let historyCap = 50
private static let messagesCap = 200
// / / userId recordKey
// authExpired /
// persist*
private var currentRecordUser = ""
override init()
{
super.init()
PushRegistry.shared.engine = self
// /
history = RecordsStore.load([TransferItem].self, "history") ?? []
messages = RecordsStore.load([MessageItem].self, "messages") ?? []
// presence / SSE
// SSE presence 线
devices = RecordsStore.load([DeviceItem].self, "devices") ?? []
// activateRecordsuserId init
// auth activateRecords AppRoot /
//
}
// / userId reset()
// authExpired /
// legacy 退 "history"/"messages"/"devices"
//
func activateRecords(userId: String)
{
guard !userId.isEmpty else { return }
currentRecordUser = userId
history = RecordsStore.load([TransferItem].self, recordKey("history"))
?? RecordsStore.load([TransferItem].self, "history") ?? []
messages = RecordsStore.load([MessageItem].self, recordKey("messages"))
?? RecordsStore.load([MessageItem].self, "messages") ?? []
devices = RecordsStore.load([DeviceItem].self, recordKey("devices"))
?? RecordsStore.load([DeviceItem].self, "devices") ?? []
}
// userIdbroker subject UUID
// / - / _ _
private func recordKey(_ name: String) -> String
{
let safe = String(currentRecordUser.map
{ ch in
(ch.isLetter || ch.isNumber || ch == "-" || ch == "_") ? ch : "_"
})
return "\(safe)-\(name)"
}
// currentRecordUser
// "-name"
private func persistHistory()
{
guard !currentRecordUser.isEmpty else { return }
RecordsStore.save(history, recordKey("history"))
}
private func persistMessages()
{
guard !currentRecordUser.isEmpty else { return }
RecordsStore.save(messages, recordKey("messages"))
}
private func persistDevices()
{
guard !currentRecordUser.isEmpty else { return }
RecordsStore.save(devices, recordKey("devices"))
}
// makeWebView WebView __CDROP_BOOT__device_type:"ios"
@@ -232,10 +281,11 @@ final class EngineController: NSObject
seenIncomingTransfers = []
activeTab = "transfer"
pendingLoginRequest = nil
//
RecordsStore.clear("history")
RecordsStore.clear("messages")
RecordsStore.clear("devices")
// / authExpired /
// #3
// currentRecordUser userId activateRecords
// /
currentRecordUser = ""
status = t("ios.engine.disconnected")
deviceName = ""
PushRegistry.shared.reset()
@@ -251,26 +301,26 @@ final class EngineController: NSObject
func deleteTransferRecord(_ sessionId: String)
{
history.removeAll { $0.sessionId == sessionId }
RecordsStore.save(history, "history")
persistHistory()
}
func clearHistory()
{
history.removeAll()
RecordsStore.clear("history")
RecordsStore.clear(recordKey("history"))
}
// H/
func deleteMessage(_ id: String)
{
messages.removeAll { $0.id == id }
RecordsStore.save(messages, "messages")
persistMessages()
}
func clearMessages()
{
messages.removeAll()
RecordsStore.clear("messages")
RecordsStore.clear(recordKey("messages"))
}
// ---- #2 ----
@@ -655,7 +705,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
RecordsStore.save(devices, "devices") // presence
persistDevices() // presence
}
case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
@@ -678,7 +728,7 @@ extension EngineController: WKScriptMessageHandler
{
messages.removeLast(messages.count - Self.messagesCap)
}
RecordsStore.save(messages, "messages")
persistMessages()
// #5outgoing
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
}
@@ -692,7 +742,7 @@ extension EngineController: WKScriptMessageHandler
history.insert(item, at: 0)
if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) }
syncBackgroundTask()
RecordsStore.save(history, "history") //
persistHistory() //
}
case "sendStarted":
// transfers
+20 -3
View File
@@ -227,9 +227,10 @@ struct TransferListView: View
}
}
// + /
// /
//
// + /
//
// / transferDone
// history /
@ViewBuilder
private func transferRow(_ item: TransferItem, history: Bool) -> some View
{
@@ -256,6 +257,22 @@ struct TransferListView: View
else
{
card
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
}
.contextMenu
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
if item.mode != "relay"
{
Button { engine.switchToRelay(item.sessionId) }
label: { Label(t("ios.transfer.forceRelay"),
systemImage: "antenna.radiowaves.left.and.right") }
}
}
}
}