merge batch 1: top-level metadata, shared, ical, imail
This commit is contained in:
parent
f4bd7e6829
commit
cc1d40f7f6
9 changed files with 311 additions and 142 deletions
|
|
@ -83,11 +83,13 @@ public struct ICalStatsResponse {
|
||||||
|
|
||||||
// MARK: - Protocol
|
// MARK: - Protocol
|
||||||
|
|
||||||
public protocol ICalAPIClientProtocol: AnyObject {
|
public protocol ICalAPIClientProtocol: AnyObject, Sendable {
|
||||||
var isAuthenticated: Bool { get }
|
var isAuthenticated: Bool { get }
|
||||||
func syncCalendars(_ payloads: [SyncCalendarPayload]) async throws -> Int
|
func syncCalendars(_ payloads: [SyncCalendarPayload]) async throws -> Int
|
||||||
func syncEvents(_ payloads: [SyncEventPayload]) async throws -> Int
|
func syncEvents(_ payloads: [SyncEventPayload]) async throws -> Int
|
||||||
func getStats() async throws -> ICalStatsResponse
|
func getStats() async throws -> ICalStatsResponse
|
||||||
|
func getPendingSends() async throws -> [PendingCalendarSend]
|
||||||
|
func reportSendResult(id: String, status: String, error: String?) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - APIClient
|
// MARK: - APIClient
|
||||||
|
|
@ -129,6 +131,44 @@ public final class APIClient: BaseAPIClient, ICalAPIClientProtocol, @unchecked S
|
||||||
return synced
|
return synced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Queue
|
||||||
|
|
||||||
|
public func getPendingSends() async throws -> [PendingCalendarSend] {
|
||||||
|
let data = try await authenticatedRequest("/client/ical/send-queue/pending", 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
|
||||||
|
PendingCalendarSend(
|
||||||
|
id: item["id"].stringValue,
|
||||||
|
action: item["action"].stringValue,
|
||||||
|
payload: CalendarSendPayload(
|
||||||
|
eventIdentifier: item["payload"]["eventIdentifier"].string,
|
||||||
|
calendarIdentifier: item["payload"]["calendarIdentifier"].string,
|
||||||
|
title: item["payload"]["title"].string,
|
||||||
|
notes: item["payload"]["notes"].string,
|
||||||
|
location: item["payload"]["location"].string,
|
||||||
|
startDate: item["payload"]["startDate"].string,
|
||||||
|
endDate: item["payload"]["endDate"].string,
|
||||||
|
isAllDay: item["payload"]["isAllDay"].bool,
|
||||||
|
url: item["payload"]["url"].string
|
||||||
|
),
|
||||||
|
createdAt: item["createdAt"].stringValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reportSendResult(id: String, status: String, error: String?) async throws {
|
||||||
|
var params: [String: Any] = ["status": status]
|
||||||
|
if let err = error { params["error"] = err }
|
||||||
|
let data = try await authenticatedRequest("/client/ical/send-queue/\(id)/result", method: .post, parameters: params)
|
||||||
|
let json = JSON(data)
|
||||||
|
guard json["success"].boolValue else {
|
||||||
|
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func getStats() async throws -> ICalStatsResponse {
|
public func getStats() async throws -> ICalStatsResponse {
|
||||||
let data = try await authenticatedRequest("/client/ical/stats", method: .get)
|
let data = try await authenticatedRequest("/client/ical/stats", method: .get)
|
||||||
let json = JSON(data)
|
let json = JSON(data)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ public final class CalendarReader: @unchecked Sendable {
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
/// Exposed for the Sender, which needs to call `save`/`remove` on the
|
||||||
|
/// same authorized store the Reader uses.
|
||||||
|
public var eventStore: EKEventStore { store }
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Authorization
|
||||||
|
|
||||||
public func requestAuthorization() async -> Bool {
|
public func requestAuthorization() async -> Bool {
|
||||||
|
|
|
||||||
|
|
@ -43,64 +43,66 @@ public enum ICalSyncError: Equatable, Sendable {
|
||||||
// MARK: - SyncManager
|
// MARK: - SyncManager
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class SyncManager: ObservableObject {
|
public final class SyncManager: BaseSyncManager<ICalSyncStats, ICalSyncError> {
|
||||||
public static let shared = SyncManager()
|
public static let shared = SyncManager()
|
||||||
|
|
||||||
@Published public var isSyncing = false
|
|
||||||
@Published public var lastSyncCompletedAt: Date?
|
|
||||||
@Published public var stats = ICalSyncStats()
|
|
||||||
@Published public var syncError: ICalSyncError = .none
|
|
||||||
@Published public var currentOperation: String = ""
|
|
||||||
|
|
||||||
public let reader = CalendarReader.shared
|
public let reader = CalendarReader.shared
|
||||||
private let apiClient = APIClient.shared
|
private let apiClient = APIClient.shared
|
||||||
private var syncTimer: Timer?
|
|
||||||
private var lastSync: Date?
|
|
||||||
|
|
||||||
private let eventBatchSize = 200
|
private let eventBatchSize = 200
|
||||||
|
|
||||||
|
// Outbound send queue (server → Mac calendar writes).
|
||||||
|
private lazy var sendQueueClient: SendQueueClient<CalendarSendTransport> = {
|
||||||
|
let transport = CalendarSendTransport(apiClient: apiClient)
|
||||||
|
let sender = CalendarSender(eventStore: reader.eventStore)
|
||||||
|
return SendQueueClient(
|
||||||
|
label: "ical",
|
||||||
|
transport: transport,
|
||||||
|
interval: 60
|
||||||
|
) { item in
|
||||||
|
await sender.apply(item)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
public override func didStartSync() {
|
||||||
|
sendQueueClient.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func willStopSync() {
|
||||||
|
sendQueueClient.stop()
|
||||||
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
lastSync = UserDefaults.standard.object(forKey: "icalLastSync") as? Date
|
super.init(
|
||||||
lastSyncCompletedAt = UserDefaults.standard.object(forKey: "icalLastSyncCompletedAt") as? Date
|
initialStats: ICalSyncStats(),
|
||||||
log.info("init lastSync=\(String(describing: self.lastSync))")
|
noError: .none,
|
||||||
|
persistenceKey: "ical",
|
||||||
|
timerInterval: 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migrate legacy UserDefaults watermark keys (one-time).
|
||||||
|
if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSync") as? Date {
|
||||||
|
setLastSync(legacy)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "icalLastSync")
|
||||||
|
}
|
||||||
|
if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSyncCompletedAt") as? Date {
|
||||||
|
lastSyncCompletedAt = legacy
|
||||||
|
UserDefaults.standard.set(legacy, forKey: "ical.lastSyncCompletedAt")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "icalLastSyncCompletedAt")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Authorization hooks
|
||||||
|
|
||||||
public func startSync() {
|
public override func isAuthorized() async -> Bool { reader.isAuthorized }
|
||||||
log.info("startSync called")
|
|
||||||
syncError = .none
|
|
||||||
|
|
||||||
Task {
|
public override func requestAuthorization() async -> Bool {
|
||||||
let authorized = await reader.requestAuthorization()
|
await reader.requestAuthorization()
|
||||||
guard authorized else {
|
|
||||||
log.warning("Calendar access denied")
|
|
||||||
syncError = .calendarAccessRequired
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await performSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
syncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in self?.syncNow() }
|
|
||||||
}
|
|
||||||
log.info("Scheduled automatic calendar sync every 5 minutes")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stopSync() {
|
public override func onAuthorizationDenied() {
|
||||||
syncTimer?.invalidate()
|
log.warning("Calendar access denied")
|
||||||
syncTimer = nil
|
syncError = .calendarAccessRequired
|
||||||
}
|
|
||||||
|
|
||||||
public func syncNow() {
|
|
||||||
guard !isSyncing else { return }
|
|
||||||
Task {
|
|
||||||
if !reader.isAuthorized {
|
|
||||||
let ok = await reader.requestAuthorization()
|
|
||||||
guard ok else { syncError = .calendarAccessRequired; return }
|
|
||||||
}
|
|
||||||
await performSync()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func openCalendarAccessSettings() {
|
public func openCalendarAccessSettings() {
|
||||||
|
|
@ -111,9 +113,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
|
|
||||||
|
|
||||||
// Phase 1: Calendars
|
// Phase 1: Calendars
|
||||||
currentOperation = "Syncing calendars…"
|
currentOperation = "Syncing calendars…"
|
||||||
|
|
@ -188,16 +189,9 @@ public final class SyncManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
lastSync = now
|
|
||||||
lastSyncCompletedAt = now
|
|
||||||
UserDefaults.standard.set(now, forKey: "icalLastSync")
|
|
||||||
UserDefaults.standard.set(now, forKey: "icalLastSyncCompletedAt")
|
|
||||||
|
|
||||||
await updateStats()
|
await updateStats()
|
||||||
currentOperation = "Sync complete"
|
currentOperation = "Sync complete"
|
||||||
log.info("performSync complete calendars=\(calendars.count) events=\(events.count)")
|
log.info("performSync complete calendars=\(calendars.count) events=\(events.count)")
|
||||||
isSyncing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateStats() async {
|
private func updateStats() async {
|
||||||
|
|
@ -212,9 +206,7 @@ public final class SyncManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setConnectionError(_ error: Error) {
|
private func setConnectionError(_ error: Error) {
|
||||||
let msg = error.localizedDescription.lowercased()
|
if SyncConnectionErrorHeuristic.isConnectionError(error) {
|
||||||
if msg.contains("network") || msg.contains("connection") ||
|
|
||||||
msg.contains("timeout") || msg.contains("unreachable") {
|
|
||||||
syncError = .backendUnreachable
|
syncError = .backendUnreachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,12 @@ public struct IMailStatsResponse {
|
||||||
|
|
||||||
// MARK: - Protocol
|
// MARK: - Protocol
|
||||||
|
|
||||||
public protocol IMailAPIClientProtocol: AnyObject {
|
public protocol IMailAPIClientProtocol: AnyObject, Sendable {
|
||||||
var isAuthenticated: Bool { get }
|
var isAuthenticated: Bool { get }
|
||||||
func syncMail(_ payloads: [SyncEmailPayload]) async throws -> Int
|
func syncMail(_ payloads: [SyncEmailPayload]) async throws -> Int
|
||||||
func getStats() async throws -> IMailStatsResponse
|
func getStats() async throws -> IMailStatsResponse
|
||||||
|
func getPendingSends() async throws -> [PendingMailSend]
|
||||||
|
func reportSendResult(id: String, status: String, error: String?) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - APIClient
|
// MARK: - APIClient
|
||||||
|
|
@ -97,6 +99,44 @@ public final class APIClient: BaseAPIClient, IMailAPIClientProtocol, @unchecked
|
||||||
return synced
|
return synced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Queue
|
||||||
|
|
||||||
|
public func getPendingSends() async throws -> [PendingMailSend] {
|
||||||
|
let data = try await authenticatedRequest("/client/imail/send-queue/pending", 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
|
||||||
|
let p = item["payload"]
|
||||||
|
let cc = p["cc"].array?.compactMap { $0.string }
|
||||||
|
let bcc = p["bcc"].array?.compactMap { $0.string }
|
||||||
|
return PendingMailSend(
|
||||||
|
id: item["id"].stringValue,
|
||||||
|
action: item["action"].stringValue,
|
||||||
|
payload: MailSendPayload(
|
||||||
|
to: p["to"].stringValue,
|
||||||
|
cc: cc,
|
||||||
|
bcc: bcc,
|
||||||
|
subject: p["subject"].stringValue,
|
||||||
|
body: p["body"].stringValue,
|
||||||
|
isHtml: p["isHtml"].bool
|
||||||
|
),
|
||||||
|
createdAt: item["createdAt"].stringValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reportSendResult(id: String, status: String, error: String?) async throws {
|
||||||
|
var params: [String: Any] = ["status": status]
|
||||||
|
if let err = error { params["error"] = err }
|
||||||
|
let data = try await authenticatedRequest("/client/imail/send-queue/\(id)/result", method: .post, parameters: params)
|
||||||
|
let json = JSON(data)
|
||||||
|
guard json["success"].boolValue else {
|
||||||
|
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Stats
|
// MARK: - Stats
|
||||||
|
|
||||||
public func getStats() async throws -> IMailStatsResponse {
|
public func getStats() async throws -> IMailStatsResponse {
|
||||||
|
|
|
||||||
|
|
@ -36,80 +36,86 @@ public enum IMailSyncError: Equatable, Sendable {
|
||||||
|
|
||||||
/// Orchestrates iMail sync: read messages from Mail.app → push to server in batches.
|
/// Orchestrates iMail sync: read messages from Mail.app → push to server in batches.
|
||||||
///
|
///
|
||||||
/// Watermark: the Message-ID of the most recently synced message is stored in UserDefaults
|
/// Watermark: handled by `BaseSyncManager` — `lastSync` is updated at the end of every
|
||||||
/// under `imailLastMessageId`, and the send date under `imailLastSync`. On the next cycle
|
/// cycle (cycle-completion time, not the latest-message date). The reader applies a
|
||||||
/// we pass `lastSync` to `Reader.fetchMessages(since:)` so we only enumerate messages
|
/// ±1-minute tolerance which makes the cycle-time watermark safe for catching late
|
||||||
/// sent after that date (±1 minute tolerance is applied by the reader).
|
/// arrivals on the next cycle.
|
||||||
///
|
///
|
||||||
/// Runs in parallel with the server-side IMAP module during transition. De-duplication
|
/// Runs in parallel with the server-side IMAP module during transition. De-duplication
|
||||||
/// is handled server-side via `externalId` (Message-ID).
|
/// is handled server-side via `externalId` (Message-ID).
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class SyncManager: ObservableObject {
|
public final class SyncManager: BaseSyncManager<IMailSyncStats, IMailSyncError> {
|
||||||
public static let shared = SyncManager()
|
public static let shared = SyncManager()
|
||||||
|
|
||||||
@Published public var isSyncing = false
|
|
||||||
@Published public var lastSync: Date?
|
|
||||||
@Published public var lastSyncCompletedAt: Date?
|
|
||||||
@Published public var stats = IMailSyncStats()
|
|
||||||
@Published public var syncError: IMailSyncError = .none
|
|
||||||
@Published public var currentOperation: String = ""
|
|
||||||
|
|
||||||
private let reader = Reader.shared
|
private let reader = Reader.shared
|
||||||
private let sender = Sender.shared
|
private let sender = Sender.shared
|
||||||
private let apiClient = APIClient.shared
|
private let apiClient = APIClient.shared
|
||||||
private var syncTimer: Timer?
|
|
||||||
|
|
||||||
private let batchSize = 50
|
private let batchSize = 50
|
||||||
|
|
||||||
private init() {
|
// Outbound send queue (server → Mac Mail.app sends).
|
||||||
lastSync = UserDefaults.standard.object(forKey: "imailLastSync") as? Date
|
private lazy var sendQueueClient: SendQueueClient<IMailSendTransport> = {
|
||||||
lastSyncCompletedAt = UserDefaults.standard.object(forKey: "imailLastSyncCompletedAt") as? Date
|
let transport = IMailSendTransport(apiClient: apiClient)
|
||||||
log.info("init lastSync=\(String(describing: self.lastSync))")
|
let mailSender = MailSender(sender: sender)
|
||||||
}
|
return SendQueueClient(
|
||||||
|
label: "imail",
|
||||||
// MARK: - Lifecycle
|
transport: transport,
|
||||||
|
interval: 60
|
||||||
public func startSync() {
|
) { item in
|
||||||
log.info("startSync called")
|
switch item.action {
|
||||||
syncError = .none
|
case "send_mail":
|
||||||
|
return await mailSender.send(item.payload)
|
||||||
Task { await performSync() }
|
default:
|
||||||
|
return .failed(reason: "unknown action \(item.action)")
|
||||||
syncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
|
}
|
||||||
Task { @MainActor in self?.syncNow() }
|
|
||||||
}
|
}
|
||||||
log.info("Scheduled automatic sync every 5 minutes")
|
}()
|
||||||
|
|
||||||
|
public override func didStartSync() {
|
||||||
|
sendQueueClient.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stopSync() {
|
public override func willStopSync() {
|
||||||
syncTimer?.invalidate()
|
sendQueueClient.stop()
|
||||||
syncTimer = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func syncNow() {
|
private init() {
|
||||||
guard !isSyncing else { return }
|
super.init(
|
||||||
Task { await performSync() }
|
initialStats: IMailSyncStats(),
|
||||||
|
noError: .none,
|
||||||
|
persistenceKey: "imail",
|
||||||
|
timerInterval: 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migrate legacy UserDefaults watermark keys (one-time).
|
||||||
|
if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "imailLastSync") as? Date {
|
||||||
|
setLastSync(legacy)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "imailLastSync")
|
||||||
|
}
|
||||||
|
if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "imailLastSyncCompletedAt") as? Date {
|
||||||
|
lastSyncCompletedAt = legacy
|
||||||
|
UserDefaults.standard.set(legacy, forKey: "imail.lastSyncCompletedAt")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "imailLastSyncCompletedAt")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: "imailLastSync")
|
|
||||||
syncNow()
|
syncNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = "Reading Mail.app…"
|
currentOperation = "Reading Mail.app…"
|
||||||
|
|
||||||
// Reader runs synchronously on a background thread — dispatch off main
|
// Reader runs synchronously on a background thread — dispatch off main
|
||||||
let messages = await Task.detached(priority: .userInitiated) { [weak self] in
|
let watermark = lastSync
|
||||||
guard let self else { return [MailMessage]() }
|
let messages = await Task.detached(priority: .userInitiated) { [reader] in
|
||||||
return self.reader.fetchMessages(since: await self.lastSync)
|
reader.fetchMessages(since: watermark)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
log.info("Fetched \(messages.count) messages from Mail.app")
|
log.info("Fetched \(messages.count) messages from Mail.app")
|
||||||
|
|
@ -117,15 +123,11 @@ public final class SyncManager: ObservableObject {
|
||||||
guard !messages.isEmpty else {
|
guard !messages.isEmpty else {
|
||||||
currentOperation = "No new mail"
|
currentOperation = "No new mail"
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
isSyncing = false
|
|
||||||
lastSyncCompletedAt = Date()
|
|
||||||
UserDefaults.standard.set(lastSyncCompletedAt, forKey: "imailLastSyncCompletedAt")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOperation = "Syncing \(messages.count) messages…"
|
currentOperation = "Syncing \(messages.count) messages…"
|
||||||
var totalSynced = 0
|
var totalSynced = 0
|
||||||
var latestDate: Date?
|
|
||||||
|
|
||||||
let batches = stride(from: 0, to: messages.count, by: batchSize).map {
|
let batches = stride(from: 0, to: messages.count, by: batchSize).map {
|
||||||
Array(messages[$0..<min($0 + batchSize, messages.count)])
|
Array(messages[$0..<min($0 + batchSize, messages.count)])
|
||||||
|
|
@ -158,20 +160,10 @@ public final class SyncManager: ObservableObject {
|
||||||
do {
|
do {
|
||||||
let synced = try await apiClient.syncMail(payloads)
|
let synced = try await apiClient.syncMail(payloads)
|
||||||
totalSynced += synced
|
totalSynced += synced
|
||||||
|
|
||||||
// Track latest date for watermark
|
|
||||||
let df = ISO8601DateFormatter()
|
|
||||||
for msg in batch {
|
|
||||||
if let d = df.date(from: msg.sentAt) {
|
|
||||||
if latestDate == nil || d > latestDate! { latestDate = d }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
|
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
|
log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
|
||||||
let msg = error.localizedDescription.lowercased()
|
if SyncConnectionErrorHeuristic.isConnectionError(error) {
|
||||||
if msg.contains("connection") || msg.contains("network") || msg.contains("timeout") {
|
|
||||||
syncError = .connectionFailed(error.localizedDescription)
|
syncError = .connectionFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,19 +171,9 @@ public final class SyncManager: ObservableObject {
|
||||||
|
|
||||||
stats.syncedThisSession = totalSynced
|
stats.syncedThisSession = totalSynced
|
||||||
|
|
||||||
if let d = latestDate {
|
|
||||||
lastSync = d
|
|
||||||
UserDefaults.standard.set(d, forKey: "imailLastSync")
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
lastSyncCompletedAt = now
|
|
||||||
UserDefaults.standard.set(now, forKey: "imailLastSyncCompletedAt")
|
|
||||||
|
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
currentOperation = "Sync complete"
|
currentOperation = "Sync complete"
|
||||||
log.info("performSync complete: synced=\(totalSynced)")
|
log.info("performSync complete: synced=\(totalSynced)")
|
||||||
isSyncing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchStats() async {
|
private func fetchStats() async {
|
||||||
|
|
@ -200,8 +182,7 @@ public final class SyncManager: ObservableObject {
|
||||||
stats.totalEmails = response.totalEmails
|
stats.totalEmails = response.totalEmails
|
||||||
stats.totalFolders = response.totalFolders
|
stats.totalFolders = response.totalFolders
|
||||||
if let serverLastSync = response.lastSyncAt, lastSync == nil {
|
if let serverLastSync = response.lastSyncAt, lastSync == nil {
|
||||||
lastSync = serverLastSync
|
setLastSync(serverLastSync)
|
||||||
UserDefaults.standard.set(serverLastSync, forKey: "imailLastSync")
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("fetchStats failed: \(error.localizedDescription)")
|
log.warning("fetchStats failed: \(error.localizedDescription)")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
import Swifter
|
import Swifter
|
||||||
import Contacts
|
import Contacts
|
||||||
import Photos
|
import Photos
|
||||||
|
|
@ -98,6 +99,40 @@ public final class LocalWebServer {
|
||||||
return jsonArrayResponse(entries)
|
return jsonArrayResponse(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.GET["/api/imessage/service-for-handle"] = { req in
|
||||||
|
guard let rawHandle = req.queryParams.first(where: { $0.0 == "handle" })?.1,
|
||||||
|
!rawHandle.isEmpty else {
|
||||||
|
return jsonResponse(["error": "missing_handle"])
|
||||||
|
}
|
||||||
|
let handle = rawHandle.removingPercentEncoding ?? rawHandle
|
||||||
|
let chatDbPath = (NSHomeDirectory() as NSString).appendingPathComponent("Library/Messages/chat.db")
|
||||||
|
var db: OpaquePointer?
|
||||||
|
guard sqlite3_open_v2(chatDbPath, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK else {
|
||||||
|
return jsonResponse(["error": "chat_db_unavailable"])
|
||||||
|
}
|
||||||
|
defer { sqlite3_close(db) }
|
||||||
|
let sql = "SELECT service FROM message WHERE handle_id IN (SELECT ROWID FROM handle WHERE id=?) AND is_from_me=0 ORDER BY date DESC LIMIT 1"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
|
||||||
|
return jsonResponse(["error": "prepare_failed"])
|
||||||
|
}
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_text(stmt, 1, (handle as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||||
|
var rawSvc: String? = nil
|
||||||
|
if sqlite3_step(stmt) == SQLITE_ROW, let col = sqlite3_column_text(stmt, 0) {
|
||||||
|
rawSvc = String(cString: col)
|
||||||
|
}
|
||||||
|
let service: String
|
||||||
|
if rawSvc == "iMessage" || rawSvc == "SMS" {
|
||||||
|
service = rawSvc!
|
||||||
|
} else {
|
||||||
|
service = "unknown"
|
||||||
|
}
|
||||||
|
var result: [String: Any] = ["service": service]
|
||||||
|
if let raw = rawSvc { result["raw_service"] = raw }
|
||||||
|
return jsonResponse(result)
|
||||||
|
}
|
||||||
|
|
||||||
// Static files + SPA fallback
|
// Static files + SPA fallback
|
||||||
server.notFoundHandler = { [weak self] req in
|
server.notFoundHandler = { [weak self] req in
|
||||||
guard let webRoot = self?.webappDirectory() else { return .notFound }
|
guard let webRoot = self?.webappDirectory() else { return .notFound }
|
||||||
|
|
|
||||||
57
CLAUDE.md
57
CLAUDE.md
|
|
@ -1,23 +1,60 @@
|
||||||
# @mac-sync
|
# @mac-sync
|
||||||
|
|
||||||
Unified macOS sync agent — collapses iMessage, iPhoto, and iMail into one app + one server.
|
Unified macOS sync agent — Messages, Photos, Mail, Calendar (2-way), Reminders (2-way), Notes (2-way via AppleScript), Contacts, plus message search/embeddings — in one menu-bar app + one server.
|
||||||
|
|
||||||
See the plan: `~/.claude/plans/moonlit-swimming-deer.md`
|
Latest plan: `~/.claude/plans/magical-tumbling-peach.md`.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@packages/
|
@packages/
|
||||||
shared/ MacSyncShared SwiftPM target — transport, auth, chunking, local web server
|
shared/ MacSyncShared SwiftPM target
|
||||||
imessage/ IMessageSync SwiftPM target
|
• Sync/ BaseSyncManager, BlobSyncManager,
|
||||||
iphoto/ IPhotoSync SwiftPM target
|
SendQueueClient, SyncConnectionError
|
||||||
imail/ IMailSync SwiftPM target
|
• Storage/ ActivityLog, ConfigFile
|
||||||
src/client/ MacSyncApp executable (menu bar app)
|
• Transport/ Shared, DeviceRegistration
|
||||||
src/server/ NestJS-style server (TypeScript)
|
• Util/ ContentTypeMapping, PhoneUtils,
|
||||||
deploy/ install.sh, LaunchAgent, systemd units
|
AppleScriptEscape
|
||||||
web/ React SPA (dashboard)
|
• WebServer/ LocalWebServer
|
||||||
|
imessage/ IMessageSync SwiftPM target (bidirectional via
|
||||||
|
SendQueueClient,
|
||||||
|
wrapping the legacy
|
||||||
|
server-side
|
||||||
|
icloud.send_queue table;
|
||||||
|
attachment blob upload)
|
||||||
|
iphoto/ IPhotoSync SwiftPM target (read-only Mac → server,
|
||||||
|
photo blob upload)
|
||||||
|
imail/ IMailSync SwiftPM target (bidirectional via AppleScript)
|
||||||
|
ical/ ICalSync SwiftPM target (bidirectional via SendQueueClient)
|
||||||
|
ireminders/ IReminderSync SwiftPM target (bidirectional via SendQueueClient)
|
||||||
|
inotes/ INoteSync SwiftPM target (bidirectional via AppleScript)
|
||||||
|
contacts-sync-core/ ContactsSyncCore SwiftPM target (Contacts.framework
|
||||||
|
→ server)
|
||||||
|
src/client/ MacSyncApp executable (menu bar app)
|
||||||
|
src/server/ Hono + Bun + PostgreSQL server (TypeScript)
|
||||||
|
• features/embedding — message embedding pipeline
|
||||||
|
• features/search — semantic + keyword search with cache
|
||||||
|
• features/imessage — iMessage ingestion / send queue
|
||||||
|
• features/prospect — outreach prospect graph
|
||||||
|
deploy/ install.sh, LaunchAgent template, systemd units
|
||||||
|
web/ React SPA dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Architecture invariants
|
||||||
|
|
||||||
|
- **One sync manager per module**, all extending `BaseSyncManager<Stats, SyncError>`.
|
||||||
|
Blob-uploading modules (iMessage attachments, iPhoto) additionally use
|
||||||
|
`BlobSyncManager` for the blob upload pipeline.
|
||||||
|
- **One send queue contract**. Per-module Postgres tables (`icloud.<module>_send_queue`)
|
||||||
|
use the shared `createSendQueueRepo` factory. iMessage's legacy `icloud.send_queue`
|
||||||
|
table predates the factory and stays on its own bespoke schema, but the Mac
|
||||||
|
client polls all of them via the same generic `SendQueueClient<Transport>`
|
||||||
|
(60s for calendar/reminders/notes, 30s for iMessage).
|
||||||
|
- **Single AppleScript escape helper** — `MacSyncShared/Util/AppleScriptEscape.swift`.
|
||||||
|
- **Embedding/search pipeline** is server-side only. The Mac client ingests
|
||||||
|
messages; embedding generation, search caching, and sync-history bookkeeping
|
||||||
|
all live on the server.
|
||||||
|
|
||||||
## Dev
|
## Dev
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,24 @@ let package = Package(
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
||||||
// MARK: Pure-function core (no Contacts framework — fully testable on Linux/CI)
|
// MARK: iReminders module
|
||||||
|
.target(
|
||||||
|
name: "IReminderSync",
|
||||||
|
dependencies: ["MacSyncShared"],
|
||||||
|
path: "@packages/ireminders/Sources/IReminderSync",
|
||||||
|
linkerSettings: [
|
||||||
|
.linkedFramework("EventKit"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
// MARK: iNotes module
|
||||||
|
.target(
|
||||||
|
name: "INoteSync",
|
||||||
|
dependencies: ["MacSyncShared"],
|
||||||
|
path: "@packages/inotes/Sources/INoteSync"
|
||||||
|
),
|
||||||
|
|
||||||
|
// MARK: Pure-function contacts core (no Contacts framework — fully testable on Linux/CI)
|
||||||
.target(
|
.target(
|
||||||
name: "ContactsSyncCore",
|
name: "ContactsSyncCore",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
|
|
@ -99,6 +116,8 @@ let package = Package(
|
||||||
"IPhotoSync",
|
"IPhotoSync",
|
||||||
"IMailSync",
|
"IMailSync",
|
||||||
"ICalSync",
|
"ICalSync",
|
||||||
|
"IReminderSync",
|
||||||
|
"INoteSync",
|
||||||
],
|
],
|
||||||
path: "src/client",
|
path: "src/client",
|
||||||
exclude: ["Resources"]
|
exclude: ["Resources"]
|
||||||
|
|
@ -137,6 +156,20 @@ let package = Package(
|
||||||
path: "@packages/ical/Tests/ICalSyncTests"
|
path: "@packages/ical/Tests/ICalSyncTests"
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// MARK: iReminders Tests
|
||||||
|
.testTarget(
|
||||||
|
name: "IReminderSyncTests",
|
||||||
|
dependencies: ["IReminderSync"],
|
||||||
|
path: "@packages/ireminders/Tests/IReminderSyncTests"
|
||||||
|
),
|
||||||
|
|
||||||
|
// MARK: iNotes Tests
|
||||||
|
.testTarget(
|
||||||
|
name: "INoteSyncTests",
|
||||||
|
dependencies: ["INoteSync", "MacSyncShared"],
|
||||||
|
path: "@packages/inotes/Tests/INoteSyncTests"
|
||||||
|
),
|
||||||
|
|
||||||
// MARK: ContactsSyncCore Tests (pure functions — no Contacts framework required)
|
// MARK: ContactsSyncCore Tests (pure functions — no Contacts framework required)
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ContactsSyncCoreTests",
|
name: "ContactsSyncCoreTests",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
name: mac-sync
|
name: mac-sync
|
||||||
description: Unified macOS sync agent — iMessage, Photos, Mail
|
description: Unified macOS sync agent — iMessage, Photos, Mail, Calendar (2-way), Reminders (2-way), Notes (2-way via AppleScript)
|
||||||
type: application
|
type: application
|
||||||
category: sync
|
category: sync
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
|
modules:
|
||||||
|
- imessage # bidirectional via SendQueueClient over legacy send_queue table
|
||||||
|
- iphoto # read-only Mac → server
|
||||||
|
- imail # bidirectional via AppleScript
|
||||||
|
- ical # bidirectional via SendQueueClient
|
||||||
|
- ireminders # bidirectional via SendQueueClient
|
||||||
|
- inotes # bidirectional via AppleScript
|
||||||
|
|
||||||
platforms:
|
platforms:
|
||||||
plum:
|
plum:
|
||||||
|
|
@ -13,7 +20,7 @@ platforms:
|
||||||
client:
|
client:
|
||||||
type: launchagent
|
type: launchagent
|
||||||
bundle_id: com.lilith.mac-sync
|
bundle_id: com.lilith.mac-sync
|
||||||
description: MacSync menu bar agent (iMessage + Photos + Mail sync)
|
description: MacSync menu bar agent (Messages + Photos + Mail + Calendar + Reminders + Notes sync)
|
||||||
start:
|
start:
|
||||||
path: ~/Code/@applications/@mac-sync
|
path: ~/Code/@applications/@mac-sync
|
||||||
script: deploy/deploy-remote.sh
|
script: deploy/deploy-remote.sh
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue