macsync/@packages/inotes/Sources/INoteSync/Sender.swift

240 lines
8.9 KiB
Swift

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