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"
|
|
|
|
|
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 & "\\u001F" & noteName & "\\u001F" & noteBody & "\\u001F" & noteFolder & "\\u001F" & noteMod & "\\u001E"
|
|
|
|
|
end try
|
|
|
|
|
end repeat
|
|
|
|
|
return output
|
|
|
|
|
end tell
|
|
|
|
|
"""
|
|
|
|
|
let (ok, out) = await runAppleScript(script)
|
|
|
|
|
guard ok else {
|
2026-06-30 10:15:58 -04:00
|
|
|
log.warning("fetchAllNotes script failed: \(out)")
|
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 03:54:02 -04:00
|
|
|
/// Run AppleScript IN-PROCESS via `NSAppleScript`. Returns
|
|
|
|
|
/// `(success, output-or-error)`.
|
|
|
|
|
///
|
|
|
|
|
/// 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.
|
|
|
|
|
///
|
2026-06-30 10:08:24 -04:00
|
|
|
/// 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.
|
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
|
|
|
|
|
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 ?? ""))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
thread.stackSize = 8 << 20
|
|
|
|
|
thread.start()
|
2026-06-30 03:54:02 -04:00
|
|
|
}
|
2026-05-15 17:06:07 -07:00
|
|
|
}
|
|
|
|
|
}
|