import Foundation import LilithLogging import MacSyncShared private let log = AppLogger.logger(for: "INote.Sender") // MARK: - Apple Notes via AppleScript — known limitations // // - **Attachments** (images, scans, drawings) do NOT round-trip through // AppleScript. We sync only title, body (HTML), folder, and // modification date. Any attachment in a note authored on the Mac // side will be silently dropped on the server-side copy and will not // re-appear after a server-side edit. // - **Rich formatting** (bulleted/numbered lists, tables, font styling) // may degrade through the HTML round-trip. Apple Notes' AppleScript // `body` accessor emits a flavor of HTML that does not exactly match // what the app stores internally. // - **Note IDs** returned by AppleScript (`id of note`) are stable // within a given macOS version but the URI format is not publicly // documented. Apple may change this in future releases; if that // happens we will need to re-key against name+modificationDate. // - **Permission**: requires "Automation" entitlement granting the Mac // client the right to control Notes.app. Granted via System Settings // → Privacy & Security → Automation. Without it every AppleScript // call fails immediately. // MARK: - Wire types public struct NoteSendPayload: Sendable, Decodable, Equatable { public let noteIdentifier: String? public let folder: String? public let name: String? public let body: String? public init( noteIdentifier: String? = nil, folder: String? = nil, name: String? = nil, body: String? = nil ) { self.noteIdentifier = noteIdentifier self.folder = folder self.name = name self.body = body } } public struct PendingNoteSend: Sendable, Decodable, Equatable { public let id: String public let action: String public let payload: NoteSendPayload public let createdAt: String public init(id: String, action: String, payload: NoteSendPayload, createdAt: String) { self.id = id self.action = action self.payload = payload self.createdAt = createdAt } } // MARK: - Applier protocol (allows hermetic tests without AppleScript) /// Narrow protocol the dispatcher calls into so tests can substitute a /// fake that does not require Notes.app automation authorization. public protocol NoteApplying: Sendable { func create(_ payload: NoteSendPayload) async -> SendQueueApplyResult func update(_ payload: NoteSendPayload) async -> SendQueueApplyResult func delete(_ payload: NoteSendPayload) async -> SendQueueApplyResult } // MARK: - Sender /// Applies pending note send-queue items against Notes.app via AppleScript. @MainActor public final class NoteSender: NoteApplying { private let applier: any NoteApplying public init(applier: (any NoteApplying)? = nil) { self.applier = applier ?? AppleScriptNoteApplier() } public func apply(_ item: PendingNoteSend) async -> SendQueueApplyResult { log.info("apply id=\(item.id) action=\(item.action)") switch item.action { case "create_note": return await applier.create(item.payload) case "update_note": return await applier.update(item.payload) case "delete_note": return await applier.delete(item.payload) default: return .failed(reason: "unknown action: \(item.action)") } } // Pass-throughs so the sender itself conforms to NoteApplying and can // be used as a `NoteApplying` value in tests. public func create(_ payload: NoteSendPayload) async -> SendQueueApplyResult { await applier.create(payload) } public func update(_ payload: NoteSendPayload) async -> SendQueueApplyResult { await applier.update(payload) } public func delete(_ payload: NoteSendPayload) async -> SendQueueApplyResult { await applier.delete(payload) } } // MARK: - AppleScript-backed applier /// Default `NoteApplying` that drives Notes.app via osascript. public final class AppleScriptNoteApplier: NoteApplying, @unchecked Sendable { public init() {} public func create(_ payload: NoteSendPayload) async -> SendQueueApplyResult { guard let name = payload.name, !name.isEmpty else { return .failed(reason: "missing name") } let body = payload.body ?? "" let folder = payload.folder ?? "Notes" let script = Self.scriptForCreate(name: name, body: body, folder: folder) let (ok, err) = await Self.runAppleScript(script) return ok ? .sent : .failed(reason: err) } public func update(_ payload: NoteSendPayload) async -> SendQueueApplyResult { guard let id = payload.noteIdentifier, !id.isEmpty else { return .failed(reason: "missing noteIdentifier") } guard payload.name != nil || payload.body != nil else { return .failed(reason: "no fields to update") } let script = Self.scriptForUpdate(id: id, name: payload.name, body: payload.body) let (ok, err) = await Self.runAppleScript(script) return ok ? .sent : .failed(reason: err) } public func delete(_ payload: NoteSendPayload) async -> SendQueueApplyResult { guard let id = payload.noteIdentifier, !id.isEmpty else { return .failed(reason: "missing noteIdentifier") } let script = Self.scriptForDelete(id: id) let (ok, err) = await Self.runAppleScript(script) return ok ? .sent : .failed(reason: err) } // MARK: - Script generation (pure, testable) /// Build the AppleScript source for a `create_note` action. All /// interpolated values are escaped via `AppleScriptEscape.quote`. public static func scriptForCreate(name: String, body: String, folder: String) -> String { let qName = AppleScriptEscape.quote(name) let qBody = AppleScriptEscape.quote(body) let qFolder = AppleScriptEscape.quote(folder) return """ tell application "Notes" tell folder "\(qFolder)" of default account make new note with properties {name:"\(qName)", body:"\(qBody)"} end tell end tell """ } /// Build the AppleScript source for an `update_note` action. public static func scriptForUpdate(id: String, name: String?, body: String?) -> String { let qId = AppleScriptEscape.quote(id) var setters: [String] = [] if let name { setters.append("set name of note id \"\(qId)\" to \"\(AppleScriptEscape.quote(name))\"") } if let body { setters.append("set body of note id \"\(qId)\" to \"\(AppleScriptEscape.quote(body))\"") } let inner = setters.joined(separator: "\n ") return """ tell application "Notes" \(inner) end tell """ } /// Build the AppleScript source for a `delete_note` action. public static func scriptForDelete(id: String) -> String { let qId = AppleScriptEscape.quote(id) return """ tell application "Notes" delete note id "\(qId)" end tell """ } // MARK: - osascript runner /// Returns `(success, errorMessage)`. Uses /usr/bin/osascript so we /// never block a Swift main-actor task on a long AppleScript event. static func runAppleScript(_ source: String) async -> (Bool, String) { await Task.detached { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") process.arguments = ["-e", source] let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe do { try process.run() process.waitUntilExit() let errData = errPipe.fileHandleForReading.readDataToEndOfFile() let errStr = String(data: errData, encoding: .utf8) ?? "" if process.terminationStatus == 0 { return (true, "") } else { return (false, errStr.isEmpty ? "osascript exit \(process.terminationStatus)" : errStr) } } catch { return (false, error.localizedDescription) } }.value } } // MARK: - SendQueueTransport adapter public struct NoteSendTransport: SendQueueTransport { public typealias PendingItem = PendingNoteSend private let apiClient: any INoteAPIClientProtocol public init(apiClient: any INoteAPIClientProtocol) { self.apiClient = apiClient } public func id(of item: PendingNoteSend) -> String { item.id } public func fetchPending() async throws -> [PendingNoteSend] { try await apiClient.getPendingSends() } public func reportResult(id: String, status: String, error: String?) async throws { try await apiClient.reportSendResult(id: id, status: status, error: error) } }