merge batch 1: top-level metadata, shared, ical, imail

This commit is contained in:
quinn 2026-05-15 18:02:04 -07:00
parent f4bd7e6829
commit cc1d40f7f6
9 changed files with 311 additions and 142 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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
} }
} }

View file

@ -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 {

View file

@ -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)")

View file

@ -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 }

View file

@ -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

View file

@ -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",

View file

@ -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