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

175 lines
7 KiB
Swift
Raw 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"
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 {
log.warning("fetchAllNotes script failed: \(out)")
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`. 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.
///
/// 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.
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 ?? ""))
}
}
thread.stackSize = 8 << 20
thread.start()
}
}
}