From cc1d40f7f619445ee4f93121a92998a2e3352c8b Mon Sep 17 00:00:00 2001 From: quinn Date: Fri, 15 May 2026 18:02:04 -0700 Subject: [PATCH] merge batch 1: top-level metadata, shared, ical, imail --- .../ical/Sources/ICalSync/APIClient.swift | 42 +++++- @packages/ical/Sources/ICalSync/Reader.swift | 4 + .../ical/Sources/ICalSync/SyncManager.swift | 104 +++++++-------- .../imail/Sources/IMailSync/APIClient.swift | 42 +++++- .../imail/Sources/IMailSync/SyncManager.swift | 121 ++++++++---------- .../WebServer/LocalWebServer.swift | 35 +++++ CLAUDE.md | 57 +++++++-- Package.swift | 35 ++++- app.manifest.yaml | 13 +- 9 files changed, 311 insertions(+), 142 deletions(-) diff --git a/@packages/ical/Sources/ICalSync/APIClient.swift b/@packages/ical/Sources/ICalSync/APIClient.swift index 99daa7e..13b6eef 100644 --- a/@packages/ical/Sources/ICalSync/APIClient.swift +++ b/@packages/ical/Sources/ICalSync/APIClient.swift @@ -83,11 +83,13 @@ public struct ICalStatsResponse { // MARK: - Protocol -public protocol ICalAPIClientProtocol: AnyObject { +public protocol ICalAPIClientProtocol: AnyObject, Sendable { var isAuthenticated: Bool { get } func syncCalendars(_ payloads: [SyncCalendarPayload]) async throws -> Int func syncEvents(_ payloads: [SyncEventPayload]) async throws -> Int func getStats() async throws -> ICalStatsResponse + func getPendingSends() async throws -> [PendingCalendarSend] + func reportSendResult(id: String, status: String, error: String?) async throws } // MARK: - APIClient @@ -129,6 +131,44 @@ public final class APIClient: BaseAPIClient, ICalAPIClientProtocol, @unchecked S 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 { let data = try await authenticatedRequest("/client/ical/stats", method: .get) let json = JSON(data) diff --git a/@packages/ical/Sources/ICalSync/Reader.swift b/@packages/ical/Sources/ICalSync/Reader.swift index ed10e2a..dd47171 100644 --- a/@packages/ical/Sources/ICalSync/Reader.swift +++ b/@packages/ical/Sources/ICalSync/Reader.swift @@ -48,6 +48,10 @@ public final class CalendarReader: @unchecked Sendable { 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 public func requestAuthorization() async -> Bool { diff --git a/@packages/ical/Sources/ICalSync/SyncManager.swift b/@packages/ical/Sources/ICalSync/SyncManager.swift index c5dedec..29d045e 100644 --- a/@packages/ical/Sources/ICalSync/SyncManager.swift +++ b/@packages/ical/Sources/ICalSync/SyncManager.swift @@ -43,64 +43,66 @@ public enum ICalSyncError: Equatable, Sendable { // MARK: - SyncManager @MainActor -public final class SyncManager: ObservableObject { +public final class SyncManager: BaseSyncManager { 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 private let apiClient = APIClient.shared - private var syncTimer: Timer? - private var lastSync: Date? private let eventBatchSize = 200 + // Outbound send queue (server → Mac calendar writes). + private lazy var sendQueueClient: SendQueueClient = { + 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() { - lastSync = UserDefaults.standard.object(forKey: "icalLastSync") as? Date - lastSyncCompletedAt = UserDefaults.standard.object(forKey: "icalLastSyncCompletedAt") as? Date - log.info("init lastSync=\(String(describing: self.lastSync))") + super.init( + initialStats: ICalSyncStats(), + 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() { - log.info("startSync called") - syncError = .none + public override func isAuthorized() async -> Bool { reader.isAuthorized } - Task { - let authorized = 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 override func requestAuthorization() async -> Bool { + await reader.requestAuthorization() } - public func stopSync() { - syncTimer?.invalidate() - syncTimer = nil - } - - 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 override func onAuthorizationDenied() { + log.warning("Calendar access denied") + syncError = .calendarAccessRequired } public func openCalendarAccessSettings() { @@ -111,9 +113,8 @@ public final class SyncManager: ObservableObject { // MARK: - Sync cycle - private func performSync() async { + public override func performSync() async { log.info("performSync starting") - isSyncing = true // Phase 1: 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() currentOperation = "Sync complete" log.info("performSync complete calendars=\(calendars.count) events=\(events.count)") - isSyncing = false } private func updateStats() async { @@ -212,9 +206,7 @@ public final class SyncManager: ObservableObject { } private func setConnectionError(_ error: Error) { - let msg = error.localizedDescription.lowercased() - if msg.contains("network") || msg.contains("connection") || - msg.contains("timeout") || msg.contains("unreachable") { + if SyncConnectionErrorHeuristic.isConnectionError(error) { syncError = .backendUnreachable } } diff --git a/@packages/imail/Sources/IMailSync/APIClient.swift b/@packages/imail/Sources/IMailSync/APIClient.swift index 7d317f0..95f3079 100644 --- a/@packages/imail/Sources/IMailSync/APIClient.swift +++ b/@packages/imail/Sources/IMailSync/APIClient.swift @@ -64,10 +64,12 @@ public struct IMailStatsResponse { // MARK: - Protocol -public protocol IMailAPIClientProtocol: AnyObject { +public protocol IMailAPIClientProtocol: AnyObject, Sendable { var isAuthenticated: Bool { get } func syncMail(_ payloads: [SyncEmailPayload]) async throws -> Int func getStats() async throws -> IMailStatsResponse + func getPendingSends() async throws -> [PendingMailSend] + func reportSendResult(id: String, status: String, error: String?) async throws } // MARK: - APIClient @@ -97,6 +99,44 @@ public final class APIClient: BaseAPIClient, IMailAPIClientProtocol, @unchecked 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 public func getStats() async throws -> IMailStatsResponse { diff --git a/@packages/imail/Sources/IMailSync/SyncManager.swift b/@packages/imail/Sources/IMailSync/SyncManager.swift index 9a9e02f..7dcf7fa 100644 --- a/@packages/imail/Sources/IMailSync/SyncManager.swift +++ b/@packages/imail/Sources/IMailSync/SyncManager.swift @@ -36,80 +36,86 @@ public enum IMailSyncError: Equatable, Sendable { /// 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 -/// under `imailLastMessageId`, and the send date under `imailLastSync`. On the next cycle -/// we pass `lastSync` to `Reader.fetchMessages(since:)` so we only enumerate messages -/// sent after that date (±1 minute tolerance is applied by the reader). +/// Watermark: handled by `BaseSyncManager` — `lastSync` is updated at the end of every +/// cycle (cycle-completion time, not the latest-message date). The reader applies a +/// ±1-minute tolerance which makes the cycle-time watermark safe for catching late +/// arrivals on the next cycle. /// /// Runs in parallel with the server-side IMAP module during transition. De-duplication /// is handled server-side via `externalId` (Message-ID). @MainActor -public final class SyncManager: ObservableObject { +public final class SyncManager: BaseSyncManager { 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 sender = Sender.shared private let apiClient = APIClient.shared - private var syncTimer: Timer? private let batchSize = 50 - private init() { - lastSync = UserDefaults.standard.object(forKey: "imailLastSync") as? Date - lastSyncCompletedAt = UserDefaults.standard.object(forKey: "imailLastSyncCompletedAt") as? Date - log.info("init lastSync=\(String(describing: self.lastSync))") - } - - // MARK: - Lifecycle - - public func startSync() { - log.info("startSync called") - syncError = .none - - Task { await performSync() } - - syncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in - Task { @MainActor in self?.syncNow() } + // Outbound send queue (server → Mac Mail.app sends). + private lazy var sendQueueClient: SendQueueClient = { + let transport = IMailSendTransport(apiClient: apiClient) + let mailSender = MailSender(sender: sender) + return SendQueueClient( + label: "imail", + transport: transport, + interval: 60 + ) { item in + switch item.action { + case "send_mail": + return await mailSender.send(item.payload) + default: + return .failed(reason: "unknown action \(item.action)") + } } - log.info("Scheduled automatic sync every 5 minutes") + }() + + public override func didStartSync() { + sendQueueClient.start() } - public func stopSync() { - syncTimer?.invalidate() - syncTimer = nil + public override func willStopSync() { + sendQueueClient.stop() } - public func syncNow() { - guard !isSyncing else { return } - Task { await performSync() } + private init() { + super.init( + 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() { guard !isSyncing else { return } log.info("Forcing full resync") - lastSync = nil - UserDefaults.standard.removeObject(forKey: "imailLastSync") + setLastSync(nil) syncNow() } // MARK: - Sync cycle - private func performSync() async { + public override func performSync() async { log.info("performSync starting") - isSyncing = true currentOperation = "Reading Mail.app…" // Reader runs synchronously on a background thread — dispatch off main - let messages = await Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return [MailMessage]() } - return self.reader.fetchMessages(since: await self.lastSync) + let watermark = lastSync + let messages = await Task.detached(priority: .userInitiated) { [reader] in + reader.fetchMessages(since: watermark) }.value log.info("Fetched \(messages.count) messages from Mail.app") @@ -117,15 +123,11 @@ public final class SyncManager: ObservableObject { guard !messages.isEmpty else { currentOperation = "No new mail" await fetchStats() - isSyncing = false - lastSyncCompletedAt = Date() - UserDefaults.standard.set(lastSyncCompletedAt, forKey: "imailLastSyncCompletedAt") return } currentOperation = "Syncing \(messages.count) messages…" var totalSynced = 0 - var latestDate: Date? let batches = stride(from: 0, to: messages.count, by: batchSize).map { Array(messages[$0.. latestDate! { latestDate = d } - } - } - log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)") } catch { log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)") - let msg = error.localizedDescription.lowercased() - if msg.contains("connection") || msg.contains("network") || msg.contains("timeout") { + if SyncConnectionErrorHeuristic.isConnectionError(error) { syncError = .connectionFailed(error.localizedDescription) } } @@ -179,19 +171,9 @@ public final class SyncManager: ObservableObject { 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() currentOperation = "Sync complete" log.info("performSync complete: synced=\(totalSynced)") - isSyncing = false } private func fetchStats() async { @@ -200,8 +182,7 @@ public final class SyncManager: ObservableObject { stats.totalEmails = response.totalEmails stats.totalFolders = response.totalFolders if let serverLastSync = response.lastSyncAt, lastSync == nil { - lastSync = serverLastSync - UserDefaults.standard.set(serverLastSync, forKey: "imailLastSync") + setLastSync(serverLastSync) } } catch { log.warning("fetchStats failed: \(error.localizedDescription)") diff --git a/@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift b/@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift index e606620..0220a5f 100644 --- a/@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift +++ b/@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift @@ -1,4 +1,5 @@ import Foundation +import SQLite3 import Swifter import Contacts import Photos @@ -98,6 +99,40 @@ public final class LocalWebServer { 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 server.notFoundHandler = { [weak self] req in guard let webRoot = self?.webappDirectory() else { return .notFound } diff --git a/CLAUDE.md b/CLAUDE.md index 5b7e985..9643658 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,60 @@ # @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 ``` @packages/ - shared/ MacSyncShared SwiftPM target — transport, auth, chunking, local web server - imessage/ IMessageSync SwiftPM target - iphoto/ IPhotoSync SwiftPM target - imail/ IMailSync SwiftPM target -src/client/ MacSyncApp executable (menu bar app) -src/server/ NestJS-style server (TypeScript) -deploy/ install.sh, LaunchAgent, systemd units -web/ React SPA (dashboard) + shared/ MacSyncShared SwiftPM target + • Sync/ BaseSyncManager, BlobSyncManager, + SendQueueClient, SyncConnectionError + • Storage/ ActivityLog, ConfigFile + • Transport/ Shared, DeviceRegistration + • Util/ ContentTypeMapping, PhoneUtils, + AppleScriptEscape + • 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`. + Blob-uploading modules (iMessage attachments, iPhoto) additionally use + `BlobSyncManager` for the blob upload pipeline. +- **One send queue contract**. Per-module Postgres tables (`icloud._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` + (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 ```sh diff --git a/Package.swift b/Package.swift index 462bbd4..d594bcc 100644 --- a/Package.swift +++ b/Package.swift @@ -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( name: "ContactsSyncCore", dependencies: [], @@ -99,6 +116,8 @@ let package = Package( "IPhotoSync", "IMailSync", "ICalSync", + "IReminderSync", + "INoteSync", ], path: "src/client", exclude: ["Resources"] @@ -137,6 +156,20 @@ let package = Package( 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) .testTarget( name: "ContactsSyncCoreTests", diff --git a/app.manifest.yaml b/app.manifest.yaml index c4fba60..2e660a6 100644 --- a/app.manifest.yaml +++ b/app.manifest.yaml @@ -1,8 +1,15 @@ 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 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: plum: @@ -13,7 +20,7 @@ platforms: client: type: launchagent 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: path: ~/Code/@applications/@mac-sync script: deploy/deploy-remote.sh