240 lines
8.9 KiB
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)
|
|
}
|
|
}
|