| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- import Foundation
- import SwiftData
- import UniformTypeIdentifiers
- struct RemoteAsset: Decodable {
- let id: String
- let clientId: String
- let kind: String
- let fileName: String
- let mimeType: String
- let sizeBytes: Int64
- let sha256: String
- }
- struct RemoteEvent: Decodable {
- let id: String
- let clientId: String?
- let relativeTimeMs: Int64
- let eventType: String
- let textContent: String?
- let voiceStartOffsetMs: Int64?
- let voiceEndOffsetMs: Int64?
- }
- struct RemoteSession: Decodable {
- let id: String
- let clientId: String?
- let title: String
- let startTime: Date
- let endTime: Date?
- let durationMs: Int64
- let revision: Int64
- let deletedAt: Date?
- let events: [RemoteEvent]?
- let assets: [RemoteAsset]?
- }
- private struct SessionUpload: Encodable {
- let clientId: String
- let title: String
- let startTime: Date
- let endTime: Date?
- let durationMs: Int64
- let events: [EventUpload]
- }
- private struct EventUpload: Encodable {
- let clientId: String
- let relativeTimeMs: Int64
- let eventType: String
- let textContent: String?
- let voiceStartOffsetMs: Int64?
- let voiceEndOffsetMs: Int64?
- }
- private struct SyncCheckpoint: Decodable {
- let syncedAt: Date
- }
- final class RemoteNetworkService: NetworkServiceProtocol {
- private let client: APIClient
- init(client: APIClient = .shared) {
- self.client = client
- }
- func fetchSessions() async throws -> [RemoteSession] {
- try await client.request("sessions?includeDeleted=true", authenticated: true)
- }
- func syncSession(_ session: CelestiaSession) async throws -> RemoteSession {
- let payload = SessionUpload(
- clientId: session.id.uuidString,
- title: session.title,
- startTime: session.startTime,
- endTime: session.endTime,
- durationMs: session.durationMs,
- events: session.events.map {
- EventUpload(
- clientId: $0.id.uuidString,
- relativeTimeMs: $0.relativeTimeMs,
- eventType: $0.eventType,
- textContent: $0.textContent,
- voiceStartOffsetMs: $0.voiceStartOffsetMs,
- voiceEndOffsetMs: $0.voiceEndOffsetMs
- )
- }
- )
- let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
- return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
- }
- func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset {
- let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
- return try await client.upload(
- "sessions/\(sessionID)/assets",
- fileURL: fileURL,
- fileName: fileURL.lastPathComponent,
- mimeType: mimeType,
- fields: ["clientId": clientID, "kind": kind]
- )
- }
- func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
- try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
- }
- func recordSyncCheckpoint() async throws {
- let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
- }
- }
- struct RemoteBoundDevice: Decodable {
- let id: String
- let name: String
- let peripheralUUID: String
- }
- private struct BindDeviceBody: Encodable {
- let name: String
- let peripheralUUID: String
- let hardwareMAC: String?
- let firmwareVersion: String?
- let batteryLevel: Int?
- let freeStorageMB: Int?
- let totalStorageMB: Int?
- }
- private struct UpdateDeviceBody: Encodable {
- let name: String?
- let batteryLevel: Int?
- let isConnected: Bool?
- let firmwareVersion: String?
- let freeStorageMB: Int?
- let totalStorageMB: Int?
- }
- final class DeviceCloudService {
- static let shared = DeviceCloudService()
- private let client: APIClient
- init(client: APIClient = .shared) {
- self.client = client
- }
- func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
- let body = BindDeviceBody(
- name: device.name,
- peripheralUUID: device.peripheralUUID,
- hardwareMAC: device.hardwareMAC,
- firmwareVersion: device.firmwareVersion,
- batteryLevel: device.batteryLevel,
- freeStorageMB: device.freeStorageMB,
- totalStorageMB: device.totalStorageMB
- )
- return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
- }
- func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
- guard let cloudID = device.cloudID else { return try await register(device) }
- let body = UpdateDeviceBody(
- name: device.name,
- batteryLevel: device.batteryLevel,
- isConnected: device.isConnected,
- firmwareVersion: device.firmwareVersion,
- freeStorageMB: device.freeStorageMB,
- totalStorageMB: device.totalStorageMB
- )
- return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
- }
- func remove(cloudID: String) async throws {
- try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
- }
- }
- @MainActor
- final class SyncManager: ObservableObject {
- static let shared = SyncManager()
- @Published private(set) var isSyncing = false
- @Published private(set) var lastSyncDate: Date?
- @Published private(set) var lastErrorMessage: String?
- @Published private(set) var completedCount = 0
- @Published private(set) var totalCount = 0
- private let service: NetworkServiceProtocol
- init(service: NetworkServiceProtocol = RemoteNetworkService()) {
- self.service = service
- lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
- }
- func sync(
- sessions: [CelestiaSession],
- modelContext: ModelContext,
- userID: String
- ) async -> Bool {
- guard !isSyncing else { return false }
- isSyncing = true
- lastErrorMessage = nil
- completedCount = 0
- totalCount = sessions.filter {
- ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
- }.count
- defer { isSyncing = false }
- do {
- let remoteSessions = try await service.fetchSessions()
- let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
- for (remote, local) in mergedSessions {
- try await downloadMissingAssets(for: local, remote: remote)
- }
- try modelContext.save()
- let eligible = sessions.filter {
- ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
- }
- var failures: [String] = []
- for session in eligible {
- session.ownerUserID = userID
- session.syncState = .syncing
- session.lastSyncError = nil
- do {
- let remote = try await service.syncSession(session)
- session.cloudSessionId = remote.id
- session.serverRevision = remote.revision
- try await uploadLocalAssets(for: session, remote: remote)
- session.isSynced = true
- session.syncState = .synced
- session.lastSyncedAt = Date()
- completedCount += 1
- } catch {
- session.isSynced = false
- session.syncState = .failed
- session.lastSyncError = error.localizedDescription
- failures.append("\(session.title):\(error.localizedDescription)")
- }
- try modelContext.save()
- }
- guard failures.isEmpty else {
- lastErrorMessage = failures.joined(separator: "\n")
- return false
- }
- try await service.recordSyncCheckpoint()
- let now = Date()
- lastSyncDate = now
- UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
- return true
- } catch {
- lastErrorMessage = error.localizedDescription
- return false
- }
- }
- private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
- guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
- let existingIDs = Set((remote.assets ?? []).map(\.clientId))
- if let path = session.localAudioPath {
- guard let url = AudioPathHelper.resolveURL(for: path) else {
- throw APIError.transport("本地录音文件不存在:\(path)")
- }
- let clientID = "\(session.id.uuidString)-audio"
- if !existingIDs.contains(clientID) {
- _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url)
- }
- }
- for event in session.events where event.eventType == "PHOTO" {
- guard let path = event.localFilePath else { continue }
- guard let url = AudioPathHelper.resolveURL(for: path) else {
- throw APIError.transport("照片文件不存在:\(path)")
- }
- let clientID = "\(event.id.uuidString)-photo"
- if !existingIDs.contains(clientID) {
- _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url)
- }
- }
- }
- private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
- let assets = remote.assets ?? []
- if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil),
- let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) {
- let destination = downloadDestination(for: audio)
- try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
- session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
- }
- let photoAssets = Dictionary(uniqueKeysWithValues: assets
- .filter { $0.kind.uppercased() == "PHOTO" }
- .map { ($0.clientId.lowercased(), $0) })
- for event in session.events where event.eventType == "PHOTO" {
- guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
- let key = "\(event.id.uuidString)-photo".lowercased()
- guard let asset = photoAssets[key] else { continue }
- let destination = downloadDestination(for: asset)
- try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
- event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
- }
- }
- private func downloadDestination(for asset: RemoteAsset) -> URL {
- let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- let fileExtension = (asset.fileName as NSString).pathExtension
- let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
- return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
- }
- private func merge(
- _ remoteSessions: [RemoteSession],
- into localSessions: [CelestiaSession],
- modelContext: ModelContext,
- userID: String
- ) -> [(RemoteSession, CelestiaSession)] {
- var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
- var merged: [(RemoteSession, CelestiaSession)] = []
- for remote in remoteSessions {
- guard let clientID = remote.clientId?.lowercased() else { continue }
- if let local = byClientID[clientID] {
- if remote.deletedAt != nil, local.isSynced {
- modelContext.delete(local)
- continue
- }
- if local.isSynced, remote.revision > local.serverRevision {
- apply(remote, to: local, userID: userID)
- }
- if remote.deletedAt == nil {
- merged.append((remote, local))
- }
- } else if remote.deletedAt == nil {
- let id = UUID(uuidString: clientID) ?? UUID()
- let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
- apply(remote, to: session, userID: userID)
- modelContext.insert(session)
- byClientID[clientID] = session
- merged.append((remote, session))
- }
- }
- try? modelContext.save()
- return merged
- }
- private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
- local.title = remote.title
- local.startTime = remote.startTime
- local.endTime = remote.endTime
- local.cloudSessionId = remote.id
- local.ownerUserID = userID
- local.serverRevision = remote.revision
- local.isSynced = true
- local.syncState = .synced
- local.lastSyncError = nil
- local.events.removeAll()
- for remoteEvent in remote.events ?? [] {
- let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
- let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
- event.textContent = remoteEvent.textContent
- event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
- event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
- local.events.append(event)
- }
- }
- }
|