2026-05-15 17:05:39 -07:00
|
|
|
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 {
|
2026-05-17 21:34:11 -07:00
|
|
|
let id: String
|
2026-05-15 17:05:39 -07:00
|
|
|
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")
|
|
|
|
|
|
2026-06-23 14:37:40 -04:00
|
|
|
let uploadedBefore = totalUploaded
|
2026-05-15 17:05:39 -07:00
|
|
|
for item in items {
|
|
|
|
|
guard let (data, contentType) = readAttachmentFile(item: item, queuedCount: &totalQueued) else {
|
|
|
|
|
totalSkipped += 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
2026-05-17 21:34:11 -07:00
|
|
|
let storageKind = try await uploadBlob(id: item.id, data: data, contentType: contentType)
|
2026-05-15 17:05:39 -07:00
|
|
|
if storageKind == "missing" {
|
|
|
|
|
totalSkipped += 1
|
|
|
|
|
log.info("BlobSyncManager: oversized (>25MB), kept missing: \(item.externalId)")
|
|
|
|
|
} else {
|
|
|
|
|
totalUploaded += 1
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
totalFailed += 1
|
2026-05-17 21:34:11 -07:00
|
|
|
log.warning("BlobSyncManager: upload failed for \(item.externalId) (id=\(item.id)): \(error)")
|
2026-05-15 17:05:39 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if items.count < pageSize { break }
|
2026-06-23 14:37:40 -04:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-05-15 17:05:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
2026-05-17 21:34:11 -07:00
|
|
|
id: item["id"].stringValue,
|
2026-05-15 17:05:39 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 21:34:11 -07:00
|
|
|
private func uploadBlob(id: String, data: Data, contentType: String) async throws -> String {
|
|
|
|
|
let path = "/client/imessage/attachments/\(id)/blob"
|
2026-05-15 17:05:39 -07:00
|
|
|
|
2026-05-17 22:23:05 -07:00
|
|
|
let tokenPresent = ((try? apiClient.getAuthToken()) ?? nil) != nil
|
2026-05-17 23:05:13 -07:00
|
|
|
log.info("BlobSyncManager: uploadBlob id=\(id, privacy: .public) bytes=\(data.count) tokenPresent=\(tokenPresent)")
|
2026-05-17 22:23:05 -07:00
|
|
|
|
|
|
|
|
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
|
2026-05-17 23:05:13 -07:00
|
|
|
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)")
|
2026-05-17 22:23:05 -07:00
|
|
|
throw error
|
2026-05-15 17:05:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|