@l22-io/orchard-mcp 0.3.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/build/bridge.js +18 -0
- package/build/bridge.js.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +37 -0
- package/build/tools/contacts.js.map +1 -0
- package/build/tools/files.js +4 -1
- package/build/tools/files.js.map +1 -1
- package/build/tools/keynote.d.ts +2 -0
- package/build/tools/keynote.js +198 -0
- package/build/tools/keynote.js.map +1 -0
- package/build/tools/mail.js +57 -10
- package/build/tools/mail.js.map +1 -1
- package/build/tools/notes.d.ts +2 -0
- package/build/tools/notes.js +83 -0
- package/build/tools/notes.js.map +1 -0
- package/build/tools/numbers.d.ts +2 -0
- package/build/tools/numbers.js +167 -0
- package/build/tools/numbers.js.map +1 -0
- package/build/tools/pages.d.ts +2 -0
- package/build/tools/pages.js +143 -0
- package/build/tools/pages.js.map +1 -0
- package/package.json +14 -9
- package/scripts/postinstall.sh +77 -8
- package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
- package/swift/.build/AppleBridge.app.sha256 +1 -0
- package/swift/Package.resolved +14 -0
- package/swift/Package.swift +28 -0
- package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
- package/swift/Sources/AppleBridge/Calendar.swift +221 -0
- package/swift/Sources/AppleBridge/Contacts.swift +225 -0
- package/swift/Sources/AppleBridge/Doctor.swift +252 -0
- package/swift/Sources/AppleBridge/Files.swift +474 -0
- package/swift/Sources/AppleBridge/JSON.swift +57 -0
- package/swift/Sources/AppleBridge/Keynote.swift +599 -0
- package/swift/Sources/AppleBridge/Mail.swift +794 -0
- package/swift/Sources/AppleBridge/Notes.swift +263 -0
- package/swift/Sources/AppleBridge/Numbers.swift +601 -0
- package/swift/Sources/AppleBridge/Pages.swift +467 -0
- package/swift/Sources/AppleBridge/Reminders.swift +347 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import EventKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
// Reason: All calendar access goes through EventKit, which properly handles
|
|
5
|
+
// recurring event expansion via predicateForEvents -- something AppleScript cannot do.
|
|
6
|
+
|
|
7
|
+
enum CalendarBridge {
|
|
8
|
+
private static let store = EKEventStore()
|
|
9
|
+
|
|
10
|
+
/// Request full calendar access. Returns true if granted.
|
|
11
|
+
static func requestAccess() async -> Bool {
|
|
12
|
+
do {
|
|
13
|
+
return try await store.requestFullAccessToEvents()
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Check current authorization status without prompting.
|
|
20
|
+
static func authorizationStatus() -> EKAuthorizationStatus {
|
|
21
|
+
return EKEventStore.authorizationStatus(for: .event)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// List all calendars with account and metadata.
|
|
25
|
+
static func listCalendars() async {
|
|
26
|
+
guard await requestAccess() else {
|
|
27
|
+
JSONOutput.error("Calendar access denied. Grant access in System Settings > Privacy & Security > Calendars.")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let calendars = store.calendars(for: .event)
|
|
32
|
+
let result: [[String: Any]] = calendars.map { cal in
|
|
33
|
+
var dict: [String: Any] = [
|
|
34
|
+
"id": cal.calendarIdentifier,
|
|
35
|
+
"title": cal.title,
|
|
36
|
+
"type": calendarTypeName(cal.type),
|
|
37
|
+
"allowsModify": cal.allowsContentModifications
|
|
38
|
+
]
|
|
39
|
+
if let source = cal.source {
|
|
40
|
+
dict["account"] = source.title
|
|
41
|
+
dict["accountType"] = sourceTypeName(source.sourceType)
|
|
42
|
+
}
|
|
43
|
+
if let color = cal.cgColor {
|
|
44
|
+
// Reason: Convert CGColor to hex for easy display/filtering.
|
|
45
|
+
let components = color.components ?? []
|
|
46
|
+
if components.count >= 3 {
|
|
47
|
+
let r = Int(components[0] * 255)
|
|
48
|
+
let g = Int(components[1] * 255)
|
|
49
|
+
let b = Int(components[2] * 255)
|
|
50
|
+
dict["color"] = String(format: "#%02x%02x%02x", r, g, b)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return dict
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
JSONOutput.success(result)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Fetch events in a date range, optionally filtered by calendar ID.
|
|
60
|
+
/// Recurring events are properly expanded via predicateForEvents.
|
|
61
|
+
static func listEvents(startISO: String, endISO: String, calendarID: String?) async {
|
|
62
|
+
guard await requestAccess() else {
|
|
63
|
+
JSONOutput.error("Calendar access denied.")
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let formatter = ISO8601DateFormatter()
|
|
68
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
69
|
+
|
|
70
|
+
guard let startDate = formatter.date(from: startISO) ?? parseFlexibleISO(startISO) else {
|
|
71
|
+
JSONOutput.error("Invalid start date: \(startISO). Use ISO 8601 format.")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
guard let endDate = formatter.date(from: endISO) ?? parseFlexibleISO(endISO) else {
|
|
75
|
+
JSONOutput.error("Invalid end date: \(endISO). Use ISO 8601 format.")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var calendars: [EKCalendar]? = nil
|
|
80
|
+
if let calID = calendarID {
|
|
81
|
+
if let cal = store.calendar(withIdentifier: calID) {
|
|
82
|
+
calendars = [cal]
|
|
83
|
+
} else {
|
|
84
|
+
JSONOutput.error("Calendar not found: \(calID)")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let predicate = store.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars)
|
|
90
|
+
let events = store.events(matching: predicate)
|
|
91
|
+
|
|
92
|
+
let result: [[String: Any]] = events.map { evt in
|
|
93
|
+
var dict: [String: Any] = [
|
|
94
|
+
"id": evt.eventIdentifier ?? "",
|
|
95
|
+
"title": evt.title ?? "(no title)",
|
|
96
|
+
"start": iso8601(evt.startDate),
|
|
97
|
+
"end": iso8601(evt.endDate),
|
|
98
|
+
"isAllDay": evt.isAllDay,
|
|
99
|
+
"calendar": evt.calendar.title,
|
|
100
|
+
"calendarId": evt.calendar.calendarIdentifier
|
|
101
|
+
]
|
|
102
|
+
if let location = evt.location, !location.isEmpty {
|
|
103
|
+
dict["location"] = location
|
|
104
|
+
}
|
|
105
|
+
if let notes = evt.notes, !notes.isEmpty {
|
|
106
|
+
dict["notes"] = notes
|
|
107
|
+
}
|
|
108
|
+
if let url = evt.url {
|
|
109
|
+
dict["url"] = url.absoluteString
|
|
110
|
+
}
|
|
111
|
+
if evt.hasRecurrenceRules {
|
|
112
|
+
dict["isRecurring"] = true
|
|
113
|
+
}
|
|
114
|
+
if let attendees = evt.attendees, !attendees.isEmpty {
|
|
115
|
+
dict["attendees"] = attendees.map { a in
|
|
116
|
+
var info: [String: Any] = [
|
|
117
|
+
"name": a.name ?? a.url.absoluteString,
|
|
118
|
+
"status": participantStatusName(a.participantStatus)
|
|
119
|
+
]
|
|
120
|
+
if a.isCurrentUser {
|
|
121
|
+
info["isMe"] = true
|
|
122
|
+
}
|
|
123
|
+
return info
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return dict
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
JSONOutput.success(result)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Search events by title within a date range.
|
|
133
|
+
static func searchEvents(query: String, startISO: String, endISO: String) async {
|
|
134
|
+
guard await requestAccess() else {
|
|
135
|
+
JSONOutput.error("Calendar access denied.")
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let formatter = ISO8601DateFormatter()
|
|
140
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
141
|
+
|
|
142
|
+
guard let startDate = formatter.date(from: startISO) ?? parseFlexibleISO(startISO) else {
|
|
143
|
+
JSONOutput.error("Invalid start date: \(startISO)")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
guard let endDate = formatter.date(from: endISO) ?? parseFlexibleISO(endISO) else {
|
|
147
|
+
JSONOutput.error("Invalid end date: \(endISO)")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let predicate = store.predicateForEvents(withStart: startDate, end: endDate, calendars: nil)
|
|
152
|
+
let events = store.events(matching: predicate)
|
|
153
|
+
|
|
154
|
+
let lowered = query.lowercased()
|
|
155
|
+
let filtered = events.filter { evt in
|
|
156
|
+
let title = (evt.title ?? "").lowercased()
|
|
157
|
+
let notes = (evt.notes ?? "").lowercased()
|
|
158
|
+
let location = (evt.location ?? "").lowercased()
|
|
159
|
+
return title.contains(lowered) || notes.contains(lowered) || location.contains(lowered)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let result: [[String: Any]] = filtered.map { evt in
|
|
163
|
+
[
|
|
164
|
+
"id": evt.eventIdentifier ?? "",
|
|
165
|
+
"title": evt.title ?? "(no title)",
|
|
166
|
+
"start": iso8601(evt.startDate),
|
|
167
|
+
"end": iso8601(evt.endDate),
|
|
168
|
+
"isAllDay": evt.isAllDay,
|
|
169
|
+
"calendar": evt.calendar.title
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
JSONOutput.success(result)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - Helpers
|
|
177
|
+
|
|
178
|
+
private static func parseFlexibleISO(_ str: String) -> Date? {
|
|
179
|
+
// Reason: Accept date-only strings like "2026-02-17" without time component.
|
|
180
|
+
let formatter = ISO8601DateFormatter()
|
|
181
|
+
formatter.formatOptions = [.withFullDate]
|
|
182
|
+
return formatter.date(from: str)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private static func calendarTypeName(_ type: EKCalendarType) -> String {
|
|
186
|
+
switch type {
|
|
187
|
+
case .local: return "local"
|
|
188
|
+
case .calDAV: return "caldav"
|
|
189
|
+
case .exchange: return "exchange"
|
|
190
|
+
case .subscription: return "subscription"
|
|
191
|
+
case .birthday: return "birthday"
|
|
192
|
+
@unknown default: return "unknown"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private static func sourceTypeName(_ type: EKSourceType) -> String {
|
|
197
|
+
switch type {
|
|
198
|
+
case .local: return "local"
|
|
199
|
+
case .exchange: return "exchange"
|
|
200
|
+
case .calDAV: return "caldav"
|
|
201
|
+
case .mobileMe: return "mobileme"
|
|
202
|
+
case .subscribed: return "subscribed"
|
|
203
|
+
case .birthdays: return "birthdays"
|
|
204
|
+
@unknown default: return "unknown"
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private static func participantStatusName(_ status: EKParticipantStatus) -> String {
|
|
209
|
+
switch status {
|
|
210
|
+
case .unknown: return "unknown"
|
|
211
|
+
case .pending: return "pending"
|
|
212
|
+
case .accepted: return "accepted"
|
|
213
|
+
case .declined: return "declined"
|
|
214
|
+
case .tentative: return "tentative"
|
|
215
|
+
case .delegated: return "delegated"
|
|
216
|
+
case .completed: return "completed"
|
|
217
|
+
case .inProcess: return "in_process"
|
|
218
|
+
@unknown default: return "unknown"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import Contacts
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum ContactsBridge {
|
|
5
|
+
private static let store = CNContactStore()
|
|
6
|
+
|
|
7
|
+
static func requestAccess() async -> Bool {
|
|
8
|
+
await withCheckedContinuation { cont in
|
|
9
|
+
store.requestAccess(for: .contacts) { granted, _ in
|
|
10
|
+
cont.resume(returning: granted)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static func authorizationStatus() -> CNAuthorizationStatus {
|
|
16
|
+
CNContactStore.authorizationStatus(for: .contacts)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// MARK: - Keys
|
|
20
|
+
|
|
21
|
+
// Reason: CNContactNoteKey requires a restricted entitlement since macOS 11
|
|
22
|
+
// and is deliberately omitted here — requesting it would fail the fetch.
|
|
23
|
+
private static var defaultKeys: [CNKeyDescriptor] {
|
|
24
|
+
let stringKeys: [CNKeyDescriptor] = [
|
|
25
|
+
CNContactIdentifierKey,
|
|
26
|
+
CNContactGivenNameKey,
|
|
27
|
+
CNContactFamilyNameKey,
|
|
28
|
+
CNContactMiddleNameKey,
|
|
29
|
+
CNContactNicknameKey,
|
|
30
|
+
CNContactOrganizationNameKey,
|
|
31
|
+
CNContactJobTitleKey,
|
|
32
|
+
CNContactEmailAddressesKey,
|
|
33
|
+
CNContactPhoneNumbersKey,
|
|
34
|
+
CNContactPostalAddressesKey,
|
|
35
|
+
CNContactBirthdayKey,
|
|
36
|
+
CNContactUrlAddressesKey,
|
|
37
|
+
CNContactImageDataAvailableKey
|
|
38
|
+
].map { $0 as CNKeyDescriptor }
|
|
39
|
+
return stringKeys + [
|
|
40
|
+
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// MARK: - Public API
|
|
45
|
+
|
|
46
|
+
static func listGroups() async {
|
|
47
|
+
guard await requestAccess() else {
|
|
48
|
+
JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
do {
|
|
52
|
+
let groups = try store.groups(matching: nil)
|
|
53
|
+
let result: [[String: Any]] = try groups.map { g in
|
|
54
|
+
let predicate = CNContact.predicateForContactsInGroup(withIdentifier: g.identifier)
|
|
55
|
+
let members = try store.unifiedContacts(
|
|
56
|
+
matching: predicate,
|
|
57
|
+
keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
|
|
58
|
+
)
|
|
59
|
+
return [
|
|
60
|
+
"id": g.identifier,
|
|
61
|
+
"name": g.name,
|
|
62
|
+
"memberCount": members.count
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
JSONOutput.success(result)
|
|
66
|
+
} catch {
|
|
67
|
+
JSONOutput.error("Failed to list groups: \(error.localizedDescription)")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static func search(query: String, limit: Int) async {
|
|
72
|
+
guard await requestAccess() else {
|
|
73
|
+
JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
let results = findMatches(query: query)
|
|
77
|
+
let trimmed = Array(results.prefix(limit))
|
|
78
|
+
let summaries = trimmed.map { summary(for: $0) }
|
|
79
|
+
JSONOutput.success([
|
|
80
|
+
"contacts": summaries,
|
|
81
|
+
"total": results.count,
|
|
82
|
+
"limit": limit,
|
|
83
|
+
"hasMore": results.count > trimmed.count
|
|
84
|
+
] as [String: Any])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static func readContact(id: String) async {
|
|
88
|
+
guard await requestAccess() else {
|
|
89
|
+
JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
do {
|
|
93
|
+
let contact = try store.unifiedContact(
|
|
94
|
+
withIdentifier: id,
|
|
95
|
+
keysToFetch: defaultKeys
|
|
96
|
+
)
|
|
97
|
+
JSONOutput.success(fullDetail(for: contact))
|
|
98
|
+
} catch {
|
|
99
|
+
JSONOutput.error("Contact not found: \(id)")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: - Search
|
|
104
|
+
|
|
105
|
+
// Runs name / email / phone predicates, then falls back to a digits-only
|
|
106
|
+
// substring scan over every contact's phone numbers when the query looks
|
|
107
|
+
// phone-ish (CNContact.predicateForContacts(matching: CNPhoneNumber) only
|
|
108
|
+
// matches normalized full numbers — partials like "+4917" would otherwise
|
|
109
|
+
// return empty). Deduplicated by identifier.
|
|
110
|
+
private static func findMatches(query: String) -> [CNContact] {
|
|
111
|
+
var seen = Set<String>()
|
|
112
|
+
var results: [CNContact] = []
|
|
113
|
+
for predicate in predicates(for: query) {
|
|
114
|
+
do {
|
|
115
|
+
let batch = try store.unifiedContacts(matching: predicate, keysToFetch: defaultKeys)
|
|
116
|
+
for c in batch where !seen.contains(c.identifier) {
|
|
117
|
+
seen.insert(c.identifier)
|
|
118
|
+
results.append(c)
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if looksLikePhone(query) {
|
|
125
|
+
for c in phoneSubstringMatches(query: query) where !seen.contains(c.identifier) {
|
|
126
|
+
seen.insert(c.identifier)
|
|
127
|
+
results.append(c)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return results
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private static func predicates(for query: String) -> [NSPredicate] {
|
|
134
|
+
var list: [NSPredicate] = [CNContact.predicateForContacts(matchingName: query)]
|
|
135
|
+
if query.contains("@") {
|
|
136
|
+
list.append(CNContact.predicateForContacts(matchingEmailAddress: query))
|
|
137
|
+
}
|
|
138
|
+
if looksLikePhone(query) {
|
|
139
|
+
list.append(CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: query)))
|
|
140
|
+
}
|
|
141
|
+
return list
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private static func looksLikePhone(_ query: String) -> Bool {
|
|
145
|
+
query.first.map { "+0123456789".contains($0) } ?? false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private static func phoneSubstringMatches(query: String) -> [CNContact] {
|
|
149
|
+
// Normalize to digits only, then also strip any leading zeros so that
|
|
150
|
+
// national-format queries (e.g. trunk-prefixed local numbers) match
|
|
151
|
+
// international-format stored phone numbers that omit the trunk digit.
|
|
152
|
+
let digits = query.filter(\.isNumber)
|
|
153
|
+
let trimmed = String(digits.drop(while: { $0 == "0" }))
|
|
154
|
+
let candidates = Set([digits, trimmed].filter { $0.count >= 3 })
|
|
155
|
+
guard !candidates.isEmpty else { return [] }
|
|
156
|
+
|
|
157
|
+
let request = CNContactFetchRequest(keysToFetch: defaultKeys)
|
|
158
|
+
request.unifyResults = true
|
|
159
|
+
var results: [CNContact] = []
|
|
160
|
+
do {
|
|
161
|
+
try store.enumerateContacts(with: request) { contact, _ in
|
|
162
|
+
for phone in contact.phoneNumbers {
|
|
163
|
+
let phoneDigits = phone.value.stringValue.filter(\.isNumber)
|
|
164
|
+
if candidates.contains(where: { phoneDigits.contains($0) }) {
|
|
165
|
+
results.append(contact)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return results
|
|
172
|
+
}
|
|
173
|
+
return results
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - Shaping
|
|
177
|
+
|
|
178
|
+
private static func summary(for c: CNContact) -> [String: Any] {
|
|
179
|
+
[
|
|
180
|
+
"id": c.identifier,
|
|
181
|
+
"name": CNContactFormatter.string(from: c, style: .fullName) ?? "",
|
|
182
|
+
"organization": c.organizationName,
|
|
183
|
+
"emails": c.emailAddresses.map { $0.value as String },
|
|
184
|
+
"phones": c.phoneNumbers.map { $0.value.stringValue }
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static func fullDetail(for c: CNContact) -> [String: Any] {
|
|
189
|
+
var dict = summary(for: c)
|
|
190
|
+
dict["givenName"] = c.givenName
|
|
191
|
+
dict["middleName"] = c.middleName
|
|
192
|
+
dict["familyName"] = c.familyName
|
|
193
|
+
dict["nickname"] = c.nickname
|
|
194
|
+
dict["jobTitle"] = c.jobTitle
|
|
195
|
+
dict["emails"] = c.emailAddresses.map { labeled($0.label, $0.value as String) }
|
|
196
|
+
dict["phones"] = c.phoneNumbers.map { labeled($0.label, $0.value.stringValue) }
|
|
197
|
+
dict["addresses"] = c.postalAddresses.map { postalDict($0) }
|
|
198
|
+
dict["urls"] = c.urlAddresses.map { labeled($0.label, $0.value as String) }
|
|
199
|
+
if let bday = c.birthday, let date = Calendar(identifier: .gregorian).date(from: bday) {
|
|
200
|
+
dict["birthday"] = ISO8601DateFormatter().string(from: date)
|
|
201
|
+
}
|
|
202
|
+
dict["hasImage"] = c.imageDataAvailable
|
|
203
|
+
return dict
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private static func labeled(_ label: String?, _ value: String) -> [String: Any] {
|
|
207
|
+
var d: [String: Any] = ["value": value]
|
|
208
|
+
if let l = label {
|
|
209
|
+
d["label"] = CNLabeledValue<NSString>.localizedString(forLabel: l)
|
|
210
|
+
}
|
|
211
|
+
return d
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private static func postalDict(_ entry: CNLabeledValue<CNPostalAddress>) -> [String: Any] {
|
|
215
|
+
let a = entry.value
|
|
216
|
+
return [
|
|
217
|
+
"label": entry.label.map { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "",
|
|
218
|
+
"street": a.street,
|
|
219
|
+
"city": a.city,
|
|
220
|
+
"state": a.state,
|
|
221
|
+
"postalCode": a.postalCode,
|
|
222
|
+
"country": a.country
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import Contacts
|
|
2
|
+
import EventKit
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
// Reason: The doctor subcommand provides a single entry point to verify
|
|
6
|
+
// all permissions are granted and list accessible resources. Useful for
|
|
7
|
+
// first-run setup and debugging.
|
|
8
|
+
|
|
9
|
+
enum DoctorBridge {
|
|
10
|
+
static func run() async {
|
|
11
|
+
var report: [String: Any] = [
|
|
12
|
+
"version": "0.5.0",
|
|
13
|
+
"platform": "macOS",
|
|
14
|
+
"systemVersion": ProcessInfo.processInfo.operatingSystemVersionString
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
// Calendar permissions
|
|
18
|
+
let calStatus = EKEventStore.authorizationStatus(for: .event)
|
|
19
|
+
let calStatusStr = authStatusName(calStatus)
|
|
20
|
+
report["calendar"] = [
|
|
21
|
+
"status": calStatusStr,
|
|
22
|
+
"granted": calStatus == .fullAccess
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
// Reason: If not determined, attempt to request so the user gets prompted.
|
|
26
|
+
if calStatus == .notDetermined {
|
|
27
|
+
let granted = await CalendarBridge.requestAccess()
|
|
28
|
+
report["calendar"] = [
|
|
29
|
+
"status": granted ? "fullAccess" : "denied",
|
|
30
|
+
"granted": granted,
|
|
31
|
+
"note": "Permission was just requested."
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If we have calendar access, list calendar count and accounts
|
|
36
|
+
if calStatus == .fullAccess || calStatus == .notDetermined {
|
|
37
|
+
let store = EKEventStore()
|
|
38
|
+
_ = try? await store.requestFullAccessToEvents()
|
|
39
|
+
let calendars = store.calendars(for: .event)
|
|
40
|
+
let accounts = Set(calendars.compactMap { $0.source?.title })
|
|
41
|
+
report["calendarSummary"] = [
|
|
42
|
+
"count": calendars.count,
|
|
43
|
+
"accounts": Array(accounts).sorted()
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reminders permissions
|
|
48
|
+
let remStatus = EKEventStore.authorizationStatus(for: .reminder)
|
|
49
|
+
report["reminders"] = [
|
|
50
|
+
"status": authStatusName(remStatus),
|
|
51
|
+
"granted": remStatus == .fullAccess
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if remStatus == .notDetermined {
|
|
55
|
+
let granted = await RemindersBridge.requestAccess()
|
|
56
|
+
report["reminders"] = [
|
|
57
|
+
"status": granted ? "fullAccess" : "denied",
|
|
58
|
+
"granted": granted,
|
|
59
|
+
"note": "Permission was just requested."
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if remStatus == .fullAccess || remStatus == .notDetermined {
|
|
64
|
+
let store = EKEventStore()
|
|
65
|
+
_ = try? await store.requestFullAccessToReminders()
|
|
66
|
+
let lists = store.calendars(for: .reminder)
|
|
67
|
+
report["remindersSummary"] = [
|
|
68
|
+
"count": lists.count,
|
|
69
|
+
"lists": lists.map { $0.title }.sorted()
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Mail -- we can only check if osascript is available
|
|
74
|
+
// Reason: Mail access is via AppleScript, no programmatic permission check exists.
|
|
75
|
+
let mailCheck = checkMailAccess()
|
|
76
|
+
report["mail"] = mailCheck
|
|
77
|
+
|
|
78
|
+
// iWork apps
|
|
79
|
+
let numbersCheck = checkIWorkApp("Numbers")
|
|
80
|
+
let pagesCheck = checkIWorkApp("Pages")
|
|
81
|
+
let keynoteCheck = checkIWorkApp("Keynote")
|
|
82
|
+
report["numbers"] = numbersCheck
|
|
83
|
+
report["pages"] = pagesCheck
|
|
84
|
+
report["keynote"] = keynoteCheck
|
|
85
|
+
|
|
86
|
+
// Notes -- AppleScript-based, same shape as Mail
|
|
87
|
+
report["notes"] = checkNotesAccess()
|
|
88
|
+
|
|
89
|
+
// Contacts -- native CNContactStore
|
|
90
|
+
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
|
91
|
+
report["contacts"] = [
|
|
92
|
+
"status": contactsAuthName(contactsStatus),
|
|
93
|
+
"granted": contactsStatus == .authorized
|
|
94
|
+
]
|
|
95
|
+
if contactsStatus == .notDetermined {
|
|
96
|
+
let granted = await ContactsBridge.requestAccess()
|
|
97
|
+
report["contacts"] = [
|
|
98
|
+
"status": granted ? "authorized" : "denied",
|
|
99
|
+
"granted": granted,
|
|
100
|
+
"note": "Permission was just requested."
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Guidance
|
|
105
|
+
var actions: [String] = []
|
|
106
|
+
if calStatus != .fullAccess {
|
|
107
|
+
actions.append("Calendar: Grant access in System Settings > Privacy & Security > Calendars")
|
|
108
|
+
}
|
|
109
|
+
if remStatus != .fullAccess {
|
|
110
|
+
actions.append("Reminders: Grant access in System Settings > Privacy & Security > Reminders")
|
|
111
|
+
}
|
|
112
|
+
if !(mailCheck["accessible"] as? Bool ?? false) {
|
|
113
|
+
actions.append("Mail: Run apple-bridge with a mail subcommand to trigger the Automation permission dialog")
|
|
114
|
+
}
|
|
115
|
+
if !(numbersCheck["installed"] as? Bool ?? false) {
|
|
116
|
+
actions.append("Numbers: Install from App Store for spreadsheet tools")
|
|
117
|
+
}
|
|
118
|
+
if !(pagesCheck["installed"] as? Bool ?? false) {
|
|
119
|
+
actions.append("Pages: Install from App Store for document tools")
|
|
120
|
+
}
|
|
121
|
+
if !(keynoteCheck["installed"] as? Bool ?? false) {
|
|
122
|
+
actions.append("Keynote: Install from App Store for presentation tools")
|
|
123
|
+
}
|
|
124
|
+
let notesAccessible = (report["notes"] as? [String: Any])?["accessible"] as? Bool ?? false
|
|
125
|
+
if !notesAccessible {
|
|
126
|
+
actions.append("Notes: Run apple-bridge with a notes subcommand to trigger the Automation permission dialog")
|
|
127
|
+
}
|
|
128
|
+
if contactsStatus != .authorized {
|
|
129
|
+
actions.append("Contacts: Grant access in System Settings > Privacy & Security > Contacts")
|
|
130
|
+
}
|
|
131
|
+
if !actions.isEmpty {
|
|
132
|
+
report["requiredActions"] = actions
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
JSONOutput.success(report)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private static func authStatusName(_ status: EKAuthorizationStatus) -> String {
|
|
139
|
+
switch status {
|
|
140
|
+
case .notDetermined: return "notDetermined"
|
|
141
|
+
case .restricted: return "restricted"
|
|
142
|
+
case .denied: return "denied"
|
|
143
|
+
case .fullAccess: return "fullAccess"
|
|
144
|
+
case .writeOnly: return "writeOnly"
|
|
145
|
+
@unknown default: return "unknown"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static func checkIWorkApp(_ appName: String) -> [String: Any] {
|
|
150
|
+
let task = Process()
|
|
151
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
152
|
+
task.arguments = ["-e", "tell application \"\(appName)\" to return name"]
|
|
153
|
+
let outPipe = Pipe()
|
|
154
|
+
let errPipe = Pipe()
|
|
155
|
+
task.standardOutput = outPipe
|
|
156
|
+
task.standardError = errPipe
|
|
157
|
+
|
|
158
|
+
do {
|
|
159
|
+
try task.run()
|
|
160
|
+
task.waitUntilExit()
|
|
161
|
+
if task.terminationStatus == 0 {
|
|
162
|
+
return ["installed": true, "accessible": true]
|
|
163
|
+
} else {
|
|
164
|
+
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
|
165
|
+
let errStr = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
166
|
+
if errStr.contains("-600") || errStr.contains("not running") {
|
|
167
|
+
return ["installed": true, "accessible": false, "note": "\(appName) is not running."]
|
|
168
|
+
}
|
|
169
|
+
return ["installed": false, "accessible": false, "note": "\(appName) may not be installed."]
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return ["installed": false, "accessible": false, "note": "Could not check \(appName): \(error.localizedDescription)"]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private static func contactsAuthName(_ status: CNAuthorizationStatus) -> String {
|
|
177
|
+
switch status {
|
|
178
|
+
case .notDetermined: return "notDetermined"
|
|
179
|
+
case .restricted: return "restricted"
|
|
180
|
+
case .denied: return "denied"
|
|
181
|
+
case .authorized: return "authorized"
|
|
182
|
+
case .limited: return "limited"
|
|
183
|
+
@unknown default: return "unknown"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static func checkNotesAccess() -> [String: Any] {
|
|
188
|
+
let task = Process()
|
|
189
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
190
|
+
task.arguments = ["-e", "tell application \"Notes\" to count of accounts"]
|
|
191
|
+
let pipe = Pipe()
|
|
192
|
+
task.standardOutput = pipe
|
|
193
|
+
task.standardError = Pipe()
|
|
194
|
+
|
|
195
|
+
do {
|
|
196
|
+
try task.run()
|
|
197
|
+
task.waitUntilExit()
|
|
198
|
+
if task.terminationStatus == 0 {
|
|
199
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
200
|
+
let output = String(data: data, encoding: .utf8)?
|
|
201
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
202
|
+
return [
|
|
203
|
+
"accessible": true,
|
|
204
|
+
"accountCount": Int(output ?? "0") ?? 0
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
return [
|
|
208
|
+
"accessible": false,
|
|
209
|
+
"note": "Notes automation permission not yet granted or Notes.app not running."
|
|
210
|
+
]
|
|
211
|
+
} catch {
|
|
212
|
+
return [
|
|
213
|
+
"accessible": false,
|
|
214
|
+
"note": "Could not run osascript: \(error.localizedDescription)"
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private static func checkMailAccess() -> [String: Any] {
|
|
220
|
+
// Reason: Try a minimal AppleScript to see if Mail.app is accessible.
|
|
221
|
+
// This doesn't send the permission prompt -- it just checks if we can talk to Mail.
|
|
222
|
+
let task = Process()
|
|
223
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
224
|
+
task.arguments = ["-e", "tell application \"Mail\" to count of accounts"]
|
|
225
|
+
let pipe = Pipe()
|
|
226
|
+
task.standardOutput = pipe
|
|
227
|
+
task.standardError = Pipe()
|
|
228
|
+
|
|
229
|
+
do {
|
|
230
|
+
try task.run()
|
|
231
|
+
task.waitUntilExit()
|
|
232
|
+
if task.terminationStatus == 0 {
|
|
233
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
234
|
+
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
235
|
+
return [
|
|
236
|
+
"accessible": true,
|
|
237
|
+
"accountCount": Int(output ?? "0") ?? 0
|
|
238
|
+
]
|
|
239
|
+
} else {
|
|
240
|
+
return [
|
|
241
|
+
"accessible": false,
|
|
242
|
+
"note": "Mail automation permission not yet granted or Mail.app not running."
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
return [
|
|
247
|
+
"accessible": false,
|
|
248
|
+
"note": "Could not run osascript: \(error.localizedDescription)"
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|