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
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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))")
|
||||
// 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()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
public override func willStopSync() {
|
||||
sendQueueClient.stop()
|
||||
}
|
||||
|
||||
public func startSync() {
|
||||
log.info("startSync called")
|
||||
syncError = .none
|
||||
private init() {
|
||||
super.init(
|
||||
initialStats: ICalSyncStats(),
|
||||
noError: .none,
|
||||
persistenceKey: "ical",
|
||||
timerInterval: 300
|
||||
)
|
||||
|
||||
Task {
|
||||
let authorized = await reader.requestAuthorization()
|
||||
guard authorized else {
|
||||
// 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: - Authorization hooks
|
||||
|
||||
public override func isAuthorized() async -> Bool { reader.isAuthorized }
|
||||
|
||||
public override func requestAuthorization() async -> Bool {
|
||||
await reader.requestAuthorization()
|
||||
}
|
||||
|
||||
public override func onAuthorizationDenied() {
|
||||
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() {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
public override func didStartSync() {
|
||||
sendQueueClient.start()
|
||||
}
|
||||
|
||||
public override func willStopSync() {
|
||||
sendQueueClient.stop()
|
||||
}
|
||||
|
||||
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))")
|
||||
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")
|
||||
}
|
||||
|
||||
// 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() }
|
||||
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")
|
||||
}
|
||||
log.info("Scheduled automatic sync every 5 minutes")
|
||||
}
|
||||
|
||||
public func stopSync() {
|
||||
syncTimer?.invalidate()
|
||||
syncTimer = nil
|
||||
}
|
||||
|
||||
public func syncNow() {
|
||||
guard !isSyncing else { return }
|
||||
Task { await performSync() }
|
||||
}
|
||||
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
55
CLAUDE.md
55
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
|
||||
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/ NestJS-style server (TypeScript)
|
||||
deploy/ install.sh, LaunchAgent, systemd units
|
||||
web/ React SPA (dashboard)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue