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
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:
parent
acebcdc37e
commit
7af883a066
1 changed files with 55 additions and 26 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue