import AppKit import Foundation import LilithLogging private let log = AppLogger.logger(for: "INote.Reader") // MARK: - NoteEntry public struct NoteEntry: Sendable, Equatable { public let noteIdentifier: String public let folder: String? public let name: String public let body: String // HTML public let modificationDate: Date? public init( noteIdentifier: String, folder: String?, name: String, body: String, modificationDate: Date? ) { self.noteIdentifier = noteIdentifier self.folder = folder self.name = name self.body = body self.modificationDate = modificationDate } } // MARK: - NotesReader /// Reads notes from Apple Notes via AppleScript. /// /// Apple Notes has no public Cocoa framework — AppleScript is the only /// supported automation surface. We run scripts via the `osascript` /// subprocess (rather than `NSAppleScript`) so we never block the main /// thread on long-running automation events. /// /// # Output parsing /// The fetch script joins each note's fields with the ASCII Unit Separator /// (`\u{001F}`) and notes themselves with the ASCII Record Separator /// (`\u{001E}`). These control characters are extremely unlikely to appear /// inside note bodies (Apple Notes bodies are sanitized HTML), so splitting /// on them is safe in practice. If we see field corruption in the field, a /// follow-up could migrate this to JXA (JavaScript for Automation) which /// emits clean JSON. @MainActor public final class NotesReader { public static let shared = NotesReader() public private(set) var isAuthorized: Bool = false private init() {} /// AppleScript automation permission for Notes.app must be granted in /// System Settings → Privacy & Security → Automation. We prime the /// prompt with a trivial script; if it fails we mark unauthorized. public func requestAuthorization() async -> Bool { // Probe with a command that actually reads Notes data. `get name` returns // the app's bundle name WITHOUT sending an Apple event to Notes, so it never // triggers the TCC Automation prompt — the bug that left Notes ungranted and // unsynced. `count notes` sends a real automation event, surfacing the prompt // and registering MacSync → Notes. let probe = "tell application \"Notes\" to count notes" let (ok, _) = await runAppleScript(probe) isAuthorized = ok if !ok { log.warning("Notes automation authorization probe failed") } return ok } /// Fetch every note from Notes.app. Empty array on failure. public func fetchAllNotes() async -> [NoteEntry] { let script = """ tell application "Notes" set output to "" repeat with n in every note try set noteId to id of n as string set noteName to name of n as string set noteBody to body of n as string set noteFolder to "" try set noteFolder to name of (container of n) as string end try set noteMod to "" try set noteMod to ((modification date of n) as «class isot» as string) end try set output to output & noteId & "\\u001F" & noteName & "\\u001F" & noteBody & "\\u001F" & noteFolder & "\\u001F" & noteMod & "\\u001E" end try end repeat return output end tell """ let (ok, out) = await runAppleScript(script) guard ok else { log.warning("fetchAllNotes script failed: \(out, privacy: .public)") return [] } return Self.parse(out) } /// Parse the joined AppleScript output. `internal` so tests can hit it. /// `nonisolated` because parsing is a pure function and the test suite /// calls it from a non-actor context. nonisolated static func parse(_ raw: String) -> [NoteEntry] { let fieldSep = Character("\u{001F}") let recordSep = Character("\u{001E}") let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] let isoFractional = ISO8601DateFormatter() isoFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var out: [NoteEntry] = [] for record in raw.split(separator: recordSep, omittingEmptySubsequences: true) { let fields = record.split(separator: fieldSep, omittingEmptySubsequences: false).map(String.init) // Need at least id, name, body, folder, mod — defensively skip malformed guard fields.count >= 5 else { continue } let id = fields[0] guard !id.isEmpty else { continue } let name = fields[1] let body = fields[2] let folder = fields[3].isEmpty ? nil : fields[3] let modStr = fields[4] let mod: Date? = { guard !modStr.isEmpty else { return nil } return isoFractional.date(from: modStr) ?? iso.date(from: modStr) }() out.append(NoteEntry( noteIdentifier: id, folder: folder, name: name, body: body, modificationDate: mod )) } return out } /// Run AppleScript IN-PROCESS via `NSAppleScript`, on a dedicated thread that /// owns a continuously-running run loop. /// /// Three constraints must hold at once and only this satisfies all of them: /// - IN-PROCESS — TCC attributes the Apple event to MacSync (a subprocess /// like `osascript` would be the responsible process and the /// `MacSync → Notes` grant would NOT apply). NSAppleScript sends from us. /// - REAL RUN LOOP — `executeAndReturnError` does `AESendMessage(…kAEWaitReply…)` /// whose reply is a mach message that must be pumped by a CFRunLoop on the /// sending thread. A bare detached thread has none, so the reply for the /// long ~25s notes fetch is never received (it errored). The dedicated /// thread runs a real run loop forever. /// - OFF MAIN — a 25s `tell application` on the main thread freezes the agent /// (the @MainActor sync coordinator / timers live there). private func runAppleScript(_ source: String) async -> (Bool, String) { await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String), Never>) in AppleScriptRunner.shared.run(source) { ok, out in continuation.resume(returning: (ok, out)) } } } } /// One long-lived thread with a continuously-running CFRunLoop. Scripts are /// marshaled onto it so in-process NSAppleScript can send long `tell application` /// events and receive their replies without blocking the main thread. private final class AppleScriptRunner: @unchecked Sendable { static let shared = AppleScriptRunner() private var runLoop: CFRunLoop! private init() { let ready = DispatchSemaphore(value: 0) let thread = Thread { [self] in runLoop = CFRunLoopGetCurrent() // A far-future repeating timer is a perpetual run-loop source, so // RunLoop.run() blocks waiting for work instead of returning at once. RunLoop.current.add(Timer(timeInterval: 1e9, repeats: true) { _ in }, forMode: .default) ready.signal() RunLoop.current.run() } thread.name = "com.lilith.mac-sync.applescript" thread.stackSize = 8 << 20 thread.start() ready.wait() // ensure `runLoop` is captured before first use } func run(_ source: String, completion: @escaping (Bool, String) -> Void) { CFRunLoopPerformBlock(runLoop, CFRunLoopMode.defaultMode.rawValue) { var errorInfo: NSDictionary? let result = NSAppleScript(source: source)?.executeAndReturnError(&errorInfo) if let errorInfo { let code = (errorInfo[NSAppleScript.errorNumber] as? Int) ?? 0 let msg = (errorInfo[NSAppleScript.errorMessage] as? String) ?? "\(errorInfo)" completion(false, "AppleScript error \(code): \(msg)") } else { completion(true, result?.stringValue ?? "") } } CFRunLoopWakeUp(runLoop) // PerformBlock alone won't wake a sleeping loop } }