Files
Commilitia-Drop/ios/CDrop/Sources/Engine/EngineController.swift
T
admin 349d94aa8f iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/)
- UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型
- 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber
- 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md
- 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主
- 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate
- 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方
- 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
2026-06-28 19:33:08 +08:00

1306 lines
61 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Observation
import UIKit
import WebKit
// presence web store DeviceInfolastSeen ms epoch
// Decodable JS WKWebView bool NSNumber
// / JSONDecoder
// web hub.ts handlePresence
struct DeviceItem: Identifiable, Equatable, Codable
{
// 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 { 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
}
// tab
// conversations() messages 线 /
// reachable线 APNs 线 iOS ConversationView 线
// 线 / sendableDevices
struct ConversationItem: Identifiable, Equatable
{
let peerName: String
let deviceID: String // id /
let deviceType: String
let online: Bool
let reachable: Bool // 线
let lastText: String
let lastAt: Double // epoch 0
let lastOutgoing: Bool
var id: String { peerName }
}
// web engine/main.ts toWire ice*
// P2P TURN
struct TransferItem: Identifiable, Equatable, Codable
{
let sessionId: String
let direction: String
let fileName: String
let fileSize: Int
let state: String
let mode: String?
let peerName: String
let phase: String?
let bytesTransferred: Int?
let bytesPerSec: Double?
let iceConn: String?
let iceLocal: String?
let iceRemote: String?
var id: String { sessionId }
}
// #2 / /
// / deviceActionStatus / clipboardStatus
struct Notice: Identifiable, Equatable
{
enum Kind: Equatable { case success, error, info }
let id = UUID()
let kind: Kind
let text: String
}
// #3installLogBridge console.warn/error
// N lastEngineLogat
struct LogEntry: Identifiable, Equatable
{
let id = UUID()
let level: String // warn | error | info
let message: String
let at: Date
// "session <id> ..." id + pion
// / SSE / nil
let sessionId: String?
init(level: String, message: String, at: Date)
{
self.level = level
self.message = message
self.at = at
self.sessionId = LogEntry.parseSession(message)
}
static func parseSession(_ msg: String) -> String?
{
guard msg.hasPrefix("session ") else { return nil }
let id = msg.dropFirst("session ".count).prefix { !$0.isWhitespace }
return id.isEmpty ? nil : String(id)
}
}
// #8/link?r=&c=
// / requestId / code
struct LoginRequestItem: Identifiable, Equatable
{
let requestId: String
let code: String
let deviceName: String
let deviceType: String
let requestIp: String
var id: String { requestId }
}
// JS web/src/net/ios.tsios/PLAN.md §2
// "cdropEngine" WKScriptMessageHandler { id, method, payload }RPC
// { notify, payload }RPC evaluateJavaScript
// window.__cdropEngineResolve(id, ok, value) window.__cdropEngineEvent
@MainActor
@Observable
final class EngineController: NSObject
{
// SwiftUI
var status: String = t("ios.engine.disconnected")
var deviceName: String = ""
// UI demo
var devices: [DeviceItem] = [] // 线 / 线 presence presence
var transfers: [TransferItem] = [] // transfers
var history: [TransferItem] = [] // transferDone 30
var messages: [MessageItem] = [] // message
// SSE / + presence +
// warn/error 401
var hubConnected = false
var hubReconnecting = false
var presenceCount = 0
var lastEngineLog = ""
// #3log logsCap N
var logs: [LogEntry] = []
private static let logsCap = 300
// #2
var notices: [Notice] = []
// #8 / /
var pendingLoginRequest: LoginRequestItem?
// #5 tab activeTab 使
// seenIncomingTransfers
// unreadByPeer tab
// unreadMessages activeConversation 使
var unreadByPeer: [String: Int] = [:]
var activeConversation: String?
var unreadMessages: Int { unreadByPeer.values.reduce(0, +) }
var unreadTransfers = 0
var activeTab = "transfer"
private var seenIncomingTransfers: Set<String> = []
// JS "ready" APNs
// didRegister ready flushPushRegistration
private var engineReady = false
// WebRTC origin PLAN R-iOS-3线
// https CDROP_ENGINE_URL http://localhostlocalhost
// WebRTC Windows loopback
private var engineURL: URL
{
if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s)
{
return u
}
return URL(string: "https://drop.commilitia.net/engine.html")!
}
// boot EngineWebView.makeUIView boot #11
var auth: AuthManager?
// idbroker metapresence PATCH
// /api/devices/{device_id}
var selfDeviceID: String { auth?.session?.deviceId ?? "" }
private var webView: WKWebView?
private let downloads = DownloadManager()
// id URL cdrop-file://<id>
// WKURLSchemeHandler
private var outgoing: [String: URL] = [:]
// libwebrtcSafari RTCPeerConnection WKWebView 256KB SCTP
// + desktop/NATIVE-TRANSFER.md §7 gomobile/pionpion Go raw
// socket iOS Network framework P2P RPC
// Documents DownloadManager p2pEvents
// 宿libwebrtc 线 / 线宿
private var transferEngine: LibWebRtcEngine?
private var p2pEvents: P2PEventsBridge?
// /
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
// 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"
// cdrop-file scheme JS
func makeWebView() -> WKWebView
{
if let existing = webView { return existing }
let ucc = WKUserContentController()
ucc.add(self, name: "cdropEngine")
// boot nulldevice_type ios
// api_base /api prod URL
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
+ "device_name: \(jsString(currentDeviceName())), "
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
ucc.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
let cfg = WKWebViewConfiguration()
cfg.userContentController = ucc
cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file")
// Auth Broker refresh_tokenPOST /api/auth/refresh
// broker cdrop_session cookie URLSession cookie WebView
let wv = WKWebView(frame: .zero, configuration: cfg)
// engine.html WebView
// / handler engine.html
// bundle
var request = URLRequest(url: engineURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
wv.load(request)
webView = wv
deviceName = currentDeviceName()
//
PushRegistry.shared.requestAuthorizationAndRegister()
// #5使 WKWebView WebRTC host
LocalNetworkPermission.trigger()
return wv
}
// SSE WebView UI makeWebView
// boot auth.session
func reset()
{
sendCommand("shutdown", payload: [:])
webView = nil
engineReady = false
devices = []
transfers = []
history = []
messages = []
// / / / /
notices = []
logs = []
unreadByPeer = [:]
activeConversation = nil
unreadTransfers = 0
seenIncomingTransfers = []
activeTab = "transfer"
pendingLoginRequest = nil
// / authExpired /
// #3
// currentRecordUser userId activateRecords
// /
currentRecordUser = ""
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()
}
// D/
func deleteTransferRecord(_ sessionId: String)
{
history.removeAll { $0.sessionId == sessionId }
persistHistory()
}
func clearHistory()
{
history.removeAll()
// activateRecords 退 legacy
// [] 使 load 退
// legacy
persistHistory()
RecordsStore.clear("history")
}
// H/
func deleteMessage(_ id: String)
{
messages.removeAll { $0.id == id }
persistMessages()
}
func clearMessages()
{
messages.removeAll()
// clearHistory legacy 退 legacy
persistMessages()
RecordsStore.clear("messages")
}
// /
//
//
func deleteConversation(_ peerName: String)
{
messages.removeAll { $0.peerName == peerName }
persistMessages()
}
// ---- #2 ----
// notify 4s
//
func notify(_ text: String, kind: Notice.Kind)
{
guard !text.isEmpty else { return }
let notice = Notice(kind: kind, text: text)
notices.insert(notice, at: 0)
Task
{
try? await Task.sleep(for: .seconds(4))
dismissNotice(notice.id)
}
}
func dismissNotice(_ id: UUID)
{
notices.removeAll { $0.id == id }
}
// ---- #5 ----
// setActiveTab
func setActiveTab(_ tab: String)
{
activeTab = tab
// tab
// markConversationRead tab
if tab == "transfer" { unreadTransfers = 0 }
}
// ConversationView.onAppear + onDisappear nil
func setActiveConversation(_ peerName: String?)
{
activeConversation = peerName
}
func markConversationRead(_ peerName: String)
{
if unreadByPeer[peerName] != nil { unreadByPeer[peerName] = 0 }
}
//
func unread(for peerName: String) -> Int
{
return unreadByPeer[peerName] ?? 0
}
// noteIncomingTransfer sessionId
//
private func noteIncomingTransfer(_ item: TransferItem)
{
guard item.direction == "incoming", !seenIncomingTransfers.contains(item.sessionId) else { return }
seenIncomingTransfers.insert(item.sessionId)
if activeTab != "transfer" { unreadTransfers += 1 }
}
// ---- #3 ----
func clearLogs()
{
logs.removeAll()
}
// Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
//
// name (1)退
func receivedFile(matching name: String) -> URL?
{
let files = downloads.receivedFiles()
if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact }
let base = (name as NSString).deletingPathExtension
return files.first { $0.lastPathComponent.hasPrefix(base) }
}
// 线 + 线iOS APNs线
// / 线 web transfer.ts waitForPeerThenBegin
func sendableDevices() -> [DeviceItem]
{
return devices.filter { $0.name != deviceName && ($0.online || Self.isWakeable($0)) }
}
// 线iOS App
static func isWakeable(_ dev: DeviceItem) -> Bool
{
if dev.online { return false }
let k = dev.type.lowercased()
return k.contains("ios") || k.contains("iphone") || k.contains("ipad")
}
// 线线
func sendLabel(_ dev: DeviceItem) -> String
{
return Self.isWakeable(dev) ? "\(dev.name) · \(t("home.device.wakeable"))" : dev.name
}
// ---- ----
// 线 /
// presence / 线 lastAt=0
//
func conversations() -> [ConversationItem]
{
// messages
var latest: [String: MessageItem] = [:]
for m in messages where latest[m.peerName] == nil { latest[m.peerName] = m }
let reachable = sendableDevices()
let reachableNames = Set(reachable.map { $0.name })
//
var peers = Set(latest.keys)
peers.formUnion(reachableNames)
peers.remove(deviceName) //
var items: [ConversationItem] = []
for peer in peers
{
let dev = devices.first { $0.name == peer }
let last = latest[peer]
items.append(ConversationItem(
peerName: peer,
deviceID: dev?.deviceID ?? "",
deviceType: dev?.type ?? "",
online: dev?.online ?? false,
reachable: reachableNames.contains(peer),
lastText: last?.text ?? "",
lastAt: last?.sentAt ?? 0,
lastOutgoing: last?.direction == "outgoing"))
}
//
return items.sorted { a, b in a.lastAt != b.lastAt ? a.lastAt > b.lastAt : a.peerName < b.peerName }
}
// 线
func thread(with peerName: String) -> [MessageItem]
{
return messages.filter { $0.peerName == peerName }.sorted { $0.sentAt < $1.sentAt }
}
// 线 APNs sendableDevices 线
// 线 /
func canMessage(_ peerName: String) -> Bool
{
return sendableDevices().contains { $0.name == peerName }
}
// UIPasteboard
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// UIPasteboard handleNotify "clipboard"
func pullClipboard()
{
sendCommand("clipboardPull", payload: [:])
}
// / device_idDELETE /api/devices/{device_id}
// #2
func revokeDevice(_ deviceID: String)
{
sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
}
// ---- #8 ----
// /link?r=&c=qrRequest
// requestId / code
func fetchLoginRequest(requestId: String, code: String)
{
sendCommand("fetchLoginRequest", payload: [ "request_id": requestId, "code": code ])
}
// full访 Web/PWA
func approveLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("approveLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func denyLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("denyLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func cancelLoginRequest()
{
pendingLoginRequest = nil
}
// 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.didRegisterready
// + /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 }
// meta = device_id+ sub="widget" +
// tier=clipboard dev_widget device_id
// ClipboardClient#7
let id = selfDeviceID
guard !id.isEmpty else { return }
sendCommand("provisionWidgetSession", payload: [
"device_id": id,
"device_name": currentDeviceName(),
"sub": "widget",
])
}
// JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any])
{
guard let json = jsonString(payload) else { return }
webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));")
}
// sendFile cdrop-file://<id> +
func sendFile(to target: String, fileURL: URL)
{
let ref = stageOutgoingFile(fileURL)
var size = 0
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let n = attrs[.size] as? Int { size = n }
sendCommand("sendFile", payload: [
"target": target,
"url": ref,
"name": fileURL.lastPathComponent,
"size": size,
])
}
// 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 = []
}
// #14PhotosPicker 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
{
let id = UUID().uuidString
_ = url.startAccessingSecurityScopedResource()
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()
}
// ---- gomobile/pion----
// Documents DownloadManager P2PEventsBridge
// 线 JSlibwebrtc RPC error
private func nativeTransferEngine() -> LibWebRtcEngine
{
if let e = transferEngine { return e }
let events = P2PEventsBridge()
events.controller = self
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].path
let e = LibWebRtcEngine(downloadDir: dir, delegate: events)
p2pEvents = events
transferEngine = e
return e
}
// cdrop-file://<id> Go
// 访 stageOutgoingFile reset Go os.Open
//
private func resolveOutgoingPath(_ ref: String) -> String
{
guard let id = URL(string: ref)?.host, let url = outgoing[id] else { return "" }
return url.path
}
// resolveOnMain libwebrtc 线线线 resolve RPC
// nonisolated 使线 DispatchQueue.main + assumeIsolated @MainActor
// resolveevaluateJavaScript 线errornil ok=false退 /
nonisolated private func resolveOnMain(id: Int, error: Error?)
{
DispatchQueue.main.async
{
MainActor.assumeIsolated
{
self.resolve(id: id, ok: error == nil, value: error?.localizedDescription)
}
}
}
// #3 P2PEventsBridge.onLog 线
func appendNativeLog(_ msg: String)
{
guard !msg.isEmpty else { return }
lastEngineLog = msg
logs.insert(LogEntry(level: "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
}
// MARK: - JS RPC +
extension EngineController: WKScriptMessageHandler
{
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage)
{
guard let body = message.body as? [String: Any] else { return }
if let id = body["id"] as? Int
{
handleRPC(id: id,
method: body["method"] as? String ?? "",
payload: body["payload"] as? [String: Any] ?? [:])
return
}
if let notify = body["notify"] as? String
{
handleNotify(notify, payload: body["payload"])
}
}
private func handleRPC(id: Int, method: String, payload: [String: Any])
{
do
{
switch method
{
case "saveDownload":
let name = payload["name"] as? String ?? "download"
let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: path)
case "beginDownload":
try downloads.begin(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
case "appendDownload":
try downloads.append(sessionId: payload["sessionId"] as? String ?? "",
data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: nil)
case "finalizeDownload":
let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "",
name: payload["name"] as? String ?? "download")
resolve(id: id, ok: true, value: path)
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)
case "p2pStartOutgoing":
// filePath cdrop-file://<id>
// libwebrtc completion offer + resolveOnMain
let engine = nativeTransferEngine()
let path = resolveOutgoingPath(payload["filePath"] as? String ?? "")
guard !path.isEmpty
else { resolve(id: id, ok: false, value: "no staged file"); return }
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startOutgoing(sessionId: sid, peerName: peer, filePath: path, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pStartIncoming":
// PeerConnection offer Documents
let engine = nativeTransferEngine()
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startIncoming(sessionId: sid, peerName: peer, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pHandleSignal":
// JS hub SSE completion
// JS offer/answer trickle p2pIos.ts chain
guard let engine = transferEngine
else { resolve(id: id, ok: false, value: "no native session"); return }
let from = payload["fromPeer"] as? String ?? ""
let pj = payload["payloadJSON"] as? String ?? ""
engine.handleSignal(fromPeer: from, payloadJSON: pj)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pCancel":
// / 退 OnStatelibwebrtc close resolve
let sid = payload["sessionId"] as? String ?? ""
transferEngine?.cancel(sessionId: sid)
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
}
catch
{
resolve(id: id, ok: false, value: "\(error)")
}
}
private func handleNotify(_ name: String, payload: Any?)
{
switch name
{
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]
{
let secure = (p["secure"] as? Bool) ?? false
let rtc = (p["rtc"] as? String) ?? "?"
let host = (p["host"] as? Int) ?? 0
let srflx = (p["srflx"] as? Int) ?? 0
status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)"
}
case "presence":
// JS NSArray`as? [[String: Any]]`
// Foundation 便 [Any]
// as? [String: Any]
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
persistDevices() // presence
}
case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
for item in transfers { noteIncomingTransfer(item) } // #5
syncBackgroundTask()
}
case "message":
// + + +
//
if let p = payload as? [String: Any],
let m = p["message"] as? [String: Any],
let item = Self.parseMessage(m)
{
let isNew = !messages.contains { $0.id == item.id }
messages.removeAll { $0.id == item.id }
messages.insert(item, at: 0)
if messages.count > Self.messagesCap
{
messages.removeLast(messages.count - Self.messagesCap)
}
persistMessages()
//
// outgoing
if isNew, item.direction == "incoming", item.peerName != activeConversation
{
unreadByPeer[item.peerName, default: 0] += 1
}
}
case "transferDone":
// sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
noteIncomingTransfer(item) // #5 seen
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) }
syncBackgroundTask()
persistHistory() //
}
case "sendStarted":
// transfers
break
case "hubState":
if let p = payload as? [String: Any]
{
hubConnected = Self.boolOf(p["connected"])
hubReconnecting = Self.boolOf(p["reconnecting"])
presenceCount = Self.intOf(p["presenceCount"])
}
case "log":
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
logs.insert(LogEntry(level: p["level"] as? String ?? "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
case "clipboard":
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
notify(t("ios.clipboard.pulled"), kind: .success)
}
else
{
notify(t("ios.clipboard.empty"), kind: .info)
}
case "clipboardUploaded":
notify(t("ios.clipboard.uploaded"), kind: .success)
case "deviceRevoked":
notify(t("ios.devices.revoked"), kind: .success)
case "loginRequest":
// #8
if let p = payload as? [String: Any],
let rid = p["request_id"] as? String, let code = p["code"] as? String
{
pendingLoginRequest = LoginRequestItem(
requestId: rid, code: code,
deviceName: p["device_name"] as? String ?? "",
deviceType: p["device_type"] as? String ?? "",
requestIp: p["request_ip"] as? String ?? "")
}
case "loginApproved":
notify(t("ios.scan.approved"), kind: .success)
case "loginDenied":
notify(t("ios.scan.denied"), kind: .success)
case "loginFailed":
pendingLoginRequest = nil
notify(t("ios.scan.failed"), kind: .error)
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
notify(t("ios.settings.deviceName.success"), kind: .success)
}
case "error":
// error /
if let p = payload as? [String: Any]
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { notify(t("ios.clipboard.failed"), kind: .error) }
else if stage == "revoke"
{
notify(msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error)
}
else { status = "错误:\(msg)" }
}
case "sessionRotated":
// broker refresh Keychain refresh
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String
{
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()
reset()
default:
break
}
}
private func resolve(id: Int, ok: Bool, value: Any?)
{
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: - schemecdrop-file
extension EngineController: WKURLSchemeHandler
{
// cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4rangeSource `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]
else
{
respond(urlSchemeTask, status: 404)
return
}
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)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// 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)
{
guard let url = task.request.url,
let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)
else { return }
task.didReceive(resp)
task.didFinish()
}
}
// MARK: - DeviceItem
extension EngineController
{
// WKWebView NSNumber / Int / Double / Bool
//
static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
// web store DeviceInfocamelCasedeviceId / 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 }
let ice = t["ice"] as? [String: Any]
return TransferItem(sessionId: sessionId,
direction: t["direction"] as? String ?? "outgoing",
fileName: t["fileName"] as? String ?? "",
fileSize: intOf(t["fileSize"]),
state: t["state"] as? String ?? "PENDING",
mode: t["mode"] as? String,
peerName: t["peerName"] as? String ?? "",
phase: t["phase"] as? String,
bytesTransferred: t["bytesTransferred"].map { intOf($0) },
bytesPerSec: t["bytesPerSec"].map { doubleOf($0) },
iceConn: ice?["conn"] as? String,
iceLocal: ice?["local"] as? String,
iceRemote: ice?["remote"] as? String)
}
static func boolOf(_ v: Any?) -> Bool
{
if let b = v as? Bool { return b }
if let n = v as? NSNumber { return n.boolValue }
return false
}
static func intOf(_ v: Any?) -> Int
{
if let i = v as? Int { return i }
if let n = v as? NSNumber { return n.intValue }
if let d = v as? Double { return Int(d) }
return 0
}
static func doubleOf(_ v: Any?) -> Double
{
if let d = v as? Double { return d }
if let n = v as? NSNumber { return n.doubleValue }
if let i = v as? Int { return Double(i) }
return 0
}
}
// MARK: -
private extension EngineController
{
func decodeBase64(_ v: Any?) -> Data
{
guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() }
return d
}
func jsonString(_ obj: [String: Any]) -> String?
{
guard JSONSerialization.isValidJSONObject(obj),
let d = try? JSONSerialization.data(withJSONObject: obj),
let s = String(data: d, encoding: .utf8)
else { return nil }
return s
}
// JS JSON nil null
// JSONSerialization
func jsonValue(_ v: Any?) -> String
{
guard let v = v else { return "null" }
guard let d = try? JSONSerialization.data(withJSONObject: [v]),
let s = String(data: d, encoding: .utf8)
else { return "null" }
return String(s.dropFirst().dropLast())
}
func jsString(_ s: String) -> String
{
return jsonValue(s)
}
// boot JSON nullapi_base
func sessionJSON() -> String
{
guard let s = auth?.session else { return "null" }
return "{ access_token: \(jsString(s.accessToken)), "
+ "refresh_token: \(jsString(s.refreshToken)), "
+ "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }"
}
func currentDeviceName() -> String
{
return auth?.session?.deviceName ?? "iPhone"
}
func apiBaseForBoot() -> String
{
return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? ""
}
}