diff --git a/@packages/inotes/Sources/INoteSync/Reader.swift b/@packages/inotes/Sources/INoteSync/Reader.swift index b188c73..2d4be6f 100644 --- a/@packages/inotes/Sources/INoteSync/Reader.swift +++ b/@packages/inotes/Sources/INoteSync/Reader.swift @@ -97,7 +97,7 @@ public final class NotesReader { """ let (ok, out) = await runAppleScript(script) guard ok else { - log.warning("fetchAllNotes script failed: \(out)") + log.warning("fetchAllNotes script failed: \(out, privacy: .public)") return [] } return Self.parse(out) @@ -140,35 +140,64 @@ public final class NotesReader { return out } - /// Run AppleScript IN-PROCESS via `NSAppleScript`. Returns - /// `(success, output-or-error)`. + /// Run AppleScript IN-PROCESS via `NSAppleScript`, on a dedicated thread that + /// owns a continuously-running run loop. /// - /// We must NOT shell out to `/usr/bin/osascript`: TCC attributes the Apple - /// event a subprocess sends to that subprocess (`osascript`), not to MacSync, - /// so the `MacSync → Notes` Automation grant doesn't apply and every read is - /// denied even after the user allows it. NSAppleScript sends the event from - /// MacSync itself, so the grant is honored. - /// - /// Run on a dedicated thread, not the main actor: a ~25s `tell application` - /// read on the main thread blocks the run loop the rest of the app (and the - /// sync coordinator) needs. `NSAppleScript.executeAndReturnError` is - /// synchronous and handles its own Apple-event reply, so a fresh thread is - /// safe and keeps the caller async via a continuation. + /// 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 - let thread = Thread { - var errorInfo: NSDictionary? - let script = NSAppleScript(source: source) - let result = script?.executeAndReturnError(&errorInfo) - if let errorInfo { - let msg = (errorInfo[NSAppleScript.errorMessage] as? String) ?? "\(errorInfo)" - continuation.resume(returning: (false, msg)) - } else { - continuation.resume(returning: (true, result?.stringValue ?? "")) - } + AppleScriptRunner.shared.run(source) { ok, out in + continuation.resume(returning: (ok, out)) } - thread.stackSize = 8 << 20 - thread.start() } } } + +/// 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 + } +}