diff --git a/Package.swift b/Package.swift index b253638..76adbac 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let package = Package( .executable(name: "icloud-cli", targets: ["icloud-cli"]), ], targets: [ - .target(name: "ICloudCLICore"), + .target(name: "ICloudCLICore", linkerSettings: [.linkedFramework("EventKit")]), .executableTarget( name: "icloud-cli", dependencies: ["ICloudCLICore"] diff --git a/Sources/ICloudCLICore/AppleMetadataInventories.swift b/Sources/ICloudCLICore/AppleMetadataInventories.swift index 323d142..3d1db7a 100644 --- a/Sources/ICloudCLICore/AppleMetadataInventories.swift +++ b/Sources/ICloudCLICore/AppleMetadataInventories.swift @@ -1021,10 +1021,14 @@ public struct PermissionProbe: Codable, Equatable, Sendable { } public struct PermissionsDoctor: Sendable { - public init() {} + public let remindersAuthorization: RemindersAuthorizationState + + public init(remindersAuthorization: RemindersAuthorizationState = SystemReminderEventKitClient.authorizationState()) { + self.remindersAuthorization = remindersAuthorization + } public func diagnose() -> [PermissionProbe] { - probeMatrix().map { item in + let pathProbes = probeMatrix().map { item in let redacted = item.paths.map(redactHomePath) let missing = item.paths.filter { !FileManager.default.fileExists(atPath: $0) } let unreadable = item.paths.filter { FileManager.default.fileExists(atPath: $0) && !FileManager.default.isReadableFile(atPath: $0) } @@ -1040,6 +1044,18 @@ public struct PermissionsDoctor: Sendable { } return PermissionProbe(command: item.command, paths: redacted, status: status, hint: hint(for: status)) } + return pathProbes + [remindersProbe()] + } + + private func remindersProbe() -> PermissionProbe { + let status = "eventkit-\(remindersAuthorization.rawValue)" + let hint: String + switch remindersAuthorization { + case .fullAccess: hint = "Reminders read access is granted through EventKit." + case .notDetermined: hint = "Grant Reminders access in System Settings > Privacy & Security > Reminders before reading reminders." + case .denied, .restricted, .writeOnly, .unknown: hint = "Enable Reminders read access in System Settings > Privacy & Security > Reminders." + } + return PermissionProbe(command: "reminders list", paths: [], status: status, hint: hint) } private func probeMatrix() -> [(command: String, paths: [String], needsConfirmation: Bool)] { diff --git a/Sources/ICloudCLICore/CommandLine.swift b/Sources/ICloudCLICore/CommandLine.swift index 0528126..43df607 100644 --- a/Sources/ICloudCLICore/CommandLine.swift +++ b/Sources/ICloudCLICore/CommandLine.swift @@ -196,14 +196,18 @@ public struct RemindersListOptions: Equatable, Sendable { public var dueBefore: String? public var dueAfter: String? public var includeCompleted: Bool + public var degradedPrivateStore: Bool + public var limit: Int - public init(format: OutputFormat = .json, store: URL = AppleRemindersStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false) { + public init(format: OutputFormat = .json, store: URL = AppleRemindersStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false, degradedPrivateStore: Bool = false, limit: Int = 200) { self.format = format self.store = store self.list = list self.dueBefore = dueBefore self.dueAfter = dueAfter self.includeCompleted = includeCompleted + self.degradedPrivateStore = degradedPrivateStore + self.limit = limit } } @@ -537,6 +541,7 @@ public enum CLICommand: Equatable, Sendable { case providersList(OutputFormat) case remindersList(RemindersListOptions) case remindersLists(RemindersListOptions) + case remindersAuthorization(OutputFormat) case safariBookmarks(SafariBookmarksOptions) case safariFrequentlyVisited(SafariFrequentlyVisitedOptions) case safariHistory(SafariHistoryOptions) @@ -784,6 +789,7 @@ public struct CLIParser: Sendable { guard let remindersCommand = tokens.first else { throw CLIParseError.unknownCommand("reminders") } tokens.removeFirst() switch remindersCommand { + case "authorization": return .remindersAuthorization(try parseFormatOnlyOptions(tokens)) case "list": return .remindersList(try parseRemindersListOptions(tokens)) case "lists": return .remindersLists(try parseRemindersListOptions(tokens)) case "flagged": return .metadata(.remindersFlagged, try parseMetadataOptions(tokens)) @@ -1124,6 +1130,11 @@ public struct CLIParser: Sendable { case "--due-before": options.dueBefore = try value(after: token, in: tokens, at: &index) case "--due-after": options.dueAfter = try value(after: token, in: tokens, at: &index) case "--completed": options.includeCompleted = true + case "--degraded-private-store": options.degradedPrivateStore = true + case "--limit": + let raw = try value(after: token, in: tokens, at: &index) + guard let limit = Int(raw), limit > 0 else { throw CLIParseError.missingValue(token) } + options.limit = limit default: throw CLIParseError.unknownCommand(token) } index += 1 @@ -1131,6 +1142,18 @@ public struct CLIParser: Sendable { return options } + private func parseFormatOnlyOptions(_ tokens: [String]) throws -> OutputFormat { + var format = OutputFormat.json + var index = 0 + while index < tokens.count { + let token = tokens[index] + guard token == "--format" else { throw CLIParseError.unknownCommand(token) } + format = try parseFormat(after: token, in: tokens, at: &index) + index += 1 + } + return format + } + private func parseSafariHistoryOptions(_ tokens: [String]) throws -> SafariHistoryOptions { var options = SafariHistoryOptions(); var index = 0 while index < tokens.count { @@ -1369,8 +1392,9 @@ Usage: icloud-cli photos shared-library [--format json|text] [--photos-store PATH] icloud-cli notes list [--folder NAME] [--modified-since ISO8601] [--include-body] [--format json|text] [--notes-store PATH] icloud-cli notes accounts|folders|tags|shared [--format json|text] [--notes-store PATH] - icloud-cli reminders lists [--format json|text] [--reminders-store PATH] - icloud-cli reminders list [--list NAME] [--due-before ISO8601] [--due-after ISO8601] [--completed] [--format json|text] [--reminders-store PATH] + icloud-cli reminders authorization [--format json|text] + icloud-cli reminders lists [--limit N] [--degraded-private-store] [--format json|text] [--reminders-store PATH] + icloud-cli reminders list [--list NAME] [--due-before ISO8601] [--due-after ISO8601] [--completed] [--limit N] [--degraded-private-store] [--format json|text] [--reminders-store PATH] icloud-cli reminders flagged|today|scheduled|assigned [--include-notes] [--since ISO8601] [--until ISO8601] [--limit N] [--format json|text] [--reminders-store PATH] icloud-cli calendar accounts|list|events [--since ISO8601] [--until ISO8601] [--include-attendees] [--include-notes] [--format json|text] [--calendar-store PATH] icloud-cli contacts list [--search QUERY] [--limit N] [--include-notes] [--format json|text] [--addressbook-db PATH] diff --git a/Sources/ICloudCLICore/CommandRunner.swift b/Sources/ICloudCLICore/CommandRunner.swift index 2cf976c..3201e5f 100644 --- a/Sources/ICloudCLICore/CommandRunner.swift +++ b/Sources/ICloudCLICore/CommandRunner.swift @@ -2,15 +2,18 @@ import Foundation public struct CommandRunner: Sendable { private let parser: CLIParser + private let remindersClient: any ReminderEventKitClient private let output: @Sendable (String) -> Void private let errorOutput: @Sendable (String) -> Void public init( parser: CLIParser = CLIParser(), + remindersClient: any ReminderEventKitClient = SystemReminderEventKitClient(), output: @escaping @Sendable (String) -> Void = { print($0) }, errorOutput: @escaping @Sendable (String) -> Void = { FileHandle.standardError.write(Data(($0 + "\n").utf8)) } ) { self.parser = parser + self.remindersClient = remindersClient self.output = output self.errorOutput = errorOutput } @@ -109,13 +112,24 @@ public struct CommandRunner: Sendable { output(try render(ProviderRegistry.manifest, format: format)) return 0 case .remindersList(let options): - let reminders = try LocalSQLiteInventoryReader(database: options.store).reminders(list: options.list, dueBefore: options.dueBefore, dueAfter: options.dueAfter, includeCompleted: options.includeCompleted) + let reminders = if options.degradedPrivateStore { + try LocalSQLiteInventoryReader(database: options.store).reminders(list: options.list, dueBefore: options.dueBefore, dueAfter: options.dueAfter, includeCompleted: options.includeCompleted, limit: options.limit) + } else { + try EventKitRemindersProvider(client: remindersClient).reminders(list: options.list, dueBefore: options.dueBefore, dueAfter: options.dueAfter, includeCompleted: options.includeCompleted, limit: options.limit) + } output(try render(reminders, format: options.format)) return 0 case .remindersLists(let options): - let lists = try LocalSQLiteInventoryReader(database: options.store).reminderLists() + let lists = if options.degradedPrivateStore { + try LocalSQLiteInventoryReader(database: options.store).reminderLists(limit: options.limit) + } else { + try EventKitRemindersProvider(client: remindersClient).lists(limit: options.limit) + } output(try render(lists, format: options.format)) return 0 + case .remindersAuthorization(let format): + output(try render(EventKitRemindersProvider(client: remindersClient).authorization(), format: format)) + return 0 case .safariBookmarks(let options): let directory = SafariProfileDirectoryResolver().directory(baseDirectory: options.safariDirectory, profile: options.profile) let bookmarks = try SafariBookmarksReader(safariDirectory: directory).readBookmarks() diff --git a/Sources/ICloudCLICore/EventKitReminders.swift b/Sources/ICloudCLICore/EventKitReminders.swift new file mode 100644 index 0000000..f3be4ce --- /dev/null +++ b/Sources/ICloudCLICore/EventKitReminders.swift @@ -0,0 +1,196 @@ +import EventKit +import Foundation + +public enum RemindersAuthorizationState: String, Codable, Equatable, Sendable { + case notDetermined = "not-determined" + case restricted + case denied + case writeOnly = "write-only" + case fullAccess = "full-access" + case unknown +} + +public struct RemindersAuthorizationReport: Codable, Equatable, Sendable { + public let provider: String + public let state: RemindersAuthorizationState + public let canRead: Bool + public let requestsAccess: Bool + public let limitations: [String] + public let nextAction: String? +} + +public struct EventKitReminderList: Codable, Equatable, Sendable { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + +public struct EventKitReminderRecord: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let listId: String + public let listName: String + public let dueAt: String? + public let isCompleted: Bool + public let priority: Int + public let notes: String? + public let createdAt: String? + + public init(id: String, title: String, listId: String, listName: String, dueAt: String?, isCompleted: Bool, priority: Int, notes: String?, createdAt: String?) { + self.id = id + self.title = title + self.listId = listId + self.listName = listName + self.dueAt = dueAt + self.isCompleted = isCompleted + self.priority = priority + self.notes = notes + self.createdAt = createdAt + } +} + +public protocol ReminderEventKitClient: Sendable { + func authorizationState() -> RemindersAuthorizationState + func fetchLists() throws -> [EventKitReminderList] + func fetchReminders() throws -> [EventKitReminderRecord] +} + +public enum RemindersProviderError: Error, LocalizedError, Equatable { + case authorizationRequired(RemindersAuthorizationState) + case fetchTimedOut + + public var errorDescription: String? { + switch self { + case .authorizationRequired(let state): + return "Reminders EventKit read access is \(state.rawValue). Grant Reminders access in System Settings > Privacy & Security > Reminders, then retry." + case .fetchTimedOut: + return "Timed out reading Reminders through EventKit." + } + } +} + +public struct EventKitRemindersProvider: Sendable { + private let client: any ReminderEventKitClient + + public init(client: any ReminderEventKitClient = SystemReminderEventKitClient()) { + self.client = client + } + + public func authorization() -> RemindersAuthorizationReport { + let state = client.authorizationState() + return RemindersAuthorizationReport( + provider: "eventkit", + state: state, + canRead: state == .fullAccess, + requestsAccess: false, + limitations: [ + "EventKit does not expose private assigned-to-me smart-list semantics.", + "Private Core Data-only fields are omitted.", + ], + nextAction: state == .fullAccess ? nil : "Grant Reminders access in System Settings > Privacy & Security > Reminders." + ) + } + + public func lists(limit: Int) throws -> [ReminderListSummary] { + try requireReadAccess() + let reminders = try client.fetchReminders() + let counts = Dictionary(grouping: reminders, by: \.listId).mapValues(\.count) + return try client.fetchLists() + .map { ReminderListSummary(name: $0.name, itemCount: counts[$0.id, default: 0]) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .prefix(boundedLimit(limit)) + .map { $0 } + } + + public func reminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool, limit: Int) throws -> [ReminderEntry] { + try requireReadAccess() + let before = dueBefore.flatMap { ISO8601DateFormatter().date(from: $0) } + let after = dueAfter.flatMap { ISO8601DateFormatter().date(from: $0) } + return try client.fetchReminders() + .filter { record in + if let list, record.listName.localizedCaseInsensitiveCompare(list) != .orderedSame { return false } + if !includeCompleted, record.isCompleted { return false } + let due = record.dueAt.flatMap { ISO8601DateFormatter().date(from: $0) } + if let before, due.map({ $0 > before }) ?? true { return false } + if let after, due.map({ $0 < after }) ?? true { return false } + return true + } + .sorted { ($0.dueAt ?? "9999", $0.title, $0.id) < ($1.dueAt ?? "9999", $1.title, $1.id) } + .prefix(boundedLimit(limit)) + .map { ReminderEntry(title: $0.title, listName: $0.listName, dueAt: $0.dueAt, isCompleted: $0.isCompleted, priority: $0.priority, notes: $0.notes, createdAt: $0.createdAt) } + } + + private func requireReadAccess() throws { + let state = client.authorizationState() + guard state == .fullAccess else { throw RemindersProviderError.authorizationRequired(state) } + } + + private func boundedLimit(_ value: Int) -> Int { + max(1, min(value, 1_000)) + } +} + +public final class SystemReminderEventKitClient: ReminderEventKitClient, @unchecked Sendable { + private let store: EKEventStore + private let fetchTimeout: TimeInterval + + public init(store: EKEventStore = EKEventStore(), fetchTimeout: TimeInterval = 5) { + self.store = store + self.fetchTimeout = max(0.1, fetchTimeout) + } + + public func authorizationState() -> RemindersAuthorizationState { + Self.authorizationState() + } + + public static func authorizationState() -> RemindersAuthorizationState { + switch EKEventStore.authorizationStatus(for: .reminder) { + case .notDetermined: return .notDetermined + case .restricted: return .restricted + case .denied: return .denied + case .writeOnly: return .writeOnly + case .fullAccess, .authorized: return .fullAccess + @unknown default: return .unknown + } + } + + public func fetchLists() throws -> [EventKitReminderList] { + store.calendars(for: .reminder).map { EventKitReminderList(id: $0.calendarIdentifier, name: $0.title) } + } + + public func fetchReminders() throws -> [EventKitReminderRecord] { + let result = ReminderFetchBox() + let completed = DispatchSemaphore(value: 0) + store.fetchReminders(matching: store.predicateForReminders(in: nil)) { reminders in + result.value = reminders ?? [] + completed.signal() + } + guard completed.wait(timeout: .now() + fetchTimeout) == .success else { throw RemindersProviderError.fetchTimedOut } + return result.value.map { reminder in + EventKitReminderRecord( + id: reminder.calendarItemIdentifier, + title: reminder.title, + listId: reminder.calendar.calendarIdentifier, + listName: reminder.calendar.title, + dueAt: reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }.map { ISO8601DateFormatter().string(from: $0) }, + isCompleted: reminder.isCompleted, + priority: reminder.priority, + notes: reminder.notes, + createdAt: reminder.creationDate.map { ISO8601DateFormatter().string(from: $0) } + ) + } + } +} + +private final class ReminderFetchBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: [EKReminder] = [] + var value: [EKReminder] { + get { lock.lock(); defer { lock.unlock() }; return storage } + set { lock.lock(); defer { lock.unlock() }; storage = newValue } + } +} diff --git a/Sources/ICloudCLICore/LocalInventories.swift b/Sources/ICloudCLICore/LocalInventories.swift index ccb41c9..d712066 100644 --- a/Sources/ICloudCLICore/LocalInventories.swift +++ b/Sources/ICloudCLICore/LocalInventories.swift @@ -154,6 +154,16 @@ public struct ReminderEntry: Codable, Equatable, Sendable { case title, listName, dueAt, isCompleted, priority, notes, createdAt } + public init(title: String, listName: String, dueAt: String?, isCompleted: Bool, priority: Int, notes: String?, createdAt: String?) { + self.title = title + self.listName = listName + self.dueAt = dueAt + self.isCompleted = isCompleted + self.priority = priority + self.notes = notes + self.createdAt = createdAt + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) title = try container.decode(String.self, forKey: .title) @@ -169,6 +179,11 @@ public struct ReminderEntry: Codable, Equatable, Sendable { public struct ReminderListSummary: Codable, Equatable, Sendable { public let name: String public let itemCount: Int + + public init(name: String, itemCount: Int) { + self.name = name + self.itemCount = itemCount + } } public struct SafariHistoryEntry: Codable, Equatable, Sendable { @@ -309,16 +324,16 @@ public struct LocalSQLiteInventoryReader: Sendable { """) } - public func reminderLists() throws -> [ReminderListSummary] { + public func reminderLists(limit: Int = 200) throws -> [ReminderListSummary] { if try tableExists("ZREMCDREMINDER"), try tableExists("ZREMCDBASELIST") { - return try appleReminderLists() + return try appleReminderLists(limit: limit) } - return try query("SELECT listName AS name, COUNT(*) AS itemCount FROM reminders GROUP BY listName ORDER BY listName ASC;") + return try query("SELECT listName AS name, COUNT(*) AS itemCount FROM reminders GROUP BY listName ORDER BY listName ASC LIMIT \(bounded(limit, defaultValue: 200, max: 1000));") } - public func reminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool) throws -> [ReminderEntry] { + public func reminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool, limit: Int = 200) throws -> [ReminderEntry] { if try tableExists("ZREMCDREMINDER"), try tableExists("ZREMCDBASELIST") { - return try appleReminders(list: list, dueBefore: dueBefore, dueAfter: dueAfter, includeCompleted: includeCompleted) + return try appleReminders(list: list, dueBefore: dueBefore, dueAfter: dueAfter, includeCompleted: includeCompleted, limit: limit) } var filters: [String?] = [ list.map { "listName = '\(sqlEscape($0))'" }, @@ -327,10 +342,10 @@ public struct LocalSQLiteInventoryReader: Sendable { ] if !includeCompleted { filters.append("isCompleted = 0") } let whereClause = andClause(filters) - return try query("SELECT title, listName, dueAt, isCompleted, priority, notes, createdAt FROM reminders\(whereClause) ORDER BY dueAt ASC, createdAt ASC;") + return try query("SELECT title, listName, dueAt, isCompleted, priority, notes, createdAt FROM reminders\(whereClause) ORDER BY dueAt ASC, createdAt ASC LIMIT \(bounded(limit, defaultValue: 200, max: 1000));") } - private func appleReminderLists() throws -> [ReminderListSummary] { + private func appleReminderLists(limit: Int) throws -> [ReminderListSummary] { try query(""" SELECT COALESCE(NULLIF(l.ZNAME, ''), 'List ' || l.Z_PK) AS name, @@ -340,11 +355,12 @@ public struct LocalSQLiteInventoryReader: Sendable { AND COALESCE(r.ZMARKEDFORDELETION, 0) = 0 WHERE COALESCE(l.ZMARKEDFORDELETION, 0) = 0 GROUP BY l.Z_PK - ORDER BY name ASC; + ORDER BY name ASC + LIMIT \(bounded(limit, defaultValue: 200, max: 1000)); """) } - private func appleReminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool) throws -> [ReminderEntry] { + private func appleReminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool, limit: Int) throws -> [ReminderEntry] { let dueExpression = appleDateExpression("r.ZDUEDATE") let createdExpression = appleDateExpression("r.ZCREATIONDATE") var filters: [String?] = [ @@ -369,7 +385,8 @@ public struct LocalSQLiteInventoryReader: Sendable { FROM ZREMCDREMINDER r LEFT JOIN ZREMCDBASELIST l ON l.Z_PK = r.ZLIST \(whereClause) - ORDER BY dueAt ASC, createdAt ASC; + ORDER BY dueAt ASC, createdAt ASC + LIMIT \(bounded(limit, defaultValue: 200, max: 1000)); """) } diff --git a/Sources/ICloudCLICore/ProviderManifest.swift b/Sources/ICloudCLICore/ProviderManifest.swift index 6cb28b3..36af7bb 100644 --- a/Sources/ICloudCLICore/ProviderManifest.swift +++ b/Sources/ICloudCLICore/ProviderManifest.swift @@ -53,7 +53,7 @@ public enum ProviderRegistry { provider("news", "News", .beta, .sqlite, .moderate, ["news history", "news topics"], ["history", "topics", "date-filtering"]), provider("notes", "Notes", .beta, .sqlite, .high, ["notes accounts", "notes folders", "notes list", "notes shared", "notes tags"], ["accounts", "folders", "inventory", "sharing", "tags"]), provider("photos", "Photos", .beta, .mixed, .high, ["photos list", "photos screenshots", "photos shared-albums", "photos shared-library"], ["assets", "screenshots", "sharing"]), - provider("reminders", "Reminders", .beta, .sqlite, .high, ["reminders assigned", "reminders flagged", "reminders list", "reminders lists", "reminders scheduled", "reminders today"], ["inventory", "lists", "smart-views"]), + provider("reminders", "Reminders", .beta, .mixed, .high, ["reminders assigned", "reminders authorization", "reminders flagged", "reminders list", "reminders lists", "reminders scheduled", "reminders today"], ["degraded-private-store", "eventkit-primary", "inventory", "lists", "smart-views"], permissionExpectations: ["eventkit-reminders-read", "full-disk-access-only-for-explicit-degraded-fallback"]), provider("safari", "Safari", .beta, .mixed, .high, ["safari bookmarks", "safari cloud-tabs list", "safari cloud-tabs probe", "safari extensions list", "safari frequently-visited", "safari history", "safari profiles list", "safari reading-list", "safari tabs"], ["bookmarks", "cloud-tabs", "extensions", "history", "profiles", "tabs"]), provider("shortcuts", "Shortcuts", .stable, .filesystem, .moderate, ["shortcuts list"], ["inventory", "search"], true), provider("stocks", "Stocks", .beta, .sqlite, .moderate, ["stocks groups", "stocks watchlist"], ["groups", "watchlist"]), @@ -72,7 +72,8 @@ public enum ProviderRegistry { _ sensitivity: ProviderSensitivity, _ commands: [String], _ capabilities: [String], - _ defaultPolling: Bool = false + _ defaultPolling: Bool = false, + permissionExpectations: [String]? = nil ) -> ProviderDescriptor { ProviderDescriptor( id: id, @@ -81,7 +82,7 @@ public enum ProviderRegistry { sourceKind: sourceKind, accessMode: .readOnly, sensitivity: sensitivity, - permissionExpectations: permissions(for: sourceKind), + permissionExpectations: (permissionExpectations ?? permissions(for: sourceKind)).sorted(), commands: commands.sorted(), capabilities: capabilities.sorted(), defaultPolling: defaultPolling diff --git a/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift b/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift index 42422b8..265a98d 100644 --- a/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift +++ b/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift @@ -27,8 +27,8 @@ import Testing ["icloud-cli", "photos", "screenshots", "--screenshots-dir", media.screenshots.path], ["icloud-cli", "photos", "list", "--photos-library", media.photosLibrary.path], ["icloud-cli", "notes", "list", "--notes-store", database.path], - ["icloud-cli", "reminders", "lists", "--reminders-store", database.path], - ["icloud-cli", "reminders", "list", "--reminders-store", database.path], + ["icloud-cli", "reminders", "lists", "--degraded-private-store", "--reminders-store", database.path], + ["icloud-cli", "reminders", "list", "--degraded-private-store", "--reminders-store", database.path], ["icloud-cli", "safari", "history", "--confirm-sensitive", "--history-db", database.path], ["icloud-cli", "messages", "conversations", "--chat-db", database.path], ["icloud-cli", "messages", "recent", "--confirm-sensitive", "--chat-db", database.path], diff --git a/Tests/ICloudCLICoreTests/EventKitRemindersTests.swift b/Tests/ICloudCLICoreTests/EventKitRemindersTests.swift new file mode 100644 index 0000000..076069e --- /dev/null +++ b/Tests/ICloudCLICoreTests/EventKitRemindersTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import ICloudCLICore + +@Test func eventKitProviderReadsAndBoundsListsAndReminders() throws { + let client = FakeReminderEventKitClient( + state: .fullAccess, + lists: [ + EventKitReminderList(id: "work", name: "Work"), + EventKitReminderList(id: "home", name: "Home"), + ], + reminders: [ + eventKitReminder(id: "1", title: "Ship PR", listId: "work", listName: "Work", dueAt: "2026-07-12T12:00:00Z"), + eventKitReminder(id: "2", title: "Done", listId: "work", listName: "Work", completed: true), + eventKitReminder(id: "3", title: "Buy milk", listId: "home", listName: "Home"), + ] + ) + let provider = EventKitRemindersProvider(client: client) + + #expect(try provider.lists(limit: 1).map(\.name) == ["Home"]) + let reminders = try provider.reminders(list: "Work", dueBefore: "2026-07-13T00:00:00Z", dueAfter: nil, includeCompleted: false, limit: 10) + #expect(reminders.map(\.title) == ["Ship PR"]) + #expect(reminders.first?.notes == nil) +} + +@Test func eventKitProviderReportsAuthorizationWithoutRequestingAccess() { + let provider = EventKitRemindersProvider(client: FakeReminderEventKitClient(state: .denied)) + #expect(provider.authorization().state == .denied) + #expect(provider.authorization().canRead == false) + #expect(throws: RemindersProviderError.authorizationRequired(.denied)) { + try provider.lists(limit: 10) + } +} + +@Test func commandRunnerUsesEventKitAndExposesPermissionSafeSmoke() throws { + let client = FakeReminderEventKitClient( + state: .fullAccess, + lists: [EventKitReminderList(id: "work", name: "Work")], + reminders: [eventKitReminder(id: "1", title: "Ship PR", listId: "work", listName: "Work")] + ) + final class Sink: @unchecked Sendable { var output: [String] = []; var errors: [String] = [] } + let sink = Sink() + let runner = CommandRunner(remindersClient: client, output: { sink.output.append($0) }, errorOutput: { sink.errors.append($0) }) + + #expect(runner.run(arguments: ["icloud-cli", "reminders", "authorization"]) == 0) + #expect(runner.run(arguments: ["icloud-cli", "reminders", "lists", "--limit", "10"]) == 0) + #expect(runner.run(arguments: ["icloud-cli", "reminders", "list", "--list", "Work", "--limit", "10"]) == 0) + #expect(sink.errors.isEmpty) + #expect(try JSONDecoder().decode(RemindersAuthorizationReport.self, from: Data(sink.output[0].utf8)).state == .fullAccess) + #expect(try JSONDecoder().decode([ReminderListSummary].self, from: Data(sink.output[1].utf8)).first?.name == "Work") + #expect(try JSONDecoder().decode([ReminderEntry].self, from: Data(sink.output[2].utf8)).first?.title == "Ship PR") +} + +@Test func privateStoreFallbackRequiresExplicitFlag() throws { + let parsed = try CLIParser().parse(arguments: ["icloud-cli", "reminders", "list", "--degraded-private-store", "--reminders-store", "/tmp/reminders.sqlite"]) + guard case .remindersList(let options) = parsed else { Issue.record("Expected reminders list"); return } + #expect(options.degradedPrivateStore) + #expect(options.store.path == "/tmp/reminders.sqlite") +} + +@Test func commandRunnerBoundsDegradedPrivateStoreResults() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("icloud-cli-reminders-limit-\(UUID().uuidString)") + let database = root.appendingPathComponent("reminders.sqlite") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try runSQLite(database: database, sql: """ + CREATE TABLE reminders (title TEXT, listName TEXT, dueAt TEXT, isCompleted INTEGER, priority INTEGER, notes TEXT, createdAt TEXT); + INSERT INTO reminders VALUES ('First', 'Alpha', '2026-01-01T00:00:00Z', 0, 0, NULL, '2025-12-01T00:00:00Z'); + INSERT INTO reminders VALUES ('Second', 'Beta', '2026-01-02T00:00:00Z', 0, 0, NULL, '2025-12-02T00:00:00Z'); + INSERT INTO reminders VALUES ('Third', 'Beta', '2026-01-03T00:00:00Z', 0, 0, NULL, '2025-12-03T00:00:00Z'); + """) + + final class Sink: @unchecked Sendable { var output: [String] = []; var errors: [String] = [] } + let sink = Sink() + let runner = CommandRunner(output: { sink.output.append($0) }, errorOutput: { sink.errors.append($0) }) + + #expect(runner.run(arguments: ["icloud-cli", "reminders", "lists", "--degraded-private-store", "--reminders-store", database.path, "--limit", "1"]) == 0) + #expect(runner.run(arguments: ["icloud-cli", "reminders", "list", "--degraded-private-store", "--reminders-store", database.path, "--limit", "1"]) == 0) + + #expect(try JSONDecoder().decode([ReminderListSummary].self, from: Data(sink.output[0].utf8)).map(\.name) == ["Alpha"]) + #expect(try JSONDecoder().decode([ReminderEntry].self, from: Data(sink.output[1].utf8)).map(\.title) == ["First"]) + #expect(sink.errors.isEmpty) +} + +@Test func permissionsDoctorReportsEventKitAuthorization() { + let probes = PermissionsDoctor(remindersAuthorization: .denied).diagnose() + let reminders = probes.first { $0.command == "reminders list" } + #expect(reminders?.status == "eventkit-denied") + #expect(reminders?.paths.isEmpty == true) + #expect(reminders?.hint.contains("System Settings") == true) +} + +private struct FakeReminderEventKitClient: ReminderEventKitClient { + let state: RemindersAuthorizationState + var lists: [EventKitReminderList] = [] + var reminders: [EventKitReminderRecord] = [] + + func authorizationState() -> RemindersAuthorizationState { state } + func fetchLists() throws -> [EventKitReminderList] { lists } + func fetchReminders() throws -> [EventKitReminderRecord] { reminders } +} + +private func eventKitReminder( + id: String, + title: String, + listId: String, + listName: String, + dueAt: String? = nil, + completed: Bool = false +) -> EventKitReminderRecord { + EventKitReminderRecord(id: id, title: title, listId: listId, listName: listName, dueAt: dueAt, isCompleted: completed, priority: 0, notes: nil, createdAt: nil) +} + +private func runSQLite(database: URL, sql: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [database.path, sql] + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) +} diff --git a/Tests/ICloudCLICoreTests/ProviderManifestTests.swift b/Tests/ICloudCLICoreTests/ProviderManifestTests.swift index 74f6665..bebbb29 100644 --- a/Tests/ICloudCLICoreTests/ProviderManifestTests.swift +++ b/Tests/ICloudCLICoreTests/ProviderManifestTests.swift @@ -21,6 +21,11 @@ import Testing ]) #expect(manifest.providers.allSatisfy { !$0.commands.isEmpty && !$0.capabilities.isEmpty }) #expect(manifest.providers.allSatisfy { $0.accessMode == .readOnly }) + let reminders = manifest.providers.first { $0.id == "reminders" } + #expect(reminders?.sourceKind == .mixed) + #expect(reminders?.commands.contains("reminders authorization") == true) + #expect(reminders?.capabilities.contains("eventkit-primary") == true) + #expect(reminders?.permissionExpectations == ["eventkit-reminders-read", "full-disk-access-only-for-explicit-degraded-fallback"]) } @Test func providerManifestJSONContainsMetadataOnly() throws { diff --git a/docs/adr/001-eventkit-reminders-action-boundary.md b/docs/adr/001-eventkit-reminders-action-boundary.md new file mode 100644 index 0000000..74f658f --- /dev/null +++ b/docs/adr/001-eventkit-reminders-action-boundary.md @@ -0,0 +1,31 @@ +# ADR 001: EventKit Reminders and the action boundary + +Status: accepted + +## Context + +Reminders data is sensitive and Apple's private on-disk schema changes between macOS releases. Read-only inventory should use the supported EventKit API. Private-store access remains useful for explicit recovery and compatibility diagnostics, but must not be the normal path. + +## Decision + +- `reminders list` and `reminders lists` use EventKit by default and never request access implicitly. +- `reminders authorization` reports the current EventKit state without triggering a prompt. +- Operators may select the read-only, schema-checked private-store reader with `--degraded-private-store`. This path may require Full Disk Access and is not suitable for unattended default polling. +- Smart views (`flagged`, `today`, `scheduled`, and `assigned`) remain private-store compatibility commands until equivalent supported behavior is implemented. Their output is read-only and version/schema checked. +- This issue adds no mutation capability. + +## Future actions + +Any future Reminders mutation belongs under a separate `actions reminders` namespace and requires a new decision and implementation issue. That work must provide: + +1. a dry-run default that describes the intended mutation; +2. explicit confirmation bound to the proposed operation; +3. a structured, locally retained audit record with reminder content redacted by default; +4. least-privilege EventKit authorization and a clear denied/restricted failure; +5. bounded execution and stable identifiers where EventKit supplies them. + +EventKit does not provide transactions or guaranteed rollback across iCloud synchronization. A create may be compensatable by deleting the created identifier; an update may retain a best-effort preimage; and completion may be reversible while the identifier remains available. None of these are guaranteed rollback. The CLI must disclose that limit before confirmation and must not claim atomic recovery. + +## Consequences + +Supported reads become more resilient to macOS schema changes and permission diagnostics distinguish Reminders authorization from Full Disk Access. Some private smart-list concepts remain explicitly degraded until supported API semantics are defined. diff --git a/docs/privacy.md b/docs/privacy.md index a98a721..2753800 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -52,7 +52,7 @@ OpenClaw integrations should default to local retention. Exporting raw browsing | `icloud-cli photos screenshots` | Screenshot file metadata under `~/Pictures/Screenshots` by default | Normal user file access. The command does not read pixel data or thumbnails. Logs should redact home-directory paths. | | `icloud-cli photos list` | Local Photos library file metadata under `~/Pictures/Photos Library.photoslibrary` by default | Full Disk Access may be needed. The command emits file metadata only and does not export pixels, thumbnails, or EXIF location data. | | `icloud-cli notes list` | Local Notes SQLite metadata from `~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite` by default | Full Disk Access may be needed. Body content is omitted unless `--include-body` is passed. Logs should redact titles and always omit bodies. | -| `icloud-cli reminders list` / `reminders lists` | Local Reminders metadata under `~/Library/Reminders` by default | Normal user file access or Full Disk Access depending on macOS privacy posture. Reminder titles and notes are sensitive; logs should summarize list counts. | +| `icloud-cli reminders list` / `reminders lists` | EventKit reminder and list metadata by default | Reminders authorization is required. `reminders authorization` checks state without prompting. The explicit `--degraded-private-store` fallback reads a version-checked private store and may require Full Disk Access. Reminder titles and notes are sensitive; logs should summarize list counts. | | `icloud-cli safari history` | `~/Library/Safari/History.db` | Full Disk Access is expected. Requires `--confirm-sensitive`; `--redact-urls` is recommended for automated export. This command is not part of default watch polling. | | `icloud-cli messages conversations` / `messages recent` | `~/Library/Messages/chat.db` | Full Disk Access is expected. Recent message reads require `--confirm-sensitive`; bodies require `--include-body`. Message commands are not part of default watch polling. | | `icloud-cli contacts list` | Local AddressBook SQLite metadata under `~/Library/Application Support/AddressBook` by default | Contacts permission or Full Disk Access may be needed. Notes are omitted unless `--include-notes` is passed. | @@ -63,6 +63,8 @@ OpenClaw integrations should default to local retention. Exporting raw browsing Automation permission is not required for the current Safari tab reader because it reads local files. Any future command that controls Safari, System Settings, or another app must document the Automation prompt and failure mode before merge. +Reminders commands are read-only. EventKit is the primary source for `list` and `lists`; private smart views and the explicit degraded fallback can break when Apple changes its schema and therefore fail closed on unsupported shapes. Any future mutation must live under a separate `actions reminders` namespace and follow [ADR 001](adr/001-eventkit-reminders-action-boundary.md). + ## Fixtures And Tests Fixtures must be synthetic. The fast gate runs `scripts/check-privacy-fixtures.sh`, which rejects fixture content containing local home paths, known live Safari database names, file URLs, or non-example web URLs. Use `example.com`, `example.org`, or `example.net` for tab fixtures. diff --git a/docs/provider-manifest.md b/docs/provider-manifest.md index 947daed..c8123ce 100644 --- a/docs/provider-manifest.md +++ b/docs/provider-manifest.md @@ -26,6 +26,8 @@ Each provider declares: The registry is static: it contains no payload records, account identifiers, user paths, permission probes, or other machine-local state. `--format text` renders the same fields for operators. +Provider-specific permission expectations may replace the generic source-kind default. Reminders declares EventKit authorization for its primary reads and Full Disk Access only for its explicit degraded private-store fallback. + ## External control-plane projection `icloud-cli providers external-manifest --format json` embeds this exact registry in the `providerManifest` field of `icloud-cli.openclaw.external.v1`. The projection adds action-class policy only; it does not repeat per-provider command metadata. See [the OpenClaw control-plane contract](openclaw-skill-contract.md) for wrapper, confirmation, redaction, timeout, retention, and structured-error requirements.