fix(inotes): run Notes AppleScript on a thread with a live run loop
Some checks are pending
Swift Build & Test / swift build + test (push) Waiting to run

The detached thread had no run loop, so AESendMessage(kAEWaitReply) for the long
~25s notes fetch never received its reply (errored). Marshal scripts onto one
long-lived thread that owns a continuously-running CFRunLoop: in-process (TCC
attributes the event to MacSync, grant honored), real run loop (reply pumped),
off-main (no agent freeze). Also log the AppleScript error with .public privacy
so failures are visible instead of os_log's <private> redaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-30 13:02:12 -04:00
parent acebcdc37e
commit 7af883a066

View file

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