macsync/@packages/inotes/Sources/INoteSync/Reader.swift

208 lines
8.7 KiB
Swift
Raw Permalink Normal View History

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"
-- AppleScript has no \\u escape; build the US/RS separators (0x1F/0x1E,
-- which Self.parse splits on) with `character id` instead.
set fieldSep to (character id 31)
set recordSep to (character id 30)
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 & fieldSep & noteName & fieldSep & noteBody & fieldSep & noteFolder & fieldSep & noteMod & recordSep
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
}
}