import Alamofire import Foundation import LilithAgentCore import LilithLogging import MacSyncShared // ContentTypeMapping, ActivityLog, macSyncResolveServerURL import SwiftyJSON private let log = AppLogger.logger(for: "IMessage.BlobSync") // MARK: - Response types private struct MissingAttachmentItem { let id: String let externalId: String let filename: String? let transferName: String? let mimeType: String? let size: Int? } // MARK: - BlobSyncManager /// Uploads raw attachment blobs for iMessage attachments that were synced metadata-only. /// /// Runs after initial message sync completes. Fetches the list of missing blobs from the /// server in pages, locates each file on disk via its filename/transferName path, and /// PUTs the raw bytes to the server using multipart upload. final class BlobSyncManager: @unchecked Sendable { private let apiClient: APIClient private let activityLog: ActivityLog private let pageSize = 200 init(apiClient: APIClient, activityLog: ActivityLog) { self.apiClient = apiClient self.activityLog = activityLog } // MARK: - Public API func syncBlobs() async { log.info("BlobSyncManager: starting blob sync pass") await MainActor.run { activityLog.info("Syncing attachment blobs...") } var totalUploaded = 0 var totalSkipped = 0 var totalFailed = 0 var totalQueued = 0 var page = 0 while true { let items: [MissingAttachmentItem] do { items = try await fetchMissingPage() } catch { log.warning("BlobSyncManager: failed to fetch missing list: \(error)") await MainActor.run { activityLog.warning("Blob sync: failed to fetch missing list — \(error.localizedDescription)") } break } if items.isEmpty { log.info("BlobSyncManager: no more missing blobs (page=\(page))") break } page += 1 log.info("BlobSyncManager: page \(page), uploading \(items.count) blobs") let uploadedBefore = totalUploaded for item in items { guard let (data, contentType) = readAttachmentFile(item: item, queuedCount: &totalQueued) else { totalSkipped += 1 continue } do { let storageKind = try await uploadBlob(id: item.id, data: data, contentType: contentType) if storageKind == "missing" { totalSkipped += 1 log.info("BlobSyncManager: oversized (>25MB), kept missing: \(item.externalId)") } else { totalUploaded += 1 } } catch { totalFailed += 1 log.warning("BlobSyncManager: upload failed for \(item.externalId) (id=\(item.id)): \(error)") } } if items.count < pageSize { break } // No cursor on the missing-attachments endpoint: a full page that // produced zero successful uploads returns identically on the next // fetch (failed uploads don't shrink the missing set). Stop the pass // to avoid an infinite re-fetch loop that wedges sync; next pass retries. if totalUploaded == uploadedBefore { log.warning("BlobSyncManager: full page, 0 uploads — stopping pass to avoid re-fetch loop") await MainActor.run { activityLog.warning("Blob sync: backend not accepting uploads — stopping pass") } break } } let queuedNote = totalQueued > 0 ? ", \(totalQueued) queued for iCloud download" : "" let summary = "Blob sync complete: \(totalUploaded) uploaded, \(totalSkipped) skipped, \(totalFailed) failed\(queuedNote)" log.info("BlobSyncManager: \(summary)") await MainActor.run { activityLog.success(summary) } } // MARK: - Private helpers private func fetchMissingPage() async throws -> [MissingAttachmentItem] { let data = try await apiClient.authenticatedRequest( "/client/imessage/attachments/missing?limit=\(pageSize)", method: .get ) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return json["data"]["items"].arrayValue.map { item in MissingAttachmentItem( id: item["id"].stringValue, externalId: item["externalId"].stringValue, filename: item["filename"].string, transferName: item["transferName"].string, mimeType: item["mimeType"].string, size: item["size"].int ) } } private func readAttachmentFile(item: MissingAttachmentItem, queuedCount: inout Int) -> (Data, String)? { let candidates: [String?] = [item.filename, item.transferName] let homeDir = FileManager.default.homeDirectoryForCurrentUser.path let fm = FileManager.default for candidate in candidates.compactMap({ $0 }) { guard ContentTypeMapping.isAllowedAttachmentPath(candidate, homeDirectory: homeDir) else { log.warning("BlobSyncManager: path outside allowed directory, skipping: \(candidate)") continue } let expandedPath = candidate.hasPrefix("~/") ? homeDir + candidate.dropFirst(1) : candidate let fileURL = URL(fileURLWithPath: expandedPath) if fm.fileExists(atPath: expandedPath) { do { let fileData = try Data(contentsOf: fileURL) let contentType = item.mimeType ?? mimeTypeForPath(expandedPath) return (fileData, contentType) } catch { log.warning("BlobSyncManager: could not read \(expandedPath): \(error)") } } else if let icloudURL = icloudPlaceholderURL(for: fileURL), fm.fileExists(atPath: icloudURL.path) { // File is offloaded to iCloud — queue download so next sync pass can upload it do { try fm.startDownloadingUbiquitousItem(at: fileURL) queuedCount += 1 log.info("BlobSyncManager: queued iCloud download for \(expandedPath)") } catch { log.warning("BlobSyncManager: could not queue iCloud download for \(expandedPath): \(error)") } } } log.info("BlobSyncManager: attachment file not found on disk: \(item.externalId)") return nil } /// Returns the `.filename.ext.icloud` placeholder URL for a given file URL, or nil if not applicable. private func icloudPlaceholderURL(for fileURL: URL) -> URL? { let dir = fileURL.deletingLastPathComponent() let name = fileURL.lastPathComponent let placeholder = dir.appendingPathComponent(".\(name).icloud") return placeholder } private func uploadBlob(id: String, data: Data, contentType: String) async throws -> String { let path = "/client/imessage/attachments/\(id)/blob" let tokenPresent = ((try? apiClient.getAuthToken()) ?? nil) != nil log.info("BlobSyncManager: uploadBlob id=\(id, privacy: .public) bytes=\(data.count) tokenPresent=\(tokenPresent)") let responseData: Data do { responseData = try await apiClient.authenticatedMultipartUpload(path) { form in form.append(data, withName: "blob", fileName: "blob", mimeType: contentType) } } catch { let ns = error as NSError log.warning("BlobSyncManager: uploadBlob error id=\(id, privacy: .public) path=\(path, privacy: .public) domain=\(ns.domain, privacy: .public) code=\(ns.code) desc=\(error.localizedDescription, privacy: .public)") throw error } let json = JSON(responseData) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return json["data"]["storageKind"].stringValue } private func mimeTypeForPath(_ path: String) -> String { let ext = (path as NSString).pathExtension.lowercased() return ContentTypeMapping.contentType(forExtension: ext) } }