apple-tools-mcp 1.2.1 → 2.0.1
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 +278 -7
- package/contacts.js +71 -19
- package/index.js +84 -4
- package/indexer.js +7 -2
- package/lib/appleScript.js +416 -0
- package/lib/calendarWrite.js +1367 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +500 -0
- package/lib/messagesWrite.js +313 -0
- package/lib/shell.js +23 -5
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +258 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +411 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +481 -0
- package/search.js +3 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contacts write operations: add, edit, remove.
|
|
3
|
+
*
|
|
4
|
+
* Contacts are addressed by their AddressBook unique id (Contacts.app's
|
|
5
|
+
* `id`, for example "ABCD1234-...:ABPerson"), which `contacts_search` and
|
|
6
|
+
* `contacts_lookup` report as "Contact ID" and `contacts_add` returns.
|
|
7
|
+
*
|
|
8
|
+
* Writes go through Contacts.app rather than the AddressBook sqlite file:
|
|
9
|
+
* writing that database directly corrupts iCloud sync.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { runAppleScript, asString, CONTACTS_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
13
|
+
import {
|
|
14
|
+
planWrite,
|
|
15
|
+
normalizeList,
|
|
16
|
+
isEmailAddress,
|
|
17
|
+
isPhoneNumber,
|
|
18
|
+
validateContactId,
|
|
19
|
+
validateSubject,
|
|
20
|
+
validateLabel,
|
|
21
|
+
writeErrorMessage,
|
|
22
|
+
writeSuccessMessage,
|
|
23
|
+
isFlagTrue,
|
|
24
|
+
truncate
|
|
25
|
+
} from "./writeGuards.js";
|
|
26
|
+
|
|
27
|
+
const MAX_VALUES_PER_FIELD = 10;
|
|
28
|
+
|
|
29
|
+
function validateContactEmails(value, label) {
|
|
30
|
+
const emails = normalizeList(value);
|
|
31
|
+
if (emails.length > MAX_VALUES_PER_FIELD) {
|
|
32
|
+
return { emails: [], error: `at most ${MAX_VALUES_PER_FIELD} email addresses per contact` };
|
|
33
|
+
}
|
|
34
|
+
const bad = emails.filter((e) => !isEmailAddress(e));
|
|
35
|
+
if (bad.length > 0) {
|
|
36
|
+
return { emails: [], error: `invalid email address(es): ${bad.map((e) => truncate(e, 40)).join(", ")}` };
|
|
37
|
+
}
|
|
38
|
+
const resolvedLabel = validateLabel(label, "work");
|
|
39
|
+
if (!resolvedLabel) return { emails: [], error: "email_label must be letters and spaces only" };
|
|
40
|
+
return { emails: emails.map((email) => ({ value: email, label: resolvedLabel })), error: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateContactPhones(value, label) {
|
|
44
|
+
const phones = normalizeList(value);
|
|
45
|
+
if (phones.length > MAX_VALUES_PER_FIELD) {
|
|
46
|
+
return { phones: [], error: `at most ${MAX_VALUES_PER_FIELD} phone numbers per contact` };
|
|
47
|
+
}
|
|
48
|
+
const bad = phones.filter((p) => !isPhoneNumber(p));
|
|
49
|
+
if (bad.length > 0) {
|
|
50
|
+
return { phones: [], error: `invalid phone number(s): ${bad.map((p) => truncate(p, 40)).join(", ")}` };
|
|
51
|
+
}
|
|
52
|
+
const resolvedLabel = validateLabel(label, "mobile");
|
|
53
|
+
if (!resolvedLabel) return { phones: [], error: "phone_label must be letters and spaces only" };
|
|
54
|
+
return { phones: phones.map((phone) => ({ value: phone, label: resolvedLabel })), error: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findPersonHandler() {
|
|
58
|
+
return `on atmFindPerson(theId)
|
|
59
|
+
tell application "Contacts"
|
|
60
|
+
try
|
|
61
|
+
return person id theId
|
|
62
|
+
end try
|
|
63
|
+
try
|
|
64
|
+
set hits to (every person whose id is theId)
|
|
65
|
+
if (count of hits) > 0 then return item 1 of hits
|
|
66
|
+
end try
|
|
67
|
+
end tell
|
|
68
|
+
error "CONTACT_NOT_FOUND"
|
|
69
|
+
end atmFindPerson`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function childLines(items, kind, personVar) {
|
|
73
|
+
return items
|
|
74
|
+
.map((item) => ` make new ${kind} at end of ${kind}s of ${personVar} with properties {label:${asString(item.label)}, value:${asString(item.value)}}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function failure(action, summary, result, secrets = []) {
|
|
79
|
+
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
80
|
+
return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
|
|
81
|
+
}
|
|
82
|
+
const raw = String(result.error || "");
|
|
83
|
+
if (raw.includes("CONTACT_NOT_FOUND")) {
|
|
84
|
+
return `${action} failed — attempted to ${summary}. No contact with that id was found; use the Contact ID from contacts_search or contacts_lookup.`;
|
|
85
|
+
}
|
|
86
|
+
if (result.kind === "attribution") {
|
|
87
|
+
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
88
|
+
}
|
|
89
|
+
if (result.kind === "app_unavailable") {
|
|
90
|
+
return `${action} failed — attempted to ${summary}. Contacts.app could not be reached on this host.`;
|
|
91
|
+
}
|
|
92
|
+
return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildAddContactScript({ properties, emails, phones }) {
|
|
96
|
+
const props = Object.entries(properties)
|
|
97
|
+
.map(([key, value]) => `${key}:${asString(value)}`)
|
|
98
|
+
.join(", ");
|
|
99
|
+
|
|
100
|
+
return `tell application "Contacts"
|
|
101
|
+
set newPerson to make new person with properties {${props}}
|
|
102
|
+
${emails.length ? `${childLines(emails, "email", "newPerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "newPerson")}\n` : ""} save
|
|
103
|
+
set newId to id of newPerson
|
|
104
|
+
end tell
|
|
105
|
+
return newId`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function contactsAdd(args = {}) {
|
|
109
|
+
const action = "contacts_add";
|
|
110
|
+
|
|
111
|
+
const firstName = validateSubject(args.first_name);
|
|
112
|
+
if (firstName.error) return { ok: false, message: `${action} refused: ${firstName.error.replace("subject", "first_name")}` };
|
|
113
|
+
const lastName = validateSubject(args.last_name);
|
|
114
|
+
if (lastName.error) return { ok: false, message: `${action} refused: ${lastName.error.replace("subject", "last_name")}` };
|
|
115
|
+
const organization = validateSubject(args.organization);
|
|
116
|
+
if (organization.error) return { ok: false, message: `${action} refused: ${organization.error.replace("subject", "organization")}` };
|
|
117
|
+
const jobTitle = validateSubject(args.job_title);
|
|
118
|
+
if (jobTitle.error) return { ok: false, message: `${action} refused: ${jobTitle.error.replace("subject", "job_title")}` };
|
|
119
|
+
|
|
120
|
+
if (!firstName.text && !lastName.text && !organization.text) {
|
|
121
|
+
return { ok: false, message: `${action} refused: provide at least first_name, last_name, or organization.` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const emails = validateContactEmails(args.emails, args.email_label);
|
|
125
|
+
if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
|
|
126
|
+
const phones = validateContactPhones(args.phones, args.phone_label);
|
|
127
|
+
if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
|
|
128
|
+
|
|
129
|
+
const displayName = [firstName.text, lastName.text].filter(Boolean).join(" ") || organization.text;
|
|
130
|
+
const summary = `create contact "${truncate(displayName, 100)}"` +
|
|
131
|
+
`${emails.emails.length ? ` with ${emails.emails.length} email(s)` : ""}` +
|
|
132
|
+
`${phones.phones.length ? ` and ${phones.phones.length} phone number(s)` : ""}`;
|
|
133
|
+
|
|
134
|
+
const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
|
|
135
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
136
|
+
|
|
137
|
+
const properties = {};
|
|
138
|
+
if (firstName.text) properties["first name"] = firstName.text;
|
|
139
|
+
if (lastName.text) properties["last name"] = lastName.text;
|
|
140
|
+
if (organization.text) properties.organization = organization.text;
|
|
141
|
+
if (jobTitle.text) properties["job title"] = jobTitle.text;
|
|
142
|
+
|
|
143
|
+
const result = runAppleScript(
|
|
144
|
+
buildAddContactScript({ properties, emails: emails.emails, phones: phones.phones }),
|
|
145
|
+
{ timeout: 60000, appName: "Contacts" }
|
|
146
|
+
);
|
|
147
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
message: writeSuccessMessage(action, "contact created", {
|
|
152
|
+
contact_id: result.output,
|
|
153
|
+
name: displayName,
|
|
154
|
+
emails: emails.emails.map((e) => e.value).join(", ") || undefined,
|
|
155
|
+
phones: phones.phones.map((p) => p.value).join(", ") || undefined
|
|
156
|
+
})
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildEditContactScript({ contactId, properties, emails, phones, replaceEmails, replacePhones }) {
|
|
161
|
+
const lines = Object.entries(properties).map(([key, value]) => ` set ${key} of thePerson to ${asString(value)}`);
|
|
162
|
+
if (replaceEmails) {
|
|
163
|
+
lines.push(` try
|
|
164
|
+
delete every email of thePerson
|
|
165
|
+
end try`);
|
|
166
|
+
}
|
|
167
|
+
if (replacePhones) {
|
|
168
|
+
lines.push(` try
|
|
169
|
+
delete every phone of thePerson
|
|
170
|
+
end try`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${findPersonHandler()}
|
|
174
|
+
|
|
175
|
+
set thePerson to atmFindPerson(${asString(contactId)})
|
|
176
|
+
tell application "Contacts"
|
|
177
|
+
${lines.join("\n")}
|
|
178
|
+
${emails.length ? `${childLines(emails, "email", "thePerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "thePerson")}\n` : ""} save
|
|
179
|
+
set editedName to name of thePerson
|
|
180
|
+
end tell
|
|
181
|
+
return editedName`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function contactsEdit(args = {}) {
|
|
185
|
+
const action = "contacts_edit";
|
|
186
|
+
|
|
187
|
+
const contactId = validateContactId(args.contact_id);
|
|
188
|
+
if (!contactId) {
|
|
189
|
+
return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). This tool will not guess which contact you meant.` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const properties = {};
|
|
193
|
+
const changed = [];
|
|
194
|
+
|
|
195
|
+
const fieldMap = {
|
|
196
|
+
first_name: "first name",
|
|
197
|
+
last_name: "last name",
|
|
198
|
+
organization: "organization",
|
|
199
|
+
job_title: "job title"
|
|
200
|
+
};
|
|
201
|
+
for (const [arg, applescriptProp] of Object.entries(fieldMap)) {
|
|
202
|
+
if (args[arg] === undefined) continue;
|
|
203
|
+
const validated = validateSubject(args[arg]);
|
|
204
|
+
if (validated.error) return { ok: false, message: `${action} refused: ${validated.error.replace("subject", arg)}` };
|
|
205
|
+
properties[applescriptProp] = validated.text;
|
|
206
|
+
changed.push(arg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const emails = validateContactEmails(args.emails, args.email_label);
|
|
210
|
+
if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
|
|
211
|
+
const phones = validateContactPhones(args.phones, args.phone_label);
|
|
212
|
+
if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
|
|
213
|
+
|
|
214
|
+
const replaceEmails = isFlagTrue(args.replace_emails);
|
|
215
|
+
const replacePhones = isFlagTrue(args.replace_phones);
|
|
216
|
+
if (emails.emails.length) changed.push(`${replaceEmails ? "replace" : "add"} ${emails.emails.length} email(s)`);
|
|
217
|
+
if (phones.phones.length) changed.push(`${replacePhones ? "replace" : "add"} ${phones.phones.length} phone(s)`);
|
|
218
|
+
if (replaceEmails && emails.emails.length === 0) changed.push("remove all emails");
|
|
219
|
+
if (replacePhones && phones.phones.length === 0) changed.push("remove all phones");
|
|
220
|
+
|
|
221
|
+
if (changed.length === 0) {
|
|
222
|
+
return { ok: false, message: `${action} refused: nothing to change. Pass at least one of first_name, last_name, organization, job_title, emails, phones.` };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const summary = `update contact ${contactId}: ${changed.join(", ")}`;
|
|
226
|
+
// Dropping every email or phone is destructive, so it needs confirmation.
|
|
227
|
+
const destructive = (replaceEmails && emails.emails.length === 0) || (replacePhones && phones.phones.length === 0);
|
|
228
|
+
const plan = planWrite({
|
|
229
|
+
action,
|
|
230
|
+
summary,
|
|
231
|
+
destructive,
|
|
232
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
233
|
+
confirm: isFlagTrue(args.confirm)
|
|
234
|
+
});
|
|
235
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
236
|
+
|
|
237
|
+
const result = runAppleScript(
|
|
238
|
+
buildEditContactScript({
|
|
239
|
+
contactId,
|
|
240
|
+
properties,
|
|
241
|
+
emails: emails.emails,
|
|
242
|
+
phones: phones.phones,
|
|
243
|
+
replaceEmails,
|
|
244
|
+
replacePhones
|
|
245
|
+
}),
|
|
246
|
+
{ timeout: 60000, appName: "Contacts" }
|
|
247
|
+
);
|
|
248
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
message: writeSuccessMessage(action, "contact updated", {
|
|
253
|
+
contact_id: contactId,
|
|
254
|
+
name: truncate(result.output, 120) || undefined,
|
|
255
|
+
changed: changed.join(", ")
|
|
256
|
+
})
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function buildRemoveContactScript(contactId) {
|
|
261
|
+
return `${findPersonHandler()}
|
|
262
|
+
|
|
263
|
+
set thePerson to atmFindPerson(${asString(contactId)})
|
|
264
|
+
tell application "Contacts"
|
|
265
|
+
set removedName to name of thePerson
|
|
266
|
+
delete thePerson
|
|
267
|
+
save
|
|
268
|
+
end tell
|
|
269
|
+
return removedName`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function contactsRemove(args = {}) {
|
|
273
|
+
const action = "contacts_remove";
|
|
274
|
+
|
|
275
|
+
const contactId = validateContactId(args.contact_id);
|
|
276
|
+
if (!contactId) {
|
|
277
|
+
return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). Deletes never run on a guessed id.` };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const summary = `delete contact ${contactId}`;
|
|
281
|
+
const plan = planWrite({
|
|
282
|
+
action,
|
|
283
|
+
summary,
|
|
284
|
+
destructive: true,
|
|
285
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
286
|
+
confirm: isFlagTrue(args.confirm)
|
|
287
|
+
});
|
|
288
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
289
|
+
|
|
290
|
+
const result = runAppleScript(buildRemoveContactScript(contactId), { timeout: 60000, appName: "Contacts" });
|
|
291
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
message: writeSuccessMessage(action, "contact deleted", {
|
|
296
|
+
contact_id: contactId,
|
|
297
|
+
name: truncate(result.output, 120) || undefined
|
|
298
|
+
})
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived EventKit JXA worker.
|
|
3
|
+
*
|
|
4
|
+
* writeOnly (EKAuthorizationStatus = 4) can create an EKEvent and read
|
|
5
|
+
* identifiers off that in-memory object, but cannot re-query via
|
|
6
|
+
* eventWithIdentifier / calendarItemsWithExternalIdentifier — Mini 2cdf44d
|
|
7
|
+
* failed add on a post-save lookup even though create returned ids.
|
|
8
|
+
*
|
|
9
|
+
* The indexer daemon therefore keeps one osascript process around and
|
|
10
|
+
* caches the EKEvent objects from create so edit/remove can call
|
|
11
|
+
* saveEvent / removeEvent without a fetch.
|
|
12
|
+
*
|
|
13
|
+
* stdin/stdout: one JSON object per line. Never interpolates commands
|
|
14
|
+
* into a shell (`spawn`, `shell: false`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "child_process";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import path from "path";
|
|
21
|
+
|
|
22
|
+
let singleton = null;
|
|
23
|
+
|
|
24
|
+
export function setEventKitSession(session) {
|
|
25
|
+
singleton = session || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getEventKitSession() {
|
|
29
|
+
return singleton;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldStartEventKitSession({
|
|
33
|
+
platform = process.platform,
|
|
34
|
+
env = process.env
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (platform !== "darwin") return false;
|
|
37
|
+
if (env.VITEST) return false;
|
|
38
|
+
if (env.APPLE_TOOLS_EVENTKIT_SESSION === "0") return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* JXA worker: create caches the EKEvent; update/remove use the cache.
|
|
44
|
+
* create does not call findEventByIds.
|
|
45
|
+
*/
|
|
46
|
+
export function sessionPaths(home = process.env.HOME || "") {
|
|
47
|
+
const root = home ? path.join(home, ".apple-tools-mcp") : path.join(os.tmpdir(), "atm-eventkit");
|
|
48
|
+
const dir = path.join(root, "eventkit-session");
|
|
49
|
+
return {
|
|
50
|
+
root,
|
|
51
|
+
dir,
|
|
52
|
+
scriptPath: path.join(root, "eventkit-worker.jxa"),
|
|
53
|
+
cmdPath: path.join(dir, "cmd.json"),
|
|
54
|
+
rspPath: path.join(dir, "rsp.json")
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildEventKitWorkerScript(helpers, paths = {}) {
|
|
59
|
+
const cmdPath = JSON.stringify(paths.cmdPath || "");
|
|
60
|
+
const rspPath = JSON.stringify(paths.rspPath || "");
|
|
61
|
+
return `ObjC.import("EventKit");
|
|
62
|
+
ObjC.import("Foundation");
|
|
63
|
+
${helpers}
|
|
64
|
+
|
|
65
|
+
var store = $.EKEventStore.alloc.init;
|
|
66
|
+
var cache = {};
|
|
67
|
+
|
|
68
|
+
function remember(ev) {
|
|
69
|
+
if (!ev) return;
|
|
70
|
+
var keys = [
|
|
71
|
+
jsString(ev.eventIdentifier),
|
|
72
|
+
jsString(ev.calendarItemIdentifier),
|
|
73
|
+
jsString(ev.calendarItemExternalIdentifier)
|
|
74
|
+
];
|
|
75
|
+
for (var i = 0; i < keys.length; i++) {
|
|
76
|
+
if (keys[i]) cache[keys[i]] = ev;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function forget(ev) {
|
|
81
|
+
var keys = [];
|
|
82
|
+
for (var k in cache) keys.push(k);
|
|
83
|
+
for (var i = 0; i < keys.length; i++) {
|
|
84
|
+
if (cache[keys[i]] === ev) delete cache[keys[i]];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolve(ids) {
|
|
89
|
+
if (!ids) return null;
|
|
90
|
+
for (var i = 0; i < ids.length; i++) {
|
|
91
|
+
var id = ids[i];
|
|
92
|
+
if (id && cache[id]) return cache[id];
|
|
93
|
+
}
|
|
94
|
+
return findEventByIds(store, ids);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var cmdPath = ${cmdPath};
|
|
98
|
+
var rspPath = ${rspPath};
|
|
99
|
+
|
|
100
|
+
function writeReply(s) {
|
|
101
|
+
$.NSString.stringWithString(String(s) + "\\n").writeToFileAtomicallyEncodingError(rspPath, true, $.NSUTF8StringEncoding, null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fileExists(p) {
|
|
105
|
+
try { return !!$.NSFileManager.defaultManager.fileExistsAtPath(p); } catch (e) { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readCmd() {
|
|
109
|
+
var str = $.NSString.stringWithContentsOfFileEncodingError(cmdPath, $.NSUTF8StringEncoding, null);
|
|
110
|
+
return jsString(str);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function removeCmd() {
|
|
114
|
+
try { $.NSFileManager.defaultManager.removeItemAtPathError(cmdPath, null); } catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickCalendar(calendarName) {
|
|
118
|
+
var defaultCal = null;
|
|
119
|
+
try { defaultCal = store.defaultCalendarForNewEvents; } catch (e) {}
|
|
120
|
+
var cals = null;
|
|
121
|
+
var count = 0;
|
|
122
|
+
try {
|
|
123
|
+
cals = store.calendarsForEntityType($.EKEntityTypeEvent);
|
|
124
|
+
count = cals ? Number(cals.count) : 0;
|
|
125
|
+
} catch (e) {}
|
|
126
|
+
var target = null;
|
|
127
|
+
var writables = [];
|
|
128
|
+
var wanted = String(calendarName || "").toLowerCase();
|
|
129
|
+
function matchesWanted(c) {
|
|
130
|
+
var t = titleOf(c).toLowerCase();
|
|
131
|
+
var id = identifierOf(c);
|
|
132
|
+
return (t && t === wanted) || (id && (id === calendarName || id.toLowerCase() === wanted));
|
|
133
|
+
}
|
|
134
|
+
for (var i = 0; i < count; i++) {
|
|
135
|
+
var c = cals.objectAtIndex(i);
|
|
136
|
+
var writable = true;
|
|
137
|
+
try { writable = !!c.allowsContentModifications; } catch (e) {}
|
|
138
|
+
if (!writable) continue;
|
|
139
|
+
writables.push(c);
|
|
140
|
+
if (matchesWanted(c)) {
|
|
141
|
+
target = c;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!target && defaultCal && matchesWanted(defaultCal)) target = defaultCal;
|
|
146
|
+
if (!target && writables.length === 1) target = writables[0];
|
|
147
|
+
if (!target && defaultCal) target = defaultCal;
|
|
148
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
149
|
+
if (!target) {
|
|
150
|
+
throw new Error("CALENDAR_NOT_FOUND status=" + status + " eventKitCalendars=" + count + " default=" + titleOf(defaultCal) + " defaultId=" + identifierOf(defaultCal));
|
|
151
|
+
}
|
|
152
|
+
return target;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createEvent(cmd) {
|
|
156
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
157
|
+
if (status === 1 || status === 2) throw new Error("EVENTKIT_DENIED status=" + status);
|
|
158
|
+
var target = pickCalendar(cmd.calendarName);
|
|
159
|
+
var event = null;
|
|
160
|
+
try { event = $.EKEvent.eventWithEventStore(store); } catch (e) {}
|
|
161
|
+
if (!event) {
|
|
162
|
+
try { event = $.EKEvent.alloc.initWithEventStore(store); } catch (e2) {}
|
|
163
|
+
}
|
|
164
|
+
if (!event) throw new Error("EVENTKIT_NO_EVENT status=" + status);
|
|
165
|
+
event.title = String(cmd.title || "");
|
|
166
|
+
event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
|
|
167
|
+
event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
|
|
168
|
+
event.allDay = !!cmd.allDay;
|
|
169
|
+
try { event.calendar = target; } catch (e) {}
|
|
170
|
+
try { if (event.setCalendar) event.setCalendar(target); } catch (e) {}
|
|
171
|
+
if (cmd.location) event.location = String(cmd.location);
|
|
172
|
+
if (cmd.notes) event.notes = String(cmd.notes);
|
|
173
|
+
var alerts = cmd.alerts || [];
|
|
174
|
+
if (alerts && alerts.length) {
|
|
175
|
+
var alarms = $.NSMutableArray.array;
|
|
176
|
+
for (var a = 0; a < alerts.length; a++) {
|
|
177
|
+
alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
|
|
178
|
+
}
|
|
179
|
+
event.alarms = alarms;
|
|
180
|
+
}
|
|
181
|
+
var err = Ref();
|
|
182
|
+
var ok = false;
|
|
183
|
+
try {
|
|
184
|
+
ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + String(e));
|
|
187
|
+
}
|
|
188
|
+
if (!ok) {
|
|
189
|
+
var desc = "unknown";
|
|
190
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
191
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + desc + " status=" + status);
|
|
192
|
+
}
|
|
193
|
+
remember(event);
|
|
194
|
+
var externalId = "";
|
|
195
|
+
var localId = "";
|
|
196
|
+
var itemId = "";
|
|
197
|
+
try { externalId = jsString(event.calendarItemExternalIdentifier); } catch (e) {}
|
|
198
|
+
try { localId = jsString(event.eventIdentifier); } catch (e) {}
|
|
199
|
+
try { itemId = jsString(event.calendarItemIdentifier); } catch (e) {}
|
|
200
|
+
if (!externalId && !localId && !itemId) throw new Error("EVENTKIT_NO_ID status=" + status);
|
|
201
|
+
return externalId + "<<>>" + localId + "<<>>" + titleOf(target) + "<<>>" + itemId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function updateEvent(cmd) {
|
|
205
|
+
var event = resolve(cmd.ids);
|
|
206
|
+
if (!event) {
|
|
207
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
208
|
+
throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
|
|
209
|
+
}
|
|
210
|
+
if (cmd.title !== undefined && cmd.title !== null) event.title = String(cmd.title);
|
|
211
|
+
if (cmd.location !== undefined && cmd.location !== null) event.location = String(cmd.location);
|
|
212
|
+
if (cmd.notes !== undefined && cmd.notes !== null) event.notes = String(cmd.notes);
|
|
213
|
+
if (cmd.startSec !== undefined && cmd.startSec !== null) {
|
|
214
|
+
event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
|
|
215
|
+
}
|
|
216
|
+
if (cmd.endSec !== undefined && cmd.endSec !== null) {
|
|
217
|
+
event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
|
|
218
|
+
}
|
|
219
|
+
if (cmd.clearAlerts) {
|
|
220
|
+
try { event.alarms = $.NSMutableArray.array; } catch (e) {}
|
|
221
|
+
}
|
|
222
|
+
var alerts = cmd.alerts || [];
|
|
223
|
+
if (alerts && alerts.length) {
|
|
224
|
+
var alarms = $.NSMutableArray.array;
|
|
225
|
+
for (var a = 0; a < alerts.length; a++) {
|
|
226
|
+
alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
|
|
227
|
+
}
|
|
228
|
+
event.alarms = alarms;
|
|
229
|
+
}
|
|
230
|
+
var err = Ref();
|
|
231
|
+
var ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
232
|
+
if (!ok) {
|
|
233
|
+
var desc = "unknown";
|
|
234
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
235
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + desc);
|
|
236
|
+
}
|
|
237
|
+
remember(event);
|
|
238
|
+
return jsString(event.eventIdentifier) || jsString(event.calendarItemExternalIdentifier) || "1";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function removeEvent(cmd) {
|
|
242
|
+
var event = resolve(cmd.ids);
|
|
243
|
+
if (!event) {
|
|
244
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
245
|
+
throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
|
|
246
|
+
}
|
|
247
|
+
var err = Ref();
|
|
248
|
+
var ok = store.removeEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
249
|
+
if (!ok) {
|
|
250
|
+
var desc = "unknown";
|
|
251
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
252
|
+
throw new Error("EVENTKIT_REMOVE_FAILED: " + desc);
|
|
253
|
+
}
|
|
254
|
+
forget(event);
|
|
255
|
+
return "1";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function handle(cmd) {
|
|
259
|
+
if (!cmd || !cmd.op) throw new Error("EVENTKIT_BAD_CMD");
|
|
260
|
+
if (cmd.op === "ping") return "pong";
|
|
261
|
+
if (cmd.op === "create") return createEvent(cmd);
|
|
262
|
+
if (cmd.op === "update") return updateEvent(cmd);
|
|
263
|
+
if (cmd.op === "remove") return removeEvent(cmd);
|
|
264
|
+
if (cmd.op === "quit") return "bye";
|
|
265
|
+
throw new Error("EVENTKIT_BAD_OP");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
while (true) {
|
|
269
|
+
if (!fileExists(cmdPath)) {
|
|
270
|
+
delay(0.05);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
var line = readCmd();
|
|
274
|
+
removeCmd();
|
|
275
|
+
line = String(line || "").replace(/^\\s+|\\s+$/g, "");
|
|
276
|
+
if (!line) continue;
|
|
277
|
+
try {
|
|
278
|
+
var cmd = JSON.parse(line);
|
|
279
|
+
var output = handle(cmd);
|
|
280
|
+
writeReply(JSON.stringify({ ok: true, output: String(output == null ? "" : output) }));
|
|
281
|
+
if (cmd.op === "quit") break;
|
|
282
|
+
} catch (e) {
|
|
283
|
+
writeReply(JSON.stringify({ ok: false, error: String(e) }));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function workerScriptPath(home = process.env.HOME || "") {
|
|
290
|
+
return sessionPaths(home).scriptPath;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function startEventKitSession({
|
|
294
|
+
helpers,
|
|
295
|
+
spawnImpl = spawn,
|
|
296
|
+
home = process.env.HOME || "",
|
|
297
|
+
timeoutMs = 30000,
|
|
298
|
+
sleep = (ms) => {
|
|
299
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
300
|
+
}
|
|
301
|
+
} = {}) {
|
|
302
|
+
if (!helpers) throw new Error("EventKit worker helpers are required");
|
|
303
|
+
const paths = sessionPaths(home);
|
|
304
|
+
fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
305
|
+
try { fs.unlinkSync(paths.cmdPath); } catch { /* ignore */ }
|
|
306
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
307
|
+
fs.writeFileSync(paths.scriptPath, buildEventKitWorkerScript(helpers, paths), { mode: 0o600 });
|
|
308
|
+
|
|
309
|
+
const child = spawnImpl("osascript", ["-l", "JavaScript", paths.scriptPath], {
|
|
310
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
311
|
+
shell: false
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
let dead = false;
|
|
315
|
+
if (child.on) {
|
|
316
|
+
child.on("exit", () => {
|
|
317
|
+
dead = true;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function request(cmd, waitMs = timeoutMs) {
|
|
322
|
+
if (dead) throw new Error("EVENTKIT_SESSION_DEAD");
|
|
323
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
324
|
+
fs.writeFileSync(paths.cmdPath, JSON.stringify(cmd), { mode: 0o600 });
|
|
325
|
+
const deadline = Date.now() + waitMs;
|
|
326
|
+
while (Date.now() < deadline) {
|
|
327
|
+
if (fs.existsSync(paths.rspPath)) {
|
|
328
|
+
const text = fs.readFileSync(paths.rspPath, "utf8").trim();
|
|
329
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
330
|
+
return JSON.parse(text);
|
|
331
|
+
}
|
|
332
|
+
sleep(20);
|
|
333
|
+
}
|
|
334
|
+
throw new Error("EVENTKIT_SESSION_TIMEOUT");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const pong = request({ op: "ping" });
|
|
338
|
+
if (!pong || pong.ok !== true || pong.output !== "pong") {
|
|
339
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
340
|
+
throw new Error("EVENTKIT_SESSION_HANDSHAKE");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
request,
|
|
345
|
+
close() {
|
|
346
|
+
try { request({ op: "quit" }, 2000); } catch { /* ignore */ }
|
|
347
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
348
|
+
dead = true;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function ensureEventKitSession(opts = {}) {
|
|
354
|
+
if (singleton) return singleton;
|
|
355
|
+
if (!shouldStartEventKitSession(opts)) return null;
|
|
356
|
+
try {
|
|
357
|
+
singleton = startEventKitSession(opts);
|
|
358
|
+
return singleton;
|
|
359
|
+
} catch {
|
|
360
|
+
singleton = null;
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function closeEventKitSession() {
|
|
366
|
+
if (!singleton) return;
|
|
367
|
+
try { singleton.close(); } catch { /* ignore */ }
|
|
368
|
+
singleton = null;
|
|
369
|
+
}
|