2026-05-15 17:06:07 -07:00
|
|
|
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 {
|
2026-06-30 03:41:59 -04:00
|
|
|
// 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"
|
2026-05-15 17:06:07 -07:00
|
|
|
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"
|
2026-06-30 15:00:16 -04:00
|
|
|
-- 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)
|
2026-05-15 17:06:07 -07:00
|
|
|
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
|
2026-06-30 15:00:16 -04:00
|
|
|
set output to output & noteId & fieldSep & noteName & fieldSep & noteBody & fieldSep & noteFolder & fieldSep & noteMod & recordSep
|
2026-05-15 17:06:07 -07:00
|
|
|
end try
|
|
|
|
|
end repeat
|
|
|
|
|
return output
|
|
|
|
|
end tell
|
|
|
|
|
"""
|
|
|
|
|
let (ok, out) = await runAppleScript(script)
|
|
|
|
|
guard ok else {
|
2026-06-30 13:02:12 -04:00
|
|
|
log.warning("fetchAllNotes script failed: \(out, privacy: .public)")
|
2026-05-15 17:06:07 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:02:12 -04:00
|
|
|
/// Run AppleScript IN-PROCESS via `NSAppleScript`, on a dedicated thread that
|
|
|
|
|
/// owns a continuously-running run loop.
|
2026-06-30 03:54:02 -04:00
|
|
|
///
|
2026-06-30 13:02:12 -04:00
|
|
|
/// 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).
|
2026-05-15 17:06:07 -07:00
|
|
|
private func runAppleScript(_ source: String) async -> (Bool, String) {
|
2026-06-30 10:08:24 -04:00
|
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String), Never>) in
|
2026-06-30 13:02:12 -04:00
|
|
|
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 ?? "")
|
2026-06-30 10:08:24 -04:00
|
|
|
}
|
2026-06-30 03:54:02 -04:00
|
|
|
}
|
2026-06-30 13:02:12 -04:00
|
|
|
CFRunLoopWakeUp(runLoop) // PerformBlock alone won't wake a sleeping loop
|
2026-05-15 17:06:07 -07:00
|
|
|
}
|
|
|
|
|
}
|