Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
20 changes: 18 additions & 2 deletions Sources/ICloudCLICore/AppleMetadataInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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)] {
Expand Down
30 changes: 27 additions & 3 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -1124,13 +1130,30 @@ 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
}
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 {
Expand Down Expand Up @@ -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]
Expand Down
18 changes: 16 additions & 2 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
} 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()
} 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()
Expand Down
196 changes: 196 additions & 0 deletions Sources/ICloudCLICore/EventKitReminders.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
15 changes: 15 additions & 0 deletions Sources/ICloudCLICore/LocalInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading