merge batch 2: imessage, iphoto, ContentTypeMapping/ConfigFile already-superset

This commit is contained in:
quinn 2026-05-15 18:06:23 -07:00
parent cc1d40f7f6
commit bf3879feba
4 changed files with 331 additions and 314 deletions

View file

@ -9,7 +9,7 @@ private let log = AppLogger.logger(for: "IMessage.API")
// MARK: - Protocol // MARK: - Protocol
protocol APIClientProtocol: AnyObject { protocol APIClientProtocol: AnyObject, Sendable {
var isAuthenticated: Bool { get } var isAuthenticated: Bool { get }
func syncMessages(_ payload: SyncMessagesPayload) async throws -> Int func syncMessages(_ payload: SyncMessagesPayload) async throws -> Int
func syncMessagesBatch(_ payloads: [SyncMessagesPayload]) async throws -> Int func syncMessagesBatch(_ payloads: [SyncMessagesPayload]) async throws -> Int
@ -269,7 +269,7 @@ struct SyncContactPayload: Encodable {
} }
} }
struct PendingSendMessage { struct PendingSendMessage: Sendable {
let id: String let id: String
let phoneNumber: String let phoneNumber: String
let body: String let body: String

View file

@ -291,30 +291,36 @@ class iMessageReader: MessageReaderProtocol {
let deliveredUnix: TimeInterval? = row["date_delivered_unix"] let deliveredUnix: TimeInterval? = row["date_delivered_unix"]
let readUnix: TimeInterval? = row["date_read_unix"] let readUnix: TimeInterval? = row["date_read_unix"]
let rawText: String? = row["text"]
let rawAttributedBody: Data? = row["attributedBody"]
var attributedBodyBase64: String? var attributedBodyBase64: String?
// Prefer the text column; fall back to decoding attributedBody (NSKeyedArchiver) for var extractedText: String?
// newer messages where Apple stores the string only in the attributed blob. if let attributedBodyData: Data = row["attributedBody"] {
var resolvedText: String? = rawText.flatMap { $0.isEmpty ? nil : $0 } attributedBodyBase64 = attributedBodyData.base64EncodedString()
// Try NSKeyedUnarchiver first when the blob is a real
if let bodyData = rawAttributedBody { // archived NSAttributedString it gives us the cleanest
attributedBodyBase64 = bodyData.base64EncodedString() // text. Fall back to the heuristic typedstream scan
if resolvedText == nil || resolvedText!.isEmpty { // (`extractTextFromAttributedBody`) when it isn't, which
if let decoded = try? NSKeyedUnarchiver.unarchivedObject( // is the common case for modern URL bubbles / reactions.
ofClass: NSAttributedString.self, from: bodyData if let decoded = try? NSKeyedUnarchiver.unarchivedObject(
) { ofClass: NSAttributedString.self, from: attributedBodyData
let s = decoded.string.trimmingCharacters(in: .whitespacesAndNewlines) ) {
if !s.isEmpty { resolvedText = s } let s = decoded.string.trimmingCharacters(in: .whitespacesAndNewlines)
} if !s.isEmpty { extractedText = s }
// Secondary heuristic fallback if NSKeyedUnarchiver fails. }
if resolvedText == nil || resolvedText!.isEmpty { if extractedText == nil || extractedText!.isEmpty {
resolvedText = self.extractTextFromAttributedBody(bodyData) extractedText = self.extractTextFromAttributedBody(attributedBodyData)
}
} }
} }
// Prefer the chat.db `text` field; fall back to text recovered
// from attributedBody when the row's text is null or empty
// (common for URL bubbles, reactions, expressive sends).
let rawText: String? = row["text"]
let effectiveText: String? = {
if let raw = rawText, !raw.isEmpty { return raw }
if let extracted = extractedText, !extracted.isEmpty { return extracted }
return rawText
}()
let senderId: String? = row["sender_id"] let senderId: String? = row["sender_id"]
var senderDisplayName: String? var senderDisplayName: String?
var senderPhoneNumber: String? var senderPhoneNumber: String?
@ -340,7 +346,7 @@ class iMessageReader: MessageReaderProtocol {
date: Date(timeIntervalSince1970: dateUnix), date: Date(timeIntervalSince1970: dateUnix),
dateDelivered: deliveredUnix.map { Date(timeIntervalSince1970: $0) }, dateDelivered: deliveredUnix.map { Date(timeIntervalSince1970: $0) },
dateRead: readUnix.map { Date(timeIntervalSince1970: $0) }, dateRead: readUnix.map { Date(timeIntervalSince1970: $0) },
text: resolvedText, text: effectiveText,
attributedBody: attributedBodyBase64, attributedBody: attributedBodyBase64,
associatedMessageType: row["associated_message_type"], associatedMessageType: row["associated_message_type"],
associatedMessageGuid: row["associated_message_guid"], associatedMessageGuid: row["associated_message_guid"],
@ -387,86 +393,139 @@ class iMessageReader: MessageReaderProtocol {
} }
} }
/// Decoder for the iMessage `attributedBody` NSKeyedArchiver typedstream
/// blob. Mirror of `src/server/src/shared/typedstream/decode.ts` both
/// sides must agree on which messages yield non-null text.
///
/// Strategy:
/// 1. Scan every "NSString" class-definition marker; at each, read a
/// length-prefixed UTF-8 run after the 5-byte padding. Keep the
/// longest valid extraction (handles repeated markers via
/// back-references / appended attribute dicts).
/// 2. Fallback: longest printable byte run that contains at least one
/// letter and is not a known typedstream class-name marker.
///
/// Length prefixes:
/// - 0x00..0x7F that byte is the length
/// - 0x81 LO HI 16-bit little-endian
/// - 0x82 B0..B3 32-bit little-endian
func extractTextFromAttributedBody(_ data: Data) -> String? { func extractTextFromAttributedBody(_ data: Data) -> String? {
guard !data.isEmpty else { return nil } guard !data.isEmpty else { return nil }
let bytes = [UInt8](data) let bytes = [UInt8](data)
let marker: [UInt8] = [78, 83, 83, 116, 114, 105, 110, 103] // "NSString" if let text = bestNSStringRun(in: bytes) { return text }
return longestPrintableRun(in: bytes)
}
if let markerIndex = findSubsequence(in: bytes, subsequence: marker) { private static let nsStringMarker: [UInt8] = [78, 83, 83, 116, 114, 105, 110, 103] // "NSString"
let contentStart = markerIndex + 8 + 5 private static let nsStringPadding: Int = 5
if contentStart < bytes.count { private static let typedstreamMarkerWords: Set<String> = [
let lengthByte = bytes[contentStart] "NSString", "NSMutableString",
let textStart: Int "NSAttributedString", "NSMutableAttributedString",
let textLength: Int "NSDictionary", "NSMutableDictionary",
"NSArray", "NSMutableArray",
"NSNumber", "NSObject", "NSValue", "NSData", "NSDate",
"streamtyped"
]
if lengthByte == 0x81 { private func bestNSStringRun(in bytes: [UInt8]) -> String? {
if contentStart + 3 <= bytes.count { let marker = Self.nsStringMarker
textLength = Int(bytes[contentStart + 1]) | (Int(bytes[contentStart + 2]) << 8) var best: String?
textStart = contentStart + 3 var bestLen = 0
} else { var searchFrom = 0
return nil
}
} else {
textLength = Int(lengthByte)
textStart = contentStart + 1
}
if textStart + textLength <= bytes.count && textLength > 0 { while searchFrom <= bytes.count - marker.count {
let textData = Data(bytes[textStart..<(textStart + textLength)]) guard let idx = findSubsequence(in: bytes, subsequence: marker, from: searchFrom) else { break }
if let text = String(data: textData, encoding: .utf8) { if let candidate = readNSStringRun(in: bytes, markerIndex: idx), candidate.count > bestLen {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) best = candidate
if !trimmed.isEmpty { return trimmed } bestLen = candidate.count
}
}
} }
searchFrom = idx + 1
} }
var longestText: String? return best
}
private func readNSStringRun(in bytes: [UInt8], markerIndex: Int) -> String? {
let contentStart = markerIndex + Self.nsStringMarker.count + Self.nsStringPadding
guard contentStart < bytes.count else { return nil }
guard let len = readVarLength(in: bytes, at: contentStart), len.length > 0 else { return nil }
let end = len.nextOffset + len.length
guard end <= bytes.count else { return nil }
let slice = Data(bytes[len.nextOffset..<end])
guard let text = String(data: slice, encoding: .utf8) else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private struct VarLength { let length: Int; let nextOffset: Int }
private func readVarLength(in bytes: [UInt8], at offset: Int) -> VarLength? {
guard offset < bytes.count else { return nil }
let tag = bytes[offset]
if tag < 0x81 {
return VarLength(length: Int(tag), nextOffset: offset + 1)
}
if tag == 0x81 {
guard offset + 3 <= bytes.count else { return nil }
let length = Int(bytes[offset + 1]) | (Int(bytes[offset + 2]) << 8)
return VarLength(length: length, nextOffset: offset + 3)
}
if tag == 0x82 {
guard offset + 5 <= bytes.count else { return nil }
let b0 = UInt32(bytes[offset + 1])
let b1 = UInt32(bytes[offset + 2])
let b2 = UInt32(bytes[offset + 3])
let b3 = UInt32(bytes[offset + 4])
let length = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
return VarLength(length: Int(length), nextOffset: offset + 5)
}
return nil
}
private func longestPrintableRun(in bytes: [UInt8]) -> String? {
var longest: String?
var currentRun = Data() var currentRun = Data()
let flush: (inout Data, inout String?) -> Void = { run, longest in
guard run.count > 3 else { run = Data(); return }
if let str = String(data: run, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longest?.count ?? 0)
&& !Self.typedstreamMarkerWords.contains(trimmed)
&& !trimmed.hasPrefix("NS")
&& !trimmed.hasPrefix("__")
&& !trimmed.contains("attributedString") {
longest = trimmed
}
}
run = Data()
}
for byte in bytes { for byte in bytes {
if (byte >= 0x20 && byte <= 0x7E) || byte >= 0x80 || byte == 0x0A || byte == 0x0D { if (byte >= 0x20 && byte <= 0x7E) || byte >= 0x80 || byte == 0x0A || byte == 0x0D {
currentRun.append(byte) currentRun.append(byte)
} else { } else {
if currentRun.count > 3 { flush(&currentRun, &longest)
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) &&
!trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
currentRun = Data()
} }
} }
flush(&currentRun, &longest)
if currentRun.count > 3 { return longest
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) &&
!trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
return longestText
} }
func findSubsequence(in array: [UInt8], subsequence: [UInt8]) -> Int? { func findSubsequence(in array: [UInt8], subsequence: [UInt8], from: Int = 0) -> Int? {
guard subsequence.count <= array.count else { return nil } guard subsequence.count <= array.count else { return nil }
return (0...(array.count - subsequence.count)) let start = max(0, from)
let last = array.count - subsequence.count
guard start <= last else { return nil }
return (start...last)
.first { Array(array[$0..<($0 + subsequence.count)]) == subsequence } .first { Array(array[$0..<($0 + subsequence.count)]) == subsequence }
} }
} }
enum iMessageError: LocalizedError { enum iMessageError: LocalizedError {

View file

@ -7,7 +7,7 @@ import MacSyncShared
private let log = AppLogger.logger(for: "IMessage.Sync") private let log = AppLogger.logger(for: "IMessage.Sync")
struct SyncStats: Equatable { struct SyncStats: Equatable, Sendable {
var messageCount: Int = 0 var messageCount: Int = 0
var conversationCount: Int = 0 var conversationCount: Int = 0
var contactCount: Int = 0 var contactCount: Int = 0
@ -24,7 +24,7 @@ struct ContactSyncInfo {
var hasContactsPermission: Bool = false var hasContactsPermission: Bool = false
} }
enum SyncError: Equatable { enum SyncError: Equatable, Sendable {
case none case none
case fullDiskAccessRequired case fullDiskAccessRequired
case databaseNotFound case databaseNotFound
@ -41,16 +41,13 @@ enum SyncError: Equatable {
} }
@MainActor @MainActor
class SyncManager: ObservableObject { final class SyncManager: BaseSyncManager<SyncStats, SyncError> {
static let shared = SyncManager() static let shared = SyncManager()
static let syncSchemaVersion = 3 static let syncSchemaVersion = 3
@Published var isSyncing = false // Module-specific @Published state (base owns isSyncing/lastSyncCompletedAt/
@Published var lastSync: Date? // currentOperation/syncError/stats).
@Published var lastSyncCompletedAt: Date?
@Published var stats = SyncStats()
@Published var syncError: SyncError = .none
@Published var contactSyncInfo = ContactSyncInfo() @Published var contactSyncInfo = ContactSyncInfo()
@Published var isResetting = false @Published var isResetting = false
@Published var isResyncInProgress = false @Published var isResyncInProgress = false
@ -60,56 +57,118 @@ class SyncManager: ObservableObject {
private let sendService = SendService.shared private let sendService = SendService.shared
private let activityLog = ActivityLog.shared private let activityLog = ActivityLog.shared
private let blobSyncManager: BlobSyncManager private let blobSyncManager: BlobSyncManager
private var syncTimer: Timer?
private var blobSyncTimer: Timer? /// First-cycle bootstrap (DB connect, schema-version reset, contacts load) is
/// done lazily inside `performSync` since the base's `startSync` is final.
private var didBootstrap = false
/// True until the first inbound cycle has chosen a code path (initial batch
/// vs incremental). Reset to true after a `resetAndResync`.
private var isInitialLoad = true private var isInitialLoad = true
/// Separate timer for blob uploads. Runs every 5 minutes, independent of
/// the inbound read cycle and the outbound send queue.
private var blobSyncTimer: Timer?
/// Outbound send-queue runner. Polls the server's legacy
/// `icloud.send_queue` table via `IMessageSendTransport`, delegates the
/// actual AppleScript send + rate-limit + delivery-tracking to
/// `SendService.shared`, and acks the result. Owns its own 30s timer,
/// independent of the read cycle, so an outbound queue with backlog
/// drains promptly even when no inbound sync runs.
private lazy var sendQueueClient: SendQueueClient<IMessageSendTransport> = {
let transport = IMessageSendTransport(apiClient: apiClient)
let service = sendService
let activity = activityLog
return SendQueueClient(label: "imessage", transport: transport, interval: 30) { message in
let result = service.send(recipient: message.phoneNumber, body: message.body)
let suffix = String(message.phoneNumber.prefix(4))
if result.success {
activity.success("Sent message to \(suffix)...")
return .sent
} else {
let reason = result.error ?? "Unknown error"
activity.error("Failed to send to \(suffix)...: \(reason)")
return .failed(reason: reason)
}
}
}()
private init() { private init() {
self.blobSyncManager = BlobSyncManager(apiClient: APIClient.shared, activityLog: ActivityLog.shared) self.blobSyncManager = BlobSyncManager(apiClient: APIClient.shared, activityLog: ActivityLog.shared)
lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date
lastSyncCompletedAt = UserDefaults.standard.object(forKey: "lastSyncCompletedAt") as? Date
log.info("init - lastSync: \(String(describing: self.lastSync))")
}
private func checkSchemaVersionAndReset() async -> Bool { super.init(
let storedVersion = UserDefaults.standard.integer(forKey: "syncSchemaVersion") initialStats: SyncStats(),
let currentVersion = Self.syncSchemaVersion noError: .none,
persistenceKey: "imessage",
timerInterval: 30
)
log.info("Schema version check - stored: \(storedVersion), current: \(currentVersion)") // Migrate legacy UserDefaults watermark keys (one-time). iMessage used
// unprefixed `lastSync` / `lastSyncCompletedAt`.
if storedVersion != currentVersion { if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "lastSync") as? Date {
if storedVersion > 0 { await performReset() } setLastSync(legacy)
UserDefaults.standard.set(currentVersion, forKey: "syncSchemaVersion") UserDefaults.standard.removeObject(forKey: "lastSync")
return storedVersion > 0 }
if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "lastSyncCompletedAt") as? Date {
lastSyncCompletedAt = legacy
UserDefaults.standard.set(legacy, forKey: "imessage.lastSyncCompletedAt")
UserDefaults.standard.removeObject(forKey: "lastSyncCompletedAt")
} }
return false
} }
func startSync() { func openFullDiskAccessSettings() {
log.info("startSync called") if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") {
activityLog.info("Starting sync service...") NSWorkspace.shared.open(url)
syncError = .none }
}
do { func retryConnection() { startSync() }
try imessageReader.connect()
log.info("Connected to iMessage database") // MARK: - Lifecycle hooks
activityLog.success("Connected to iMessage database")
} catch { override func didStartSync() {
let errorMsg = error.localizedDescription.lowercased() sendQueueClient.start()
if errorMsg.contains("authorization denied") || errorMsg.contains("error 23") { // Blob sync on its own 5-minute cadence, independent of read cycle.
syncError = .fullDiskAccessRequired blobSyncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
activityLog.error("Full Disk Access required") guard let self else { return }
} else if errorMsg.contains("not found") { Task { await self.blobSyncManager.syncBlobs() }
syncError = .databaseNotFound }
activityLog.error("iMessage database not found") }
} else {
syncError = .connectionFailed(error.localizedDescription) override func willStopSync() {
activityLog.error("Connection failed: \(error.localizedDescription)") sendQueueClient.stop()
blobSyncTimer?.invalidate()
blobSyncTimer = nil
}
// MARK: - Sync cycle
override func performSync() async {
// Lazy one-shot bootstrap: DB connect, schema version reset, contacts.
if !didBootstrap {
activityLog.info("Starting sync service...")
syncError = .none
do {
try imessageReader.connect()
log.info("Connected to iMessage database")
activityLog.success("Connected to iMessage database")
} catch {
let errorMsg = error.localizedDescription.lowercased()
if errorMsg.contains("authorization denied") || errorMsg.contains("error 23") {
syncError = .fullDiskAccessRequired
activityLog.error("Full Disk Access required")
} else if errorMsg.contains("not found") {
syncError = .databaseNotFound
activityLog.error("iMessage database not found")
} else {
syncError = .connectionFailed(error.localizedDescription)
activityLog.error("Connection failed: \(error.localizedDescription)")
}
return
} }
return
}
Task {
let didReset = await checkSchemaVersionAndReset() let didReset = await checkSchemaVersionAndReset()
if didReset { activityLog.info("Schema version updated, resync required") } if didReset { activityLog.info("Schema version updated, resync required") }
@ -123,58 +182,37 @@ class SyncManager: ObservableObject {
activityLog.warning("Contacts permission not granted - names won't sync") activityLog.warning("Contacts permission not granted - names won't sync")
} }
if isInitialLoad && stats.messageCount == 0 { await fetchStats()
log.info("Initial load detected - using batch sync") didBootstrap = true
activityLog.info("First sync detected - using optimized batch endpoint")
await performInitialSync()
isInitialLoad = false
} else {
isInitialLoad = false
await performSync()
}
// Blob pass runs whenever there are missing attachment blobs, regardless
// of whether this was an initial or incremental sync.
await blobSyncManager.syncBlobs()
} }
syncTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in // Outbound send queue used to be invoked inline here. It now runs on
Task { @MainActor in self?.syncNow() } // its own 30s timer via `sendQueueClient` started from
} // `didStartSync()`. We still flush once at the top of every read
blobSyncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in // cycle so a manual `syncNow()` drains pending sends promptly.
guard let self else { return } await sendQueueClient.drainOnce()
Task { await self.blobSyncManager.syncBlobs() }
}
activityLog.info("Scheduled automatic sync every 30s, blob sync every 5m")
}
func stopSync() { // Initial-load path: server has nothing, stream a metadata-only batch
syncTimer?.invalidate() // import (attachments without blob bytes). Subsequent cycles use the
syncTimer = nil // standard incremental path with full attachment data.
blobSyncTimer?.invalidate() if isInitialLoad && stats.messageCount == 0 {
blobSyncTimer = nil log.info("Initial load detected - using batch sync")
} activityLog.info("First sync detected - using optimized batch endpoint")
await performInitialSync()
func openFullDiskAccessSettings() { isInitialLoad = false
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { } else {
NSWorkspace.shared.open(url) isInitialLoad = false
await runReadCycle()
} }
}
func retryConnection() { startSync() } // Blob pass runs after every read cycle; uploads any messages whose
// attachment bytes haven't been pushed yet.
func syncNow() { await blobSyncManager.syncBlobs()
guard !isSyncing else { return }
Task {
await processSendQueue()
await performSync()
}
} }
private func performInitialSync() async { private func performInitialSync() async {
log.info("performInitialSync starting - streaming batch mode") log.info("performInitialSync starting - streaming batch mode")
activityLog.info("Starting initial batch sync...") activityLog.info("Starting initial batch sync...")
isSyncing = true
var latestMessageDate: Date? var latestMessageDate: Date?
var totalSynced = 0 var totalSynced = 0
@ -186,7 +224,7 @@ class SyncManager: ObservableObject {
activityLog.info("Found \(conversations.count) conversations") activityLog.info("Found \(conversations.count) conversations")
// Stream in windows of maxConversationsPerBatch so we never hold // Stream in windows of maxConversationsPerBatch so we never hold
// the full 48k-message history in memory at once. // the full history in memory at once.
var window: [SyncMessagesPayload] = [] var window: [SyncMessagesPayload] = []
var windowMessages = 0 var windowMessages = 0
var convIndex = 0 var convIndex = 0
@ -293,14 +331,9 @@ class SyncManager: ObservableObject {
log.info("Initial sync complete - \(totalSynced) messages synced, \(totalFailed) conversations failed") log.info("Initial sync complete - \(totalSynced) messages synced, \(totalFailed) conversations failed")
if let newDate = latestMessageDate { if let newDate = latestMessageDate {
lastSync = newDate setLastSync(newDate)
UserDefaults.standard.set(newDate, forKey: "lastSync")
} }
let now = Date()
lastSyncCompletedAt = now
UserDefaults.standard.set(now, forKey: "lastSyncCompletedAt")
activityLog.success("Initial sync complete: \(totalSynced) messages synced") activityLog.success("Initial sync complete: \(totalSynced) messages synced")
await fetchStats() await fetchStats()
@ -309,19 +342,16 @@ class SyncManager: ObservableObject {
activityLog.error("Initial sync failed: \(error.localizedDescription)") activityLog.error("Initial sync failed: \(error.localizedDescription)")
} }
isSyncing = false
isResyncInProgress = false isResyncInProgress = false
} }
private func performSync() async { private func runReadCycle() async {
log.info("performSync starting") log.info("performSync starting")
activityLog.info("Starting sync...") activityLog.info("Starting sync...")
isSyncing = true
var runningMessageCount = stats.messageCount var runningMessageCount = stats.messageCount
var runningConversationCount = stats.conversationCount var runningConversationCount = stats.conversationCount
var conversationsWithNewMessages = Set<String>() var conversationsWithNewMessages = Set<String>()
var latestMessageDate: Date?
do { do {
let conversations = try imessageReader.getConversations() let conversations = try imessageReader.getConversations()
@ -339,12 +369,6 @@ class SyncManager: ObservableObject {
if messages.isEmpty { continue } if messages.isEmpty { continue }
for msg in messages {
if latestMessageDate == nil || msg.date > latestMessageDate! {
latestMessageDate = msg.date
}
}
let chunks = ChunkingStrategy.createAdaptiveChunks(messages) let chunks = ChunkingStrategy.createAdaptiveChunks(messages)
var conversationSynced = 0 var conversationSynced = 0
@ -381,16 +405,6 @@ class SyncManager: ObservableObject {
} }
} }
if totalSynced > 0 || failedConversations == 0 {
let newSyncTime = latestMessageDate ?? Date()
lastSync = newSyncTime
UserDefaults.standard.set(newSyncTime, forKey: "lastSync")
}
let now = Date()
lastSyncCompletedAt = now
UserDefaults.standard.set(now, forKey: "lastSyncCompletedAt")
if failedConversations > 0 { if failedConversations > 0 {
if totalSynced > 0 { if totalSynced > 0 {
activityLog.warning("Sync partial: \(totalSynced) messages, \(failedConversations) conversations failed") activityLog.warning("Sync partial: \(totalSynced) messages, \(failedConversations) conversations failed")
@ -410,10 +424,23 @@ class SyncManager: ObservableObject {
activityLog.error("Sync failed: \(error.localizedDescription)") activityLog.error("Sync failed: \(error.localizedDescription)")
} }
isSyncing = false
isResyncInProgress = false isResyncInProgress = false
} }
private func checkSchemaVersionAndReset() async -> Bool {
let storedVersion = UserDefaults.standard.integer(forKey: "syncSchemaVersion")
let currentVersion = Self.syncSchemaVersion
log.info("Schema version check - stored: \(storedVersion), current: \(currentVersion)")
if storedVersion != currentVersion {
if storedVersion > 0 { await performReset() }
UserDefaults.standard.set(currentVersion, forKey: "syncSchemaVersion")
return storedVersion > 0
}
return false
}
private func syncContacts() async { private func syncContacts() async {
activityLog.info("Syncing contacts...") activityLog.info("Syncing contacts...")
do { do {
@ -464,47 +491,13 @@ class SyncManager: ObservableObject {
contactCount: response.totalContacts contactCount: response.totalContacts
) )
if let serverLastSync = response.lastSyncAt, lastSync == nil { if let serverLastSync = response.lastSyncAt, lastSync == nil {
lastSync = serverLastSync setLastSync(serverLastSync)
UserDefaults.standard.set(serverLastSync, forKey: "lastSync")
} }
} catch { } catch {
log.warning("fetchStats failed: \(error.localizedDescription)") log.warning("fetchStats failed: \(error.localizedDescription)")
} }
} }
private func processSendQueue() async {
guard apiClient.isAuthenticated else { return }
do {
let pendingMessages = try await apiClient.getPendingSends()
if pendingMessages.isEmpty { return }
activityLog.info("Processing \(pendingMessages.count) queued message(s)...")
for message in pendingMessages {
let result = sendService.send(recipient: message.phoneNumber, body: message.body)
do {
try await apiClient.reportSendResult(
messageId: message.id,
status: result.success ? "sent" : "failed",
error: result.error
)
if result.success {
activityLog.success("Sent message to \(message.phoneNumber.prefix(4))...")
} else {
activityLog.error("Failed to send to \(message.phoneNumber.prefix(4))...: \(result.error ?? "Unknown error")")
}
} catch {
activityLog.warning("Failed to report send result: \(error.localizedDescription)")
}
}
} catch {
if case APIError.missingAuthToken = error { return }
log.warning("processSendQueue failed: \(error.localizedDescription)")
}
}
private func syncChunkWithRetry( private func syncChunkWithRetry(
_ chunk: [iMessage], _ chunk: [iMessage],
conversation: iMessageConversation conversation: iMessageConversation
@ -600,7 +593,8 @@ class SyncManager: ObservableObject {
let result = try await apiClient.resetSync() let result = try await apiClient.resetSync()
activityLog.info("Cleared \(result.deletedMessages) messages, \(result.deletedConversations) conversations") activityLog.info("Cleared \(result.deletedMessages) messages, \(result.deletedConversations) conversations")
UserDefaults.standard.removeObject(forKey: "lastSync") setLastSync(nil)
isInitialLoad = true
isResetting = false isResetting = false
await performSyncForResync() await performSyncForResync()
} catch { } catch {
@ -608,7 +602,7 @@ class SyncManager: ObservableObject {
activityLog.error("Reset failed: \(error.localizedDescription)") activityLog.error("Reset failed: \(error.localizedDescription)")
if let previous = previousLastSync { if let previous = previousLastSync {
UserDefaults.standard.set(previous, forKey: "lastSync") setLastSync(previous)
} }
isResetting = false isResetting = false
isResyncInProgress = false isResyncInProgress = false
@ -672,10 +666,9 @@ class SyncManager: ObservableObject {
} }
let newSyncTime = Date() let newSyncTime = Date()
lastSync = newSyncTime setLastSync(newSyncTime)
lastSyncCompletedAt = newSyncTime lastSyncCompletedAt = newSyncTime
UserDefaults.standard.set(newSyncTime, forKey: "lastSync") UserDefaults.standard.set(newSyncTime, forKey: "imessage.lastSyncCompletedAt")
UserDefaults.standard.set(newSyncTime, forKey: "lastSyncCompletedAt")
if failedConversations > 0 { if failedConversations > 0 {
activityLog.warning("Resync partial: \(totalSynced) messages, \(failedConversations) failed") activityLog.warning("Resync partial: \(totalSynced) messages, \(failedConversations) failed")

View file

@ -80,21 +80,11 @@ public enum IPhotoSyncError: Equatable, Sendable {
/// Upload concurrency is capped at `maxConcurrentUploads` (4) using `withTaskGroup` /// Upload concurrency is capped at `maxConcurrentUploads` (4) using `withTaskGroup`
/// with explicit batch windows so we never open more than 4 concurrent HTTP streams. /// with explicit batch windows so we never open more than 4 concurrent HTTP streams.
@MainActor @MainActor
public final class SyncManager: ObservableObject { public final class SyncManager: BaseSyncManager<IPhotoSyncStats, IPhotoSyncError> {
public static let shared = SyncManager() public static let shared = SyncManager()
// MARK: Public state (observed by MenuBar + local web server)
@Published public var isSyncing = false
@Published public var lastSync: Date?
@Published public var lastSyncCompletedAt: Date?
@Published public var stats = IPhotoSyncStats()
@Published public var syncError: IPhotoSyncError = .none
@Published public var currentOperation: String = ""
// MARK: Private
private let reader = PhotosLibraryReader.shared private let reader = PhotosLibraryReader.shared
private let apiClient = APIClient.shared private let apiClient = APIClient.shared
private var syncTimer: Timer?
/// Batch size for metadata sync 100 photos per request. /// Batch size for metadata sync 100 photos per request.
private let metadataBatchSize = 100 private let metadataBatchSize = 100
@ -102,57 +92,43 @@ public final class SyncManager: ObservableObject {
private let maxConcurrentUploads = 4 private let maxConcurrentUploads = 4
private init() { private init() {
lastSync = UserDefaults.standard.object(forKey: "iphotoLastSync") as? Date super.init(
lastSyncCompletedAt = UserDefaults.standard.object(forKey: "iphotoLastSyncCompletedAt") as? Date initialStats: IPhotoSyncStats(),
log.info("init lastSync=\(String(describing: self.lastSync))") noError: .none,
} persistenceKey: "iphoto",
timerInterval: 300
)
// MARK: - Lifecycle // Migrate legacy UserDefaults watermark keys (one-time).
if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "iphotoLastSync") as? Date {
public func startSync() { setLastSync(legacy)
log.info("startSync called") UserDefaults.standard.removeObject(forKey: "iphotoLastSync")
syncError = .none
Task {
let authorized = await reader.requestAuthorization()
guard authorized else {
log.warning("Photos access denied")
syncError = .photosAccessRequired
return
}
await performSync()
} }
if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "iphotoLastSyncCompletedAt") as? Date {
syncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in lastSyncCompletedAt = legacy
Task { @MainActor in self?.syncNow() } UserDefaults.standard.set(legacy, forKey: "iphoto.lastSyncCompletedAt")
} UserDefaults.standard.removeObject(forKey: "iphotoLastSyncCompletedAt")
log.info("Scheduled automatic sync every 5 minutes")
}
public func stopSync() {
syncTimer?.invalidate()
syncTimer = nil
}
public func syncNow() {
guard !isSyncing else { return }
Task {
if !reader.isAccessible {
let ok = await reader.requestAuthorization()
guard ok else {
syncError = .photosAccessRequired
return
}
}
await performSync()
} }
} }
// MARK: - Authorization hooks
public override func isAuthorized() async -> Bool { reader.isAccessible }
public override func requestAuthorization() async -> Bool {
await reader.requestAuthorization()
}
public override func onAuthorizationDenied() {
syncError = .photosAccessRequired
}
// MARK: - Module-specific entry points
public func forceFullResync() { public func forceFullResync() {
guard !isSyncing else { return } guard !isSyncing else { return }
log.info("Forcing full resync") log.info("Forcing full resync")
lastSync = nil setLastSync(nil)
UserDefaults.standard.removeObject(forKey: "iphotoLastSync")
stats.failedBatches = 0 stats.failedBatches = 0
syncNow() syncNow()
} }
@ -160,15 +136,16 @@ public final class SyncManager: ObservableObject {
/// Upload only photos that have metadata but no binary on the server. /// Upload only photos that have metadata but no binary on the server.
public func uploadPending() { public func uploadPending() {
guard !isSyncing else { return } guard !isSyncing else { return }
Task { Task { [weak self] in
if !reader.isAccessible { guard let self else { return }
let ok = await reader.requestAuthorization() if !self.reader.isAccessible {
let ok = await self.reader.requestAuthorization()
guard ok else { guard ok else {
syncError = .photosAccessRequired self.syncError = .photosAccessRequired
return return
} }
} }
await performPendingUpload() await self.performPendingUpload()
} }
} }
@ -180,9 +157,8 @@ public final class SyncManager: ObservableObject {
// MARK: - Sync cycle // MARK: - Sync cycle
private func performSync() async { public override func performSync() async {
log.info("performSync starting") log.info("performSync starting")
isSyncing = true
currentOperation = "Fetching photos…" currentOperation = "Fetching photos…"
// Background diagnostic never blocks sync loop // Background diagnostic never blocks sync loop
@ -195,7 +171,6 @@ public final class SyncManager: ObservableObject {
if photos.isEmpty { if photos.isEmpty {
await updateStats() await updateStats()
isSyncing = false
currentOperation = "No new photos" currentOperation = "No new photos"
await performPendingUpload() await performPendingUpload()
return return
@ -242,13 +217,8 @@ public final class SyncManager: ObservableObject {
log.info("Batch \(idx + 1)/\(batches.count) synced=\(response.synced) needsUpload=\(response.needsUpload.count)") log.info("Batch \(idx + 1)/\(batches.count) synced=\(response.synced) needsUpload=\(response.needsUpload.count)")
} catch { } catch {
stats.failedBatches += 1 stats.failedBatches += 1
let msg = error.localizedDescription.lowercased() setConnectionError(error)
if msg.contains("network") || msg.contains("connection") || log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
msg.contains("timeout") || msg.contains("unreachable") {
syncError = .backendUnreachable
} else {
log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
}
} }
} }
@ -261,23 +231,15 @@ public final class SyncManager: ObservableObject {
currentOperation = "Syncing albums…" currentOperation = "Syncing albums…"
await syncAlbums() await syncAlbums()
let now = Date()
lastSync = now
lastSyncCompletedAt = now
UserDefaults.standard.set(now, forKey: "iphotoLastSync")
UserDefaults.standard.set(now, forKey: "iphotoLastSyncCompletedAt")
await updateStats() await updateStats()
currentOperation = "Sync complete" currentOperation = "Sync complete"
log.info("performSync complete") log.info("performSync complete")
isSyncing = false
await performPendingUpload() await performPendingUpload()
} }
private func performPendingUpload() async { private func performPendingUpload() async {
log.info("Checking pending uploads") log.info("Checking pending uploads")
isSyncing = true
currentOperation = "Checking pending uploads…" currentOperation = "Checking pending uploads…"
Task.detached { self.reader.checkAccessibility() } Task.detached { self.reader.checkAccessibility() }
@ -288,19 +250,16 @@ public final class SyncManager: ObservableObject {
guard !pending.isEmpty else { guard !pending.isEmpty else {
currentOperation = "No pending uploads" currentOperation = "No pending uploads"
isSyncing = false
return return
} }
currentOperation = "Uploading pending…" currentOperation = "Uploading pending…"
await uploadBatch(pending, label: "Pending upload") await uploadBatch(pending, label: "Pending upload")
await updateStats() await updateStats()
} catch { } catch {
log.warning("getPendingUploads failed: \(error.localizedDescription)") log.warning("getPendingUploads failed: \(error.localizedDescription)")
} }
isSyncing = false
currentOperation = "" currentOperation = ""
} }
@ -439,4 +398,10 @@ public final class SyncManager: ObservableObject {
log.warning("updateStats failed: \(error.localizedDescription)") log.warning("updateStats failed: \(error.localizedDescription)")
} }
} }
private func setConnectionError(_ error: Error) {
if SyncConnectionErrorHeuristic.isConnectionError(error) {
syncError = .backendUnreachable
}
}
} }