macsync/@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift
Natalie e5437a9baa fix(@applications/mac-sync): 🐛 update blob sync id handling
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-17 21:34:11 -07:00

187 lines
7.2 KiB
Swift

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")
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 }
}
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 responseData = try await apiClient.authenticatedMultipartUpload(path) { form in
form.append(data, withName: "blob", fileName: "blob", mimeType: contentType)
}
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)
}
}