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

View file

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

View file

@ -43,64 +43,66 @@ public enum ICalSyncError: Equatable, Sendable {
// MARK: - SyncManager
@MainActor
public final class SyncManager: ObservableObject {
public final class SyncManager: BaseSyncManager<ICalSyncStats, ICalSyncError> {
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<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() {
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
}
}

View file

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

View file

@ -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<IMailSyncStats, IMailSyncError> {
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<IMailSendTransport> = {
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..<min($0 + batchSize, messages.count)])
@ -158,20 +160,10 @@ public final class SyncManager: ObservableObject {
do {
let synced = try await apiClient.syncMail(payloads)
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)")
} 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)")

View file

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

View file

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

View file

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