@mgcrea/mcp-apple-contacts 0.0.0-bootstrap
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +558 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-BjWSCaMN.js +1480 -0
- package/dist/server-BjWSCaMN.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
import { AppleAutomationError, AppleAutomationError as AppleContactsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, createOsascriptRunner, describeStore, escapeLike, fail, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, readPackageIdentity, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
//#region src/build-info.ts
|
|
8
|
+
const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
9
|
+
name: "@mgcrea/mcp-apple-contacts",
|
|
10
|
+
version: "0.0.0"
|
|
11
|
+
});
|
|
12
|
+
const BUILD_INFO = {
|
|
13
|
+
name: pkg.name,
|
|
14
|
+
version: pkg.version,
|
|
15
|
+
gitCommit: "df06e7f",
|
|
16
|
+
gitCommitDate: "2026-08-22T16:20:23+02:00"
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/client/errors.ts
|
|
20
|
+
/**
|
|
21
|
+
* Contacts' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
|
|
22
|
+
* belongs here is the identity those messages are written against.
|
|
23
|
+
*/
|
|
24
|
+
const CONTACTS_SURFACE = {
|
|
25
|
+
appName: "Contacts",
|
|
26
|
+
envPrefix: "APPLE_CONTACTS"
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Contacts' Apple Events target — and, like Calendar's `com.apple.iCal`, not the
|
|
30
|
+
* display name. Contacts.app kept the id it shipped with as Address Book;
|
|
31
|
+
* `com.apple.Contacts` does not exist.
|
|
32
|
+
*
|
|
33
|
+
* Used by the write lane only. Reads never send an Apple Event.
|
|
34
|
+
*/
|
|
35
|
+
const CONTACTS_BUNDLE_ID = "com.apple.AddressBook";
|
|
36
|
+
/** A contact ref no longer resolves — deleted, or its account was removed. */
|
|
37
|
+
var ContactNotFoundError = class extends AppleAutomationError {
|
|
38
|
+
name = "ContactNotFoundError";
|
|
39
|
+
constructor(ref) {
|
|
40
|
+
super(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`, { ref });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The store could not be read, with the reason spelled out.
|
|
45
|
+
*
|
|
46
|
+
* Its own error because Contacts fails differently from every other surface in
|
|
47
|
+
* this repo: it sits behind its own TCC service rather than behind Full Disk
|
|
48
|
+
* Access, and unlike Full Disk Access that permission PROMPTS. So the fix is
|
|
49
|
+
* usually "answer the dialog", not "go to System Settings" — and telling
|
|
50
|
+
* somebody to grant whole-disk access for an address book would be asking for
|
|
51
|
+
* far more than this server needs.
|
|
52
|
+
*/
|
|
53
|
+
var ContactsUnavailableError = class extends AppleAutomationError {
|
|
54
|
+
name = "ContactsUnavailableError";
|
|
55
|
+
constructor(reason) {
|
|
56
|
+
super(reason, {});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* A write did not survive the save.
|
|
61
|
+
*
|
|
62
|
+
* Its own error because Contacts fails this way and the other surfaces do not:
|
|
63
|
+
* changes sit in an unsaved buffer until `save()` runs, so a mutation can
|
|
64
|
+
* succeed, read back correctly inside the same script, and still never reach the
|
|
65
|
+
* store. Every write script saves and then re-reads; this is what it raises when
|
|
66
|
+
* the re-read comes back empty.
|
|
67
|
+
*/
|
|
68
|
+
var ContactWriteNotPersistedError = class extends AppleAutomationError {
|
|
69
|
+
name = "ContactWriteNotPersistedError";
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(`${message} Contacts keeps edits in an unsaved buffer, so this usually means the save was refused — check whether Contacts has a modal sheet open, or an account that is read-only.`, {});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/client/jxa/core.ts
|
|
76
|
+
/**
|
|
77
|
+
* JXA script fragments for Contacts.
|
|
78
|
+
*
|
|
79
|
+
* Every script here is a static constant. None may contain a template
|
|
80
|
+
* interpolation — `assertStaticScript` rejects any script containing a dollar
|
|
81
|
+
* sign followed by a brace, including template literals written INSIDE the JXA
|
|
82
|
+
* source. Use string concatenation in JXA code.
|
|
83
|
+
*
|
|
84
|
+
* Every script follows the same contract:
|
|
85
|
+
* - it reads its parameters from `JSON.parse(argv[0])`
|
|
86
|
+
* - it returns `JSON.stringify({ok: true, data})` on success
|
|
87
|
+
* - it returns `JSON.stringify({ok: false, error: {code, message}})` on an
|
|
88
|
+
* application-level failure, still exiting 0
|
|
89
|
+
* so a non-zero exit always means infrastructure rather than "no such contact".
|
|
90
|
+
*
|
|
91
|
+
* ## There is no read.ts here, and that is still the design
|
|
92
|
+
*
|
|
93
|
+
* Adding writes did not add a read lane. Reads come off the file lane, which is
|
|
94
|
+
* the only place a suffix-keyed index over 970 phone numbers can be built at
|
|
95
|
+
* all — see `../store.ts`. These scripts exist only to make Contacts CHANGE
|
|
96
|
+
* something. `test/jxa.test.ts` asserts `read.ts` does not exist.
|
|
97
|
+
*
|
|
98
|
+
* ## What the dictionary actually offers
|
|
99
|
+
*
|
|
100
|
+
* MEASURED from `sdef /System/Applications/Contacts.app` on macOS 26.6. The
|
|
101
|
+
* whole command list is four verbs:
|
|
102
|
+
*
|
|
103
|
+
* make create a person, or a phone/email element under one
|
|
104
|
+
* add put a person in a group
|
|
105
|
+
* remove take a person out of a group
|
|
106
|
+
* save commit everything
|
|
107
|
+
*
|
|
108
|
+
* That is all of it. The Standard Suite here contains **only `make`** — the
|
|
109
|
+
* string "delete" does not appear anywhere in the dictionary. So there is no
|
|
110
|
+
* supported way to delete a contact over Apple Events, which is why this file
|
|
111
|
+
* has no DELETE script and why `delete_contacts` is not a tool. Whether Cocoa
|
|
112
|
+
* Scripting answers an undeclared `delete` event anyway is a separate question
|
|
113
|
+
* and an unmeasured one; guessing at it would risk destroying a real person's
|
|
114
|
+
* card on the strength of an assumption.
|
|
115
|
+
*
|
|
116
|
+
* ## `save` is explicit, global, and the whole reason this is not like Calendar
|
|
117
|
+
*
|
|
118
|
+
* Calendar and Reminders persist a property assignment immediately. Contacts
|
|
119
|
+
* does not: changes sit in an unsaved buffer until `Application("Contacts").save()`
|
|
120
|
+
* runs, and the dictionary says so — the application class carries an `unsaved`
|
|
121
|
+
* property, and `save` is documented as "Save ALL Contacts changes".
|
|
122
|
+
*
|
|
123
|
+
* Two consequences, both load-bearing:
|
|
124
|
+
*
|
|
125
|
+
* 1. **A write that forgets `save()` silently does nothing.** The object updates,
|
|
126
|
+
* every read-back inside the same script agrees, and the store never changes.
|
|
127
|
+
* Every script below saves before it verifies.
|
|
128
|
+
* 2. **`save()` is not scoped to our change.** It commits whatever else is
|
|
129
|
+
* pending, including an edit someone has half-typed in the Contacts window.
|
|
130
|
+
* That is a property of the dictionary, not a choice made here, and the tool
|
|
131
|
+
* descriptions say so.
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* Shared prelude.
|
|
135
|
+
*
|
|
136
|
+
* The bundle identifier is `com.apple.AddressBook`, which — like Calendar's
|
|
137
|
+
* `com.apple.iCal` — does not match the display name. Contacts.app kept the id
|
|
138
|
+
* it shipped with as Address Book. `Application("Contacts")` is the correct
|
|
139
|
+
* scripting name; `com.apple.Contacts` does not exist.
|
|
140
|
+
*/
|
|
141
|
+
const PRELUDE = `
|
|
142
|
+
ObjC.import("AppKit");
|
|
143
|
+
|
|
144
|
+
function isContactsRunning() {
|
|
145
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.AddressBook");
|
|
146
|
+
return apps.count > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ok(data) { return JSON.stringify({ ok: true, data: data }); }
|
|
150
|
+
function err(code, message) { return JSON.stringify({ ok: false, error: { code: code, message: String(message) } }); }
|
|
151
|
+
|
|
152
|
+
/** Read one property defensively: Contacts throws on properties it cannot supply. */
|
|
153
|
+
function prop(fn, fallback) {
|
|
154
|
+
try {
|
|
155
|
+
var v = fn();
|
|
156
|
+
return v === undefined ? fallback : v;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return fallback;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find one person, without assuming the two lanes spell an id the same way.
|
|
164
|
+
*
|
|
165
|
+
* The file lane holds \`ZABCDRECORD.ZUNIQUEID\`; Apple Events returns whatever
|
|
166
|
+
* \`person.id()\` returns. That those are the same string is EXACTLY the kind of
|
|
167
|
+
* thing this project has been wrong about before — Calendar's \`calendar.uid()\`
|
|
168
|
+
* throws for every calendar, and the id bridge that held for its events did not
|
|
169
|
+
* extend to them. It is unmeasured here, so it is not assumed.
|
|
170
|
+
*
|
|
171
|
+
* Two attempts, cheapest first:
|
|
172
|
+
*
|
|
173
|
+
* 1. \`byId()\` with the value as given. Contacts offers a real by-id lookup,
|
|
174
|
+
* unlike Calendar, so when the forms do agree this costs one round trip.
|
|
175
|
+
* 2. A bulk \`people.id()\` fetch, matched on the UUID substring. This is the
|
|
176
|
+
* guard, and it is affordable precisely here: docs/contacts.md measured the
|
|
177
|
+
* whole id list at 63-73 ms over 421 people, against the 1.8 s that made the
|
|
178
|
+
* same trick unusable for Calendar's events.
|
|
179
|
+
*
|
|
180
|
+
* So a mismatch in id FORM degrades to a fast scan instead of a wrong "not
|
|
181
|
+
* found" — which is the failure this would otherwise produce, and the one that
|
|
182
|
+
* looks like the contact was deleted.
|
|
183
|
+
*/
|
|
184
|
+
function uuidOf(value) {
|
|
185
|
+
var m = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/.exec(String(value || ""));
|
|
186
|
+
return m ? m[0].toUpperCase() : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function findPerson(C, personId) {
|
|
190
|
+
try {
|
|
191
|
+
var direct = C.people.byId(personId);
|
|
192
|
+
direct.id();
|
|
193
|
+
return direct;
|
|
194
|
+
} catch (e) {
|
|
195
|
+
// Fall through to the scan.
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var wanted = uuidOf(personId);
|
|
199
|
+
if (!wanted) return null;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
var ids = C.people.id();
|
|
203
|
+
for (var i = 0; i < ids.length; i++) {
|
|
204
|
+
if (uuidOf(ids[i]) === wanted) {
|
|
205
|
+
var found = C.people.byId(ids[i]);
|
|
206
|
+
found.id();
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (e2) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Everything a write returns, so a caller sees what Contacts stored. */
|
|
217
|
+
function shapePerson(p) {
|
|
218
|
+
return {
|
|
219
|
+
id: prop(function () { return String(p.id()); }, null),
|
|
220
|
+
name: prop(function () { return p.name(); }, null),
|
|
221
|
+
firstName: prop(function () { return p.firstName(); }, null),
|
|
222
|
+
lastName: prop(function () { return p.lastName(); }, null),
|
|
223
|
+
nickname: prop(function () { return p.nickname(); }, null),
|
|
224
|
+
organization: prop(function () { return p.organization(); }, null),
|
|
225
|
+
jobTitle: prop(function () { return p.jobTitle(); }, null),
|
|
226
|
+
department: prop(function () { return p.department(); }, null),
|
|
227
|
+
note: prop(function () { return p.note(); }, null),
|
|
228
|
+
company: prop(function () { return p.company(); }, false),
|
|
229
|
+
phones: prop(function () {
|
|
230
|
+
var out = [];
|
|
231
|
+
var xs = p.phones();
|
|
232
|
+
for (var i = 0; i < xs.length; i++) {
|
|
233
|
+
out.push({
|
|
234
|
+
label: prop(function () { return xs[i].label(); }, null),
|
|
235
|
+
value: prop(function () { return xs[i].value(); }, null)
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}, []),
|
|
240
|
+
emails: prop(function () {
|
|
241
|
+
var out = [];
|
|
242
|
+
var xs = p.emails();
|
|
243
|
+
for (var i = 0; i < xs.length; i++) {
|
|
244
|
+
out.push({
|
|
245
|
+
label: prop(function () { return xs[i].label(); }, null),
|
|
246
|
+
value: prop(function () { return xs[i].value(); }, null)
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}, [])
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Apply the scalar properties a caller supplied.
|
|
256
|
+
*
|
|
257
|
+
* Only keys actually present are touched: a missing key means "leave it alone",
|
|
258
|
+
* an explicit null means "clear it". Assigning undefined would blank a field the
|
|
259
|
+
* caller never mentioned.
|
|
260
|
+
*/
|
|
261
|
+
function applyFields(p, f) {
|
|
262
|
+
if (f.firstName !== undefined) p.firstName = f.firstName;
|
|
263
|
+
if (f.lastName !== undefined) p.lastName = f.lastName;
|
|
264
|
+
if (f.nickname !== undefined) p.nickname = f.nickname;
|
|
265
|
+
if (f.organization !== undefined) p.organization = f.organization;
|
|
266
|
+
if (f.jobTitle !== undefined) p.jobTitle = f.jobTitle;
|
|
267
|
+
if (f.department !== undefined) p.department = f.department;
|
|
268
|
+
if (f.note !== undefined) p.note = f.note;
|
|
269
|
+
if (f.company !== undefined) p.company = f.company;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Add phone and email elements.
|
|
274
|
+
*
|
|
275
|
+
* These are ELEMENTS, not properties: a phone number is its own object made at
|
|
276
|
+
* the end of the person's phones. There is no way to set them as a bulk array,
|
|
277
|
+
* so each one is a separate \`make\`.
|
|
278
|
+
*/
|
|
279
|
+
function addChildren(C, p, phones, emails) {
|
|
280
|
+
var i;
|
|
281
|
+
if (phones) {
|
|
282
|
+
for (i = 0; i < phones.length; i++) {
|
|
283
|
+
p.phones.push(C.Phone({ label: phones[i].label || "mobile", value: phones[i].value }));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (emails) {
|
|
287
|
+
for (i = 0; i < emails.length; i++) {
|
|
288
|
+
p.emails.push(C.Email({ label: emails[i].label || "home", value: emails[i].value }));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
`;
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/client/jxa/write.ts
|
|
295
|
+
/**
|
|
296
|
+
* The write scripts. Two verbs, and the absence of a third is deliberate.
|
|
297
|
+
*
|
|
298
|
+
* `create` and `update` are both `make`-and-`save`. There is no `delete`,
|
|
299
|
+
* because the Contacts dictionary has no delete command — see `core.ts` for the
|
|
300
|
+
* measurement. That absence is enforced by `test/jxa.test.ts`, so removing a
|
|
301
|
+
* contact cannot be added here without the decision being taken again.
|
|
302
|
+
*
|
|
303
|
+
* Every script SAVES and then RE-READS, in that order. Contacts keeps changes in
|
|
304
|
+
* an unsaved buffer, so a script that skips the save mutates a live object,
|
|
305
|
+
* reports success from its own in-memory read, and leaves the store untouched.
|
|
306
|
+
* Verifying before saving would find exactly the same false success.
|
|
307
|
+
*/
|
|
308
|
+
const CREATE_CONTACT = `${PRELUDE}
|
|
309
|
+
function run(argv) {
|
|
310
|
+
var p = JSON.parse(argv[0]);
|
|
311
|
+
var C = Application("Contacts");
|
|
312
|
+
|
|
313
|
+
if (!isContactsRunning() && !p.allowLaunch) {
|
|
314
|
+
return err("APP_NOT_RUNNING", "Contacts is not running.");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
var person;
|
|
318
|
+
try {
|
|
319
|
+
person = C.Person({
|
|
320
|
+
firstName: p.fields.firstName || "",
|
|
321
|
+
lastName: p.fields.lastName || ""
|
|
322
|
+
});
|
|
323
|
+
C.people.push(person);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return err("CREATE_FAILED", e.message || e);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
applyFields(person, p.fields);
|
|
330
|
+
addChildren(C, person, p.phones, p.emails);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
// The person exists but is incomplete. Save anyway so the caller is told
|
|
333
|
+
// about a real half-written card rather than a phantom one, and let the
|
|
334
|
+
// read-back below show exactly what landed.
|
|
335
|
+
try { C.save(); } catch (e2) {}
|
|
336
|
+
return err("CREATE_INCOMPLETE", e.message || e);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
C.save();
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return err("SAVE_FAILED", e.message || e);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Re-read AFTER the save, so what comes back is what Contacts stored rather
|
|
346
|
+
// than what this script asked for.
|
|
347
|
+
var fresh = findPerson(C, prop(function () { return String(person.id()); }, ""));
|
|
348
|
+
if (!fresh) {
|
|
349
|
+
return err("CREATE_NOT_PERSISTED", "Contacts saved without error but the contact could not be read back.");
|
|
350
|
+
}
|
|
351
|
+
return ok(shapePerson(fresh));
|
|
352
|
+
}
|
|
353
|
+
`;
|
|
354
|
+
const UPDATE_CONTACT = `${PRELUDE}
|
|
355
|
+
function run(argv) {
|
|
356
|
+
var p = JSON.parse(argv[0]);
|
|
357
|
+
var C = Application("Contacts");
|
|
358
|
+
|
|
359
|
+
if (!isContactsRunning() && !p.allowLaunch) {
|
|
360
|
+
return err("APP_NOT_RUNNING", "Contacts is not running.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
var person = findPerson(C, p.personId);
|
|
364
|
+
if (!person) {
|
|
365
|
+
return err("CONTACT_NOT_FOUND", "No contact with id " + p.personId + ".");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
applyFields(person, p.fields);
|
|
370
|
+
addChildren(C, person, p.phones, p.emails);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return err("UPDATE_FAILED", e.message || e);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
C.save();
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return err("SAVE_FAILED", e.message || e);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
var fresh = findPerson(C, p.personId);
|
|
382
|
+
if (!fresh) {
|
|
383
|
+
return err("UPDATE_NOT_PERSISTED", "Contacts saved without error but the contact could not be read back.");
|
|
384
|
+
}
|
|
385
|
+
return ok(shapePerson(fresh));
|
|
386
|
+
}
|
|
387
|
+
`;
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/client/locate.ts
|
|
390
|
+
/**
|
|
391
|
+
* Find Contacts' stores — plural, which is the whole point of this file.
|
|
392
|
+
*
|
|
393
|
+
* Every other surface in this repo has one store. Contacts has one per account
|
|
394
|
+
* plus a root database, and `docs/contacts.md` measured what that means:
|
|
395
|
+
*
|
|
396
|
+
* AddressBook-v22.abcddb 1 contact
|
|
397
|
+
* Sources/<uuid>/AddressBook-v22.abcddb 420 contacts
|
|
398
|
+
*
|
|
399
|
+
* The obvious path — the one at the top of the directory — is present, readable,
|
|
400
|
+
* correctly shaped, and empty. A server that opens it gets a working database
|
|
401
|
+
* with nobody in it, which fails no check and returns no answer. That is not a
|
|
402
|
+
* hypothetical: `scripts/probe-contacts.mjs` did exactly this and reported a
|
|
403
|
+
* confident 0% resolution rate before anyone noticed.
|
|
404
|
+
*
|
|
405
|
+
* So there is no "the" store here. Everything readable is opened and the rows
|
|
406
|
+
* are unioned, and the number of sources is discovered rather than assumed —
|
|
407
|
+
* one on the probed machine, more with Google or Exchange accounts.
|
|
408
|
+
*/
|
|
409
|
+
/** `~/Library/Application Support/AddressBook`. */
|
|
410
|
+
const ADDRESSBOOK_DIR = join("Library", "Application Support", "AddressBook");
|
|
411
|
+
/** The per-account subdirectory. Each child holds one database. */
|
|
412
|
+
const SOURCES_DIRNAME = "Sources";
|
|
413
|
+
/** Constant on every store, root and source alike. */
|
|
414
|
+
const STORE_FILENAME = "AddressBook-v22.abcddb";
|
|
415
|
+
const defaultDirPath = (home = homedir()) => join(home, ADDRESSBOOK_DIR);
|
|
416
|
+
/**
|
|
417
|
+
* The grant hint.
|
|
418
|
+
*
|
|
419
|
+
* Deliberately NOT the Full Disk Access sentence the other surfaces use.
|
|
420
|
+
* Contacts is protected by its own TCC service, and unlike Full Disk Access that
|
|
421
|
+
* one prompts — so the likely fix is a dialog that was dismissed, and the
|
|
422
|
+
* remedy names the Contacts pane rather than asking for the whole disk.
|
|
423
|
+
*/
|
|
424
|
+
const GRANT_HINT = "Contacts is protected by its own privacy permission, not by Full Disk Access. macOS asks for it the first time something reads the address book; if that dialog was dismissed, re-enable the app under System Settings > Privacy & Security > Contacts and restart it.";
|
|
425
|
+
const listDirs = (dir) => {
|
|
426
|
+
try {
|
|
427
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
428
|
+
} catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const locateStores = (opts = {}) => {
|
|
433
|
+
const dirPath = defaultDirPath(opts.home);
|
|
434
|
+
if (opts.storePath) {
|
|
435
|
+
const candidate = {
|
|
436
|
+
...describeStore(opts.storePath),
|
|
437
|
+
path: opts.storePath,
|
|
438
|
+
label: "explicit"
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
dirPath,
|
|
442
|
+
dirListable: true,
|
|
443
|
+
candidates: [candidate],
|
|
444
|
+
readable: candidate.readable ? [candidate] : [],
|
|
445
|
+
sourceCount: 0,
|
|
446
|
+
reason: candidate.readable ? null : candidate.exists ? `The store at ${opts.storePath} exists but cannot be read. ${GRANT_HINT}` : `No file at ${opts.storePath}. APPLE_CONTACTS_STORE points at nothing.`
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const rootPath = join(dirPath, STORE_FILENAME);
|
|
450
|
+
const sourceNames = listDirs(join(dirPath, SOURCES_DIRNAME));
|
|
451
|
+
const candidates = [{
|
|
452
|
+
...describeStore(rootPath),
|
|
453
|
+
path: rootPath,
|
|
454
|
+
label: "root"
|
|
455
|
+
}, ...sourceNames.map((name) => {
|
|
456
|
+
const path = join(dirPath, SOURCES_DIRNAME, name, STORE_FILENAME);
|
|
457
|
+
return {
|
|
458
|
+
...describeStore(path),
|
|
459
|
+
path,
|
|
460
|
+
label: name
|
|
461
|
+
};
|
|
462
|
+
})].filter((c) => c.exists);
|
|
463
|
+
const readable = candidates.filter((c) => c.readable);
|
|
464
|
+
const dirListable = listDirs(dirPath).length > 0 || sourceNames.length > 0;
|
|
465
|
+
const reason = readable.length ? null : candidates.length ? `Found ${candidates.length} Contacts store(s) under ${dirPath} but none could be opened. ${GRANT_HINT}` : dirListable ? `No ${STORE_FILENAME} under ${dirPath}. Has Contacts ever been set up on this account?` : `${dirPath} could not be listed, so the per-account stores could not be found. ${GRANT_HINT}`;
|
|
466
|
+
return {
|
|
467
|
+
dirPath,
|
|
468
|
+
dirListable,
|
|
469
|
+
candidates,
|
|
470
|
+
readable,
|
|
471
|
+
sourceCount: sourceNames.length,
|
|
472
|
+
reason
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/client/phone.ts
|
|
477
|
+
/**
|
|
478
|
+
* Phone number matching, which on this surface is the whole product.
|
|
479
|
+
*
|
|
480
|
+
* Contacts stores what the user typed. `docs/contacts.md` measured a 400-row
|
|
481
|
+
* sample of `ZFULLNUMBER`: 222 formatted (`06 12 34 56 78`), 159 already E.164,
|
|
482
|
+
* 15 bare digits. Messages, meanwhile, stores a handle as E.164 and nothing
|
|
483
|
+
* else. So the two never meet as strings, and the measurement says so with an
|
|
484
|
+
* unusually blunt number: **exact string equality resolves 3.7% of message
|
|
485
|
+
* traffic.** A resolver that joins on the stored value is not slightly wrong, it
|
|
486
|
+
* is useless.
|
|
487
|
+
*
|
|
488
|
+
* What works is a SUFFIX. No prefix rule connects `06…` to `+336…` without
|
|
489
|
+
* knowing the user's country, which nothing here has any business guessing, but
|
|
490
|
+
* the two agree from the ninth digit back.
|
|
491
|
+
*/
|
|
492
|
+
/** Everything that is not a digit, removed. The base of every key below. */
|
|
493
|
+
const digitsOf = (value) => value.replaceAll(/\D/g, "");
|
|
494
|
+
/**
|
|
495
|
+
* How many trailing digits make a key. Nine, measured rather than picked.
|
|
496
|
+
*
|
|
497
|
+
* | key | recent traffic resolved | ambiguous |
|
|
498
|
+
* | -------- | ----------------------- | --------- |
|
|
499
|
+
* | exact | 8.5% | 1 |
|
|
500
|
+
* | 10 | 96.7% | 5 |
|
|
501
|
+
* | **9** | **97.6%** | **6** |
|
|
502
|
+
* | 7 | 97.6% | 6 |
|
|
503
|
+
*
|
|
504
|
+
* Seven ties nine on every column measured, so nine wins on the tie-break that
|
|
505
|
+
* matters: a shorter key can only ever collide more. Ten is where French
|
|
506
|
+
* national numbers (`0612345678`, ten digits) stop lining up with the same
|
|
507
|
+
* number in E.164 (`+33612345678`, eleven) — which is exactly why 10 does no
|
|
508
|
+
* better than plain digits and 9 does.
|
|
509
|
+
*/
|
|
510
|
+
const SUFFIX_DIGITS = 9;
|
|
511
|
+
/**
|
|
512
|
+
* Below this, a number is a shortcode — a bank, a delivery service, a 2FA
|
|
513
|
+
* sender. 115 of the 958 handles in the measured `chat.db` were these. They can
|
|
514
|
+
* never resolve to a contact, and counting them as failures is how a resolver
|
|
515
|
+
* ends up reporting a far worse rate than it earns.
|
|
516
|
+
*/
|
|
517
|
+
const SHORTCODE_MAX_DIGITS = 6;
|
|
518
|
+
const isShortcode = (value) => {
|
|
519
|
+
const d = digitsOf(value);
|
|
520
|
+
return d.length > 0 && d.length <= SHORTCODE_MAX_DIGITS;
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* The lookup key, or `null` when the value is too short to make one.
|
|
524
|
+
*
|
|
525
|
+
* Returning `null` rather than a short key is deliberate: a three-digit key
|
|
526
|
+
* would match any number ending in those digits, which is the failure mode this
|
|
527
|
+
* whole module exists to avoid.
|
|
528
|
+
*/
|
|
529
|
+
const suffixKey = (value, digits = 9) => {
|
|
530
|
+
const d = digitsOf(value);
|
|
531
|
+
return d.length >= digits ? d.slice(-digits) : null;
|
|
532
|
+
};
|
|
533
|
+
/** Email keys are simply case-folded. Measured: 37 of 60 resolve, none ambiguous. */
|
|
534
|
+
const emailKey = (value) => value.trim().toLowerCase();
|
|
535
|
+
/**
|
|
536
|
+
* What kind of thing a Messages handle is.
|
|
537
|
+
*
|
|
538
|
+
* Order matters: `@` decides first, because an email address can contain digits
|
|
539
|
+
* and a phone number can never contain an `@`.
|
|
540
|
+
*/
|
|
541
|
+
const handleKind = (handle) => {
|
|
542
|
+
if (handle.includes("@")) return "email";
|
|
543
|
+
return isShortcode(handle) ? "shortcode" : "phone";
|
|
544
|
+
};
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/client/resolve.ts
|
|
547
|
+
/**
|
|
548
|
+
* Distinct PEOPLE, not distinct rows.
|
|
549
|
+
*
|
|
550
|
+
* A contact with the same number stored twice (mobile and iPhone, which Contacts
|
|
551
|
+
* does routinely) would otherwise read as ambiguous. Linked records across two
|
|
552
|
+
* accounts are collapsed on `ZLINKID` for the same reason: Contacts shows one
|
|
553
|
+
* unified card for them, and reporting two names for one person would contradict
|
|
554
|
+
* what the user sees in the app.
|
|
555
|
+
*/
|
|
556
|
+
const distinctPeople = (ids, lookup) => {
|
|
557
|
+
const seen = /* @__PURE__ */ new Map();
|
|
558
|
+
for (const id of ids) {
|
|
559
|
+
const contact = lookup.contacts.get(id);
|
|
560
|
+
if (!contact) continue;
|
|
561
|
+
const key = contact.linkId === null ? `pk:${id}` : `link:${contact.linkId}`;
|
|
562
|
+
if (!seen.has(key)) seen.set(key, contact);
|
|
563
|
+
}
|
|
564
|
+
return [...seen.values()];
|
|
565
|
+
};
|
|
566
|
+
const resolveHandle = (handle, lookup) => {
|
|
567
|
+
const kind = handleKind(handle);
|
|
568
|
+
const base = {
|
|
569
|
+
handle,
|
|
570
|
+
kind,
|
|
571
|
+
name: null,
|
|
572
|
+
contact: null,
|
|
573
|
+
matches: 0
|
|
574
|
+
};
|
|
575
|
+
if (kind === "shortcode") return {
|
|
576
|
+
...base,
|
|
577
|
+
status: "shortcode"
|
|
578
|
+
};
|
|
579
|
+
const ids = kind === "email" ? lookup.byEmail.get(emailKey(handle)) : (() => {
|
|
580
|
+
const key = suffixKey(handle, lookup.suffixDigits);
|
|
581
|
+
return key === null ? void 0 : lookup.byPhone.get(key);
|
|
582
|
+
})();
|
|
583
|
+
if (!ids?.size) return {
|
|
584
|
+
...base,
|
|
585
|
+
status: "unknown"
|
|
586
|
+
};
|
|
587
|
+
const people = distinctPeople(ids, lookup);
|
|
588
|
+
if (people.length === 1) {
|
|
589
|
+
const contact = people[0];
|
|
590
|
+
return {
|
|
591
|
+
handle,
|
|
592
|
+
kind,
|
|
593
|
+
status: "resolved",
|
|
594
|
+
name: contact.displayName,
|
|
595
|
+
contact,
|
|
596
|
+
matches: 1
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (people.length === 0) return {
|
|
600
|
+
...base,
|
|
601
|
+
status: "unknown"
|
|
602
|
+
};
|
|
603
|
+
return {
|
|
604
|
+
...base,
|
|
605
|
+
status: "ambiguous",
|
|
606
|
+
matches: people.length
|
|
607
|
+
};
|
|
608
|
+
};
|
|
609
|
+
const resolveHandles = (handles, lookup) => handles.map((h) => resolveHandle(h, lookup));
|
|
610
|
+
/** Counts by status, for a caller that wants to report coverage honestly. */
|
|
611
|
+
const summarise = (results) => {
|
|
612
|
+
const out = {
|
|
613
|
+
resolved: 0,
|
|
614
|
+
unknown: 0,
|
|
615
|
+
ambiguous: 0,
|
|
616
|
+
shortcode: 0
|
|
617
|
+
};
|
|
618
|
+
for (const r of results) out[r.status] += 1;
|
|
619
|
+
return out;
|
|
620
|
+
};
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/client/store.ts
|
|
623
|
+
/**
|
|
624
|
+
* Contacts' file lane.
|
|
625
|
+
*
|
|
626
|
+
* Reads only, and there is no Apple Events lane at all — not even a fallback.
|
|
627
|
+
* `docs/contacts.md` measured the dictionary as fast (63 ms for every id, 52 ms
|
|
628
|
+
* for every phone number) and still ruled it out for reads, because the thing
|
|
629
|
+
* this surface exists to do is join 970 stored numbers against a set of handles
|
|
630
|
+
* by suffix, and no amount of round trips gets a keyed index out of `osascript`.
|
|
631
|
+
* The consequence is worth stating: **a read-only surface needs no Automation
|
|
632
|
+
* grant**, so this server never prompts for one.
|
|
633
|
+
*
|
|
634
|
+
* ## Two things measured here that are not obvious from the schema
|
|
635
|
+
*
|
|
636
|
+
* **The store is plural.** See `locate.ts`. Every method on `ContactsIndex` fans
|
|
637
|
+
* out over shards and merges.
|
|
638
|
+
*
|
|
639
|
+
* **`ZABCDRECORD` is not a table of contacts.** It is a Core Data single-table
|
|
640
|
+
* inheritance root, and groups, containers and an info row live in it alongside
|
|
641
|
+
* people — 425 rows for 420 contacts on the probed machine. `Z_ENT` is the only
|
|
642
|
+
* discriminator, and it is resolved through `Z_PRIMARYKEY` BY NAME rather than
|
|
643
|
+
* hardcoded, because Core Data assigns those numbers per model version.
|
|
644
|
+
*/
|
|
645
|
+
/** Tables the lane cannot work without. */
|
|
646
|
+
const REQUIRED = ["ZABCDRECORD"];
|
|
647
|
+
/** The schema this was written against. Named in the drift error, not enforced. */
|
|
648
|
+
const PROBED_FINGERPRINT = "4f2871e93f6b";
|
|
649
|
+
const PROBED_MACOS = "26.6";
|
|
650
|
+
/**
|
|
651
|
+
* Entity names that mean "a person".
|
|
652
|
+
*
|
|
653
|
+
* `ABCDSubscribedContact` inherits from `ABCDContact` and is included: a contact
|
|
654
|
+
* arriving from a subscribed source is still someone whose name should appear
|
|
655
|
+
* beside their messages. Groups (`ABCDGroup`, `ABCDSmartGroup`) and the
|
|
656
|
+
* bookkeeping entities (`ABCDInfo`, `CNCDContainer`) are not people.
|
|
657
|
+
*/
|
|
658
|
+
const CONTACT_ENTITIES = /^(ABCD)?(Subscribed)?Contact$/i;
|
|
659
|
+
const num = (v) => typeof v === "number" ? v : null;
|
|
660
|
+
const text = (v) => typeof v === "string" && v.length > 0 ? v : null;
|
|
661
|
+
/**
|
|
662
|
+
* A name to show, assembled from whatever the record actually carries.
|
|
663
|
+
*
|
|
664
|
+
* Falls through deliberately: plenty of real contacts are an organisation with
|
|
665
|
+
* no person name (a garage, a doctor's office), and plenty are a first name
|
|
666
|
+
* alone. Returning an empty string would put a blank where a sender should be,
|
|
667
|
+
* so the last resort is explicit.
|
|
668
|
+
*/
|
|
669
|
+
const displayNameOf = (c) => {
|
|
670
|
+
return [c.firstName, c.lastName].filter(Boolean).join(" ").trim() || c.nickname || c.organization || "(no name)";
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Add one key to one bucket. A null key is skipped, never stored as `""` — a
|
|
674
|
+
* number too short to make a key must not become a key that matches everything.
|
|
675
|
+
*/
|
|
676
|
+
const remember = (map, key, id) => {
|
|
677
|
+
if (!key) return;
|
|
678
|
+
const bucket = map.get(key);
|
|
679
|
+
if (bucket) bucket.add(id);
|
|
680
|
+
else map.set(key, /* @__PURE__ */ new Set([id]));
|
|
681
|
+
};
|
|
682
|
+
var ContactsIndex = class ContactsIndex {
|
|
683
|
+
shards;
|
|
684
|
+
constructor(shards) {
|
|
685
|
+
this.shards = shards;
|
|
686
|
+
}
|
|
687
|
+
/** Every shard's fingerprint. More than one distinct value is worth showing. */
|
|
688
|
+
get fingerprints() {
|
|
689
|
+
return [...new Set(this.shards.map((s) => s.caps.fingerprint))];
|
|
690
|
+
}
|
|
691
|
+
get totalContacts() {
|
|
692
|
+
return this.shards.reduce((n, s) => n + s.contacts, 0);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Project a column, or a typed NULL when this store does not have it.
|
|
696
|
+
*
|
|
697
|
+
* Same guard as the other surfaces: the schema is reverse-engineered and
|
|
698
|
+
* unversioned, so an Apple rename costs one field rather than the lane.
|
|
699
|
+
*/
|
|
700
|
+
static #col(present, table, name, alias) {
|
|
701
|
+
return present.has(name) ? `${table}."${name}" AS ${alias}` : `NULL AS ${alias}`;
|
|
702
|
+
}
|
|
703
|
+
static #entFilter(caps, alias) {
|
|
704
|
+
if (!caps.contactEntities.length) return "";
|
|
705
|
+
return `WHERE ${alias}."Z_ENT" IN (${caps.contactEntities.join(", ")})`;
|
|
706
|
+
}
|
|
707
|
+
#contactsFrom(shard, where, params, limit) {
|
|
708
|
+
const c = shard.caps.recordColumns;
|
|
709
|
+
const col = ContactsIndex.#col;
|
|
710
|
+
const ent = ContactsIndex.#entFilter(shard.caps, "r");
|
|
711
|
+
const extra = where ? `${ent ? "AND" : "WHERE"} ${where}` : "";
|
|
712
|
+
const sql = `
|
|
713
|
+
SELECT r."Z_PK" AS recordPk,
|
|
714
|
+
${col(c, "r", "ZUNIQUEID", "uniqueId")},
|
|
715
|
+
${col(c, "r", "ZFIRSTNAME", "firstName")},
|
|
716
|
+
${col(c, "r", "ZLASTNAME", "lastName")},
|
|
717
|
+
${col(c, "r", "ZNICKNAME", "nickname")},
|
|
718
|
+
${col(c, "r", "ZORGANIZATION", "organization")},
|
|
719
|
+
${col(c, "r", "ZJOBTITLE", "jobTitle")},
|
|
720
|
+
${col(c, "r", "ZLINKID", "linkId")},
|
|
721
|
+
${col(c, "r", "ZCONTAINERWHERECONTACTISME", "isMe")}
|
|
722
|
+
FROM "ZABCDRECORD" r
|
|
723
|
+
${ent} ${extra}
|
|
724
|
+
ORDER BY r."ZLASTNAME" ASC, r."ZFIRSTNAME" ASC
|
|
725
|
+
LIMIT ${Math.max(1, Math.trunc(limit))}`;
|
|
726
|
+
return shard.db.prepare(sql).all(...params).map((r) => {
|
|
727
|
+
const parts = {
|
|
728
|
+
firstName: text(r.firstName),
|
|
729
|
+
lastName: text(r.lastName),
|
|
730
|
+
nickname: text(r.nickname),
|
|
731
|
+
organization: text(r.organization)
|
|
732
|
+
};
|
|
733
|
+
return {
|
|
734
|
+
recordPk: Number(r.recordPk),
|
|
735
|
+
uniqueId: text(r.uniqueId),
|
|
736
|
+
...parts,
|
|
737
|
+
jobTitle: text(r.jobTitle),
|
|
738
|
+
displayName: displayNameOf(parts),
|
|
739
|
+
source: shard.label,
|
|
740
|
+
linkId: num(r.linkId),
|
|
741
|
+
isMe: num(r.isMe) !== null
|
|
742
|
+
};
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
/** Every contact, across every shard. */
|
|
746
|
+
list(limit) {
|
|
747
|
+
return this.shards.flatMap((s) => this.#contactsFrom(s, "", [], limit)).slice(0, limit);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Name search.
|
|
751
|
+
*
|
|
752
|
+
* `LIKE ? ESCAPE '\'` with core's `escapeLike`, so a contact called "100%
|
|
753
|
+
* Design" can be searched for literally instead of matching everyone.
|
|
754
|
+
*/
|
|
755
|
+
search(query, limit) {
|
|
756
|
+
const needle = `%${escapeLike(query)}%`;
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const shard of this.shards) {
|
|
759
|
+
const c = shard.caps.recordColumns;
|
|
760
|
+
const fields = [
|
|
761
|
+
"ZFIRSTNAME",
|
|
762
|
+
"ZLASTNAME",
|
|
763
|
+
"ZNICKNAME",
|
|
764
|
+
"ORGANIZATION",
|
|
765
|
+
"ZORGANIZATION"
|
|
766
|
+
].filter((f) => c.has(f)).map((f) => `r."${f}" LIKE ? ESCAPE '\\'`);
|
|
767
|
+
if (!fields.length) continue;
|
|
768
|
+
out.push(...this.#contactsFrom(shard, `(${fields.join(" OR ")})`, fields.map(() => needle), limit));
|
|
769
|
+
}
|
|
770
|
+
return out.slice(0, limit);
|
|
771
|
+
}
|
|
772
|
+
byPk(shardLabel, recordPk) {
|
|
773
|
+
const shard = this.shards.find((s) => s.label === shardLabel);
|
|
774
|
+
if (!shard) return null;
|
|
775
|
+
return this.#contactsFrom(shard, `r."Z_PK" = ?`, [recordPk], 1)[0] ?? null;
|
|
776
|
+
}
|
|
777
|
+
#childRows(shard, table, caps, valueColumns, recordPks) {
|
|
778
|
+
if (!caps.size) return [];
|
|
779
|
+
const valueCol = valueColumns.find((v) => caps.has(v));
|
|
780
|
+
if (!valueCol || !caps.has("ZOWNER")) return [];
|
|
781
|
+
const scope = recordPks?.length ? `AND x."ZOWNER" IN (${recordPks.map(() => "?").join(", ")})` : "";
|
|
782
|
+
const sql = `
|
|
783
|
+
SELECT x."ZOWNER" AS recordPk,
|
|
784
|
+
x."${valueCol}" AS value,
|
|
785
|
+
${caps.has("ZLABEL") ? `x."ZLABEL"` : "NULL"} AS label
|
|
786
|
+
FROM "${table}" x
|
|
787
|
+
WHERE x."${valueCol}" IS NOT NULL AND x."${valueCol}" <> '' ${scope}`;
|
|
788
|
+
return shard.db.prepare(sql).all(...recordPks ?? []).flatMap((r) => {
|
|
789
|
+
const value = text(r.value);
|
|
790
|
+
const recordPk = num(r.recordPk);
|
|
791
|
+
if (!value || recordPk === null) return [];
|
|
792
|
+
return [{
|
|
793
|
+
recordPk,
|
|
794
|
+
value,
|
|
795
|
+
label: text(r.label)
|
|
796
|
+
}];
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
phonesFor(shardLabel, recordPks) {
|
|
800
|
+
const shard = this.shards.find((s) => s.label === shardLabel);
|
|
801
|
+
if (!shard) return [];
|
|
802
|
+
return this.#childRows(shard, "ZABCDPHONENUMBER", shard.caps.phoneColumns, ["ZFULLNUMBER"], recordPks);
|
|
803
|
+
}
|
|
804
|
+
emailsFor(shardLabel, recordPks) {
|
|
805
|
+
const shard = this.shards.find((s) => s.label === shardLabel);
|
|
806
|
+
if (!shard) return [];
|
|
807
|
+
return this.#childRows(shard, "ZABCDEMAILADDRESS", shard.caps.emailColumns, ["ZADDRESS", "ZADDRESSNORMALIZED"], recordPks);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* The resolver index: every phone suffix and every email, keyed to a contact.
|
|
811
|
+
*
|
|
812
|
+
* Built in one pass over every shard because a handle does not know which
|
|
813
|
+
* account its owner lives in. A key mapping to more than one DISTINCT contact
|
|
814
|
+
* is kept as such — see `resolve.ts`, which reports ambiguity rather than
|
|
815
|
+
* picking. `docs/contacts.md` measured six such collisions at nine digits, and
|
|
816
|
+
* twenty-eight at four.
|
|
817
|
+
*/
|
|
818
|
+
buildLookup(suffixDigits = 9) {
|
|
819
|
+
const byPhone = /* @__PURE__ */ new Map();
|
|
820
|
+
const byEmail = /* @__PURE__ */ new Map();
|
|
821
|
+
const contacts = /* @__PURE__ */ new Map();
|
|
822
|
+
for (const shard of this.shards) {
|
|
823
|
+
const people = this.#contactsFrom(shard, "", [], Number.MAX_SAFE_INTEGER);
|
|
824
|
+
const byPk = new Map(people.map((p) => [p.recordPk, p]));
|
|
825
|
+
for (const p of people) contacts.set(`${shard.label}:${p.recordPk}`, p);
|
|
826
|
+
for (const row of this.#childRows(shard, "ZABCDPHONENUMBER", shard.caps.phoneColumns, ["ZFULLNUMBER"])) {
|
|
827
|
+
if (!byPk.has(row.recordPk)) continue;
|
|
828
|
+
remember(byPhone, suffixKey(row.value, suffixDigits), `${shard.label}:${row.recordPk}`);
|
|
829
|
+
}
|
|
830
|
+
for (const row of this.#childRows(shard, "ZABCDEMAILADDRESS", shard.caps.emailColumns, ["ZADDRESS", "ZADDRESSNORMALIZED"])) {
|
|
831
|
+
if (!byPk.has(row.recordPk)) continue;
|
|
832
|
+
remember(byEmail, emailKey(row.value), `${shard.label}:${row.recordPk}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
byPhone,
|
|
837
|
+
byEmail,
|
|
838
|
+
contacts,
|
|
839
|
+
suffixDigits
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
close() {
|
|
843
|
+
for (const s of this.shards) try {
|
|
844
|
+
s.db.close();
|
|
845
|
+
} catch {}
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
const introspect = (db) => {
|
|
849
|
+
const recordColumns = new Set(columnsOf(db, "ZABCDRECORD"));
|
|
850
|
+
for (const t of REQUIRED) if (recordColumns.size === 0) throw new SchemaDriftError(`This Contacts store has no ${t} table. It was probed on macOS ${PROBED_MACOS} with schema fingerprint ${PROBED_FINGERPRINT} (a PROBE fingerprint — compare it against another probe run, not against the one diagnostics reports); re-run \`pnpm probe:contacts\` to see what changed.`);
|
|
851
|
+
let contactEntities = [];
|
|
852
|
+
try {
|
|
853
|
+
contactEntities = db.prepare(`SELECT Z_ENT AS ent, Z_NAME AS name FROM Z_PRIMARYKEY`).all().filter((r) => CONTACT_ENTITIES.test(r.name)).map((r) => Number(r.ent));
|
|
854
|
+
} catch {
|
|
855
|
+
contactEntities = [];
|
|
856
|
+
}
|
|
857
|
+
const phoneColumns = new Set(columnsOf(db, "ZABCDPHONENUMBER"));
|
|
858
|
+
const emailColumns = new Set(columnsOf(db, "ZABCDEMAILADDRESS"));
|
|
859
|
+
return {
|
|
860
|
+
fingerprint: fingerprintSchema(db),
|
|
861
|
+
recordColumns,
|
|
862
|
+
phoneColumns,
|
|
863
|
+
emailColumns,
|
|
864
|
+
contactEntities,
|
|
865
|
+
hasPhones: phoneColumns.size > 0,
|
|
866
|
+
hasEmails: emailColumns.size > 0,
|
|
867
|
+
hasNotes: columnsOf(db, "ZABCDNOTE").length > 0,
|
|
868
|
+
epochOffset: CORE_DATA_EPOCH_OFFSET
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
/** Count the people in one opened shard, with the entity filter applied. */
|
|
872
|
+
const countContacts = (db, caps) => {
|
|
873
|
+
const where = caps.contactEntities.length ? `WHERE "Z_ENT" IN (${caps.contactEntities.join(", ")})` : "";
|
|
874
|
+
try {
|
|
875
|
+
const row = db.prepare(`SELECT COUNT(*) AS c FROM "ZABCDRECORD" ${where}`).get();
|
|
876
|
+
return Number(row.c ?? 0);
|
|
877
|
+
} catch {
|
|
878
|
+
return 0;
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
const openShard = (path, label, mode, logger) => {
|
|
882
|
+
try {
|
|
883
|
+
const { db, mode: used, validated } = openReadOnly(path, mode, {
|
|
884
|
+
label: "Contacts store",
|
|
885
|
+
envVar: "APPLE_CONTACTS_INDEX_MODE",
|
|
886
|
+
validate: introspect,
|
|
887
|
+
fatal: (err) => err instanceof SchemaDriftError,
|
|
888
|
+
onFallback: () => logger?.debug?.("opened a Contacts store with immutable=1, which skips the write-ahead log — very recent edits may be missing until Contacts checkpoints.")
|
|
889
|
+
});
|
|
890
|
+
return {
|
|
891
|
+
db,
|
|
892
|
+
mode: used,
|
|
893
|
+
caps: validated,
|
|
894
|
+
path,
|
|
895
|
+
label,
|
|
896
|
+
contacts: countContacts(db, validated)
|
|
897
|
+
};
|
|
898
|
+
} catch (err) {
|
|
899
|
+
if (err instanceof SchemaDriftError) throw err;
|
|
900
|
+
logger?.debug?.(`skipped Contacts store ${label}: ${String(err)}`);
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/client/contacts.ts
|
|
906
|
+
var AppleContactsClient = class {
|
|
907
|
+
#config;
|
|
908
|
+
#logger;
|
|
909
|
+
#home;
|
|
910
|
+
#runner;
|
|
911
|
+
#located = null;
|
|
912
|
+
#index = null;
|
|
913
|
+
#indexTried = false;
|
|
914
|
+
#lookup = null;
|
|
915
|
+
constructor(opts) {
|
|
916
|
+
this.#config = opts.config;
|
|
917
|
+
this.#logger = opts.logger;
|
|
918
|
+
this.#home = opts.home;
|
|
919
|
+
this.#runner = opts.osascript ?? createOsascriptRunner({
|
|
920
|
+
surface: CONTACTS_SURFACE,
|
|
921
|
+
osascriptPath: opts.config.osascriptPath,
|
|
922
|
+
timeoutMs: opts.config.osascriptTimeoutMs,
|
|
923
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
get config() {
|
|
927
|
+
return this.#config;
|
|
928
|
+
}
|
|
929
|
+
located() {
|
|
930
|
+
this.#located ??= locateStores({
|
|
931
|
+
storePath: this.#config.storePath,
|
|
932
|
+
...this.#home ? { home: this.#home } : {}
|
|
933
|
+
});
|
|
934
|
+
return this.#located;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Open every readable store, once.
|
|
938
|
+
*
|
|
939
|
+
* `indexMode: "off"` is honoured as a hard no — it is what the test suite uses
|
|
940
|
+
* so that a machine WITH the grant does not silently read the developer's own
|
|
941
|
+
* address book and pass or fail on data nobody wrote.
|
|
942
|
+
*/
|
|
943
|
+
index() {
|
|
944
|
+
if (this.#indexTried) return this.#index;
|
|
945
|
+
this.#indexTried = true;
|
|
946
|
+
if (this.#config.indexMode === "off") return null;
|
|
947
|
+
const located = this.located();
|
|
948
|
+
const mode = this.#config.indexMode === "auto" ? "ro" : this.#config.indexMode;
|
|
949
|
+
const shards = [];
|
|
950
|
+
for (const candidate of located.readable) {
|
|
951
|
+
const shard = openShard(candidate.path, candidate.label, mode, this.#logger);
|
|
952
|
+
if (shard) shards.push(shard);
|
|
953
|
+
}
|
|
954
|
+
this.#index = shards.length ? new ContactsIndex(shards) : null;
|
|
955
|
+
return this.#index;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* The index, or an error naming what is wrong.
|
|
959
|
+
*
|
|
960
|
+
* Never `[]`. An empty list is the answer to "you have no contacts", and this
|
|
961
|
+
* surface has exactly one way to produce that answer wrongly — reading the
|
|
962
|
+
* root store and finding one person in it. Callers get a reason instead.
|
|
963
|
+
*/
|
|
964
|
+
#require() {
|
|
965
|
+
const index = this.index();
|
|
966
|
+
if (index) return index;
|
|
967
|
+
if (this.#config.indexMode === "off") throw new IndexUnavailableError("The Contacts index is disabled (APPLE_CONTACTS_INDEX_MODE=off). This server has no other lane, so nothing can be read until it is re-enabled.");
|
|
968
|
+
throw new ContactsUnavailableError(this.located().reason ?? "No readable Contacts store was found.");
|
|
969
|
+
}
|
|
970
|
+
list(limit) {
|
|
971
|
+
return this.#require().list(limit ?? this.#config.maxResults);
|
|
972
|
+
}
|
|
973
|
+
search(query, limit) {
|
|
974
|
+
return this.#require().search(query, limit ?? this.#config.maxResults);
|
|
975
|
+
}
|
|
976
|
+
/** One contact with its phone numbers and email addresses. */
|
|
977
|
+
get(source, recordPk) {
|
|
978
|
+
const index = this.#require();
|
|
979
|
+
const contact = index.byPk(source, recordPk);
|
|
980
|
+
if (!contact) return null;
|
|
981
|
+
return {
|
|
982
|
+
...contact,
|
|
983
|
+
phones: index.phonesFor(source, [recordPk]).map(({ value, label }) => ({
|
|
984
|
+
value,
|
|
985
|
+
label
|
|
986
|
+
})),
|
|
987
|
+
emails: index.emailsFor(source, [recordPk]).map(({ value, label }) => ({
|
|
988
|
+
value,
|
|
989
|
+
label
|
|
990
|
+
}))
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Built on first use and kept.
|
|
995
|
+
*
|
|
996
|
+
* Walking every contact and every phone row is cheap once (970 rows on the
|
|
997
|
+
* probed store) and pointless per call. There is no TTL: this process does not
|
|
998
|
+
* write to Contacts, and a server that has been running while the user edited
|
|
999
|
+
* their address book is not the case worth optimising for. `diagnostics`
|
|
1000
|
+
* reports when it was built.
|
|
1001
|
+
*/
|
|
1002
|
+
lookup() {
|
|
1003
|
+
this.#lookup ??= this.#require().buildLookup(this.#config.phoneSuffixDigits);
|
|
1004
|
+
return this.#lookup;
|
|
1005
|
+
}
|
|
1006
|
+
/** The function `packages/messages` is meant to call. */
|
|
1007
|
+
resolve(handles) {
|
|
1008
|
+
const results = resolveHandles(handles, this.lookup());
|
|
1009
|
+
return {
|
|
1010
|
+
results,
|
|
1011
|
+
summary: summarise(results)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async #run(scriptText, params) {
|
|
1015
|
+
try {
|
|
1016
|
+
return await withBusyRetry(() => this.#runner.run(scriptText, params));
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
const code = err?.details?.code;
|
|
1019
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1020
|
+
if (code === "CONTACT_NOT_FOUND") throw new ContactNotFoundError(message);
|
|
1021
|
+
if (code === "CREATE_NOT_PERSISTED" || code === "UPDATE_NOT_PERSISTED") throw new ContactWriteNotPersistedError(message);
|
|
1022
|
+
throw err;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Invalidate the read lane after a write.
|
|
1027
|
+
*
|
|
1028
|
+
* The index and the resolver lookup are both built once and kept, so a contact
|
|
1029
|
+
* created through Apple Events would otherwise stay invisible to `resolve` for
|
|
1030
|
+
* the life of the process — the exact "wrote it, cannot find it" confusion the
|
|
1031
|
+
* id bridge exists to prevent.
|
|
1032
|
+
*/
|
|
1033
|
+
#invalidate() {
|
|
1034
|
+
this.#index?.close();
|
|
1035
|
+
this.#index = null;
|
|
1036
|
+
this.#lookup = null;
|
|
1037
|
+
this.#indexTried = false;
|
|
1038
|
+
}
|
|
1039
|
+
#shapeWrite(data) {
|
|
1040
|
+
const personId = typeof data.id === "string" ? data.id : null;
|
|
1041
|
+
const shaped = (key) => Array.isArray(data[key]) ? data[key].map((r) => ({
|
|
1042
|
+
label: typeof r.label === "string" ? r.label : null,
|
|
1043
|
+
value: typeof r.value === "string" ? r.value : null
|
|
1044
|
+
})) : [];
|
|
1045
|
+
return {
|
|
1046
|
+
ref: null,
|
|
1047
|
+
personId,
|
|
1048
|
+
name: typeof data.name === "string" ? data.name : null,
|
|
1049
|
+
organization: typeof data.organization === "string" ? data.organization : null,
|
|
1050
|
+
phones: shaped("phones"),
|
|
1051
|
+
emails: shaped("emails"),
|
|
1052
|
+
source: "apple-events"
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
async createContact(input) {
|
|
1056
|
+
const data = await this.#run(CREATE_CONTACT, {
|
|
1057
|
+
fields: input.fields,
|
|
1058
|
+
phones: input.phones ?? [],
|
|
1059
|
+
emails: input.emails ?? [],
|
|
1060
|
+
allowLaunch: true
|
|
1061
|
+
});
|
|
1062
|
+
this.#invalidate();
|
|
1063
|
+
return this.#shapeWrite(data);
|
|
1064
|
+
}
|
|
1065
|
+
async updateContact(input) {
|
|
1066
|
+
const data = await this.#run(UPDATE_CONTACT, {
|
|
1067
|
+
personId: input.personId,
|
|
1068
|
+
fields: input.fields,
|
|
1069
|
+
phones: input.phones ?? [],
|
|
1070
|
+
emails: input.emails ?? [],
|
|
1071
|
+
allowLaunch: true
|
|
1072
|
+
});
|
|
1073
|
+
this.#invalidate();
|
|
1074
|
+
return this.#shapeWrite(data);
|
|
1075
|
+
}
|
|
1076
|
+
status() {
|
|
1077
|
+
const index = this.index();
|
|
1078
|
+
return {
|
|
1079
|
+
located: this.located(),
|
|
1080
|
+
shards: (index?.shards ?? []).map((s) => ({
|
|
1081
|
+
label: s.label,
|
|
1082
|
+
path: s.path,
|
|
1083
|
+
mode: s.mode,
|
|
1084
|
+
contacts: s.contacts,
|
|
1085
|
+
fingerprint: s.caps.fingerprint
|
|
1086
|
+
})),
|
|
1087
|
+
totalContacts: index?.totalContacts ?? 0,
|
|
1088
|
+
indexMode: this.#config.indexMode
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
close() {
|
|
1092
|
+
this.#index?.close();
|
|
1093
|
+
this.#index = null;
|
|
1094
|
+
this.#lookup = null;
|
|
1095
|
+
this.#indexTried = false;
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/client/ref.ts
|
|
1100
|
+
/**
|
|
1101
|
+
* `k1:<account>/<recordPk>` — an opaque handle for one contact.
|
|
1102
|
+
*
|
|
1103
|
+
* ## Why the account rides along
|
|
1104
|
+
*
|
|
1105
|
+
* Because the store is plural. A record's `Z_PK` is a rowid, and rowids are only
|
|
1106
|
+
* unique WITHIN one database — the root store and each account store number
|
|
1107
|
+
* their rows from 1 independently. A bare pk would therefore resolve to a
|
|
1108
|
+
* different person depending on which store happened to be read first, which is
|
|
1109
|
+
* the kind of bug that produces a plausible wrong answer rather than an error.
|
|
1110
|
+
*
|
|
1111
|
+
* ## Why not `ZUNIQUEID`
|
|
1112
|
+
*
|
|
1113
|
+
* It exists and is stable, but it is not what the child tables join on —
|
|
1114
|
+
* `ZABCDPHONENUMBER.ZOWNER` points at `Z_PK`. Carrying the pk means a `get`
|
|
1115
|
+
* needs no extra lookup, and the account prefix supplies the uniqueness the pk
|
|
1116
|
+
* lacks. `uniqueId` is still returned on results for callers that want a
|
|
1117
|
+
* durable identity across a re-index.
|
|
1118
|
+
*
|
|
1119
|
+
* ## Why `k1`
|
|
1120
|
+
*
|
|
1121
|
+
* `c1:` is Calendar's and `r1:` is Reminders'; `k1` is free and the version
|
|
1122
|
+
* prefix keeps a future scheme change additive rather than a silent
|
|
1123
|
+
* reinterpretation of refs already sitting in a conversation.
|
|
1124
|
+
*/
|
|
1125
|
+
const REF_VERSION = "k1";
|
|
1126
|
+
/**
|
|
1127
|
+
* An account label can contain almost anything — it is a directory name, and on
|
|
1128
|
+
* the probed machine a UUID — so the pk is anchored as the tail and the label is
|
|
1129
|
+
* whatever precedes the last `/`. Splitting on the FIRST separator would break
|
|
1130
|
+
* on any label containing one.
|
|
1131
|
+
*/
|
|
1132
|
+
const REF_PATTERN = /^k1:(.+)\/(\d+)$/;
|
|
1133
|
+
var InvalidContactRefError = class extends AppleAutomationError {
|
|
1134
|
+
name = "InvalidContactRefError";
|
|
1135
|
+
constructor(raw) {
|
|
1136
|
+
super(`"${raw}" is not a contact ref. Refs come from apple_contacts_search_contacts or apple_contacts_list_contacts and look like "k1:<account>/<id>" — they are opaque and must not be constructed by hand.` + (raw.startsWith("c1:") || raw.startsWith("r1:") ? " That one belongs to another surface: \"c1:\" refs are Calendar events and \"r1:\" refs are Reminders." : ""), { ref: raw });
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
const encodeRef = (source, recordPk) => `k1:${source}/${recordPk}`;
|
|
1140
|
+
const decodeRef = (raw) => {
|
|
1141
|
+
const m = REF_PATTERN.exec(raw.trim());
|
|
1142
|
+
if (!m) throw new InvalidContactRefError(raw);
|
|
1143
|
+
const recordPk = Number(m[2]);
|
|
1144
|
+
if (!Number.isSafeInteger(recordPk) || recordPk <= 0) throw new InvalidContactRefError(raw);
|
|
1145
|
+
return {
|
|
1146
|
+
source: m[1],
|
|
1147
|
+
recordPk
|
|
1148
|
+
};
|
|
1149
|
+
};
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/config.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* Configuration is environment-only — this server holds no secret at all, its
|
|
1154
|
+
* access is the macOS permission the user granted.
|
|
1155
|
+
*
|
|
1156
|
+
* Note what is ABSENT, and why:
|
|
1157
|
+
*
|
|
1158
|
+
* - **No `allowWrites` behaviour.** It is inherited from `BaseConfigSchema` and
|
|
1159
|
+
* deliberately ignored: this surface registers no mutating tool, so there is
|
|
1160
|
+
* nothing for the flag to gate. Editing someone's address book from a tool
|
|
1161
|
+
* call was never part of what Contacts was probed for.
|
|
1162
|
+
* - **No `osascript` settings in use.** Also inherited, also unused — there is
|
|
1163
|
+
* no Apple Events lane here at all, which is what lets this server run without
|
|
1164
|
+
* an Automation grant.
|
|
1165
|
+
* - **No account allowlist.** Contacts are unioned across accounts precisely so
|
|
1166
|
+
* that a handle resolves wherever its owner lives; scoping that by account
|
|
1167
|
+
* would reintroduce the bug this surface exists to avoid.
|
|
1168
|
+
*/
|
|
1169
|
+
const ConfigSchema = BaseConfigSchema.extend({
|
|
1170
|
+
/**
|
|
1171
|
+
* Explicit store path. Bypasses discovery — for tests and forensic copies.
|
|
1172
|
+
*
|
|
1173
|
+
* Naming one file also DISABLES the union, which is the point of having it:
|
|
1174
|
+
* a test needs a single known database, not whatever the machine happens to
|
|
1175
|
+
* hold.
|
|
1176
|
+
*/
|
|
1177
|
+
storePath: z.string().optional(),
|
|
1178
|
+
indexMode: z.enum([
|
|
1179
|
+
"auto",
|
|
1180
|
+
"ro",
|
|
1181
|
+
"immutable",
|
|
1182
|
+
"off"
|
|
1183
|
+
]).default("auto"),
|
|
1184
|
+
/**
|
|
1185
|
+
* Trailing digits that make a phone key.
|
|
1186
|
+
*
|
|
1187
|
+
* Exposed because the right value is a fact about the user's country, and nine
|
|
1188
|
+
* was measured on one machine with mostly French and E.164 numbers. Lower is
|
|
1189
|
+
* more forgiving and collides more; the ambiguity count in `diagnostics` is
|
|
1190
|
+
* how to tell whether a change helped.
|
|
1191
|
+
*/
|
|
1192
|
+
phoneSuffixDigits: z.number().int().min(6).max(15).default(9)
|
|
1193
|
+
}).strict();
|
|
1194
|
+
const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
1195
|
+
allowWrites: parseBool(env.APPLE_CONTACTS_ALLOW_WRITES),
|
|
1196
|
+
debug: parseBool(env.APPLE_CONTACTS_DEBUG),
|
|
1197
|
+
storePath: trimmed(env.APPLE_CONTACTS_STORE),
|
|
1198
|
+
indexMode: trimmed(env.APPLE_CONTACTS_INDEX_MODE),
|
|
1199
|
+
phoneSuffixDigits: parseIntOpt(env.APPLE_CONTACTS_PHONE_SUFFIX_DIGITS),
|
|
1200
|
+
osascriptPath: trimmed(env.APPLE_CONTACTS_OSASCRIPT_PATH),
|
|
1201
|
+
osascriptTimeoutMs: parseIntOpt(env.APPLE_CONTACTS_OSASCRIPT_TIMEOUT_MS),
|
|
1202
|
+
maxResults: parseIntOpt(env.APPLE_CONTACTS_MAX_RESULTS)
|
|
1203
|
+
});
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/tools/actions.ts
|
|
1206
|
+
/**
|
|
1207
|
+
* The mutating tools. Registered only when `allowWrites` is on, and never
|
|
1208
|
+
* merely refused — an MCP client caches the tool list, so a tool that exists and
|
|
1209
|
+
* says no is a tool the model will keep trying.
|
|
1210
|
+
*
|
|
1211
|
+
* Two verbs, because the dictionary has two. There is no `delete_contacts`:
|
|
1212
|
+
* `sdef /System/Applications/Contacts.app` contains no delete command of any
|
|
1213
|
+
* kind, and writes go through Apple Events on every surface here because the
|
|
1214
|
+
* store is opened `PRAGMA query_only`. See `client/jxa/core.ts`.
|
|
1215
|
+
*/
|
|
1216
|
+
const labelledValue = z.object({
|
|
1217
|
+
label: z.string().optional().describe("Which kind, e.g. \"mobile\", \"home\", \"work\". Defaults to mobile for phones."),
|
|
1218
|
+
value: z.string().min(1)
|
|
1219
|
+
});
|
|
1220
|
+
const fields = {
|
|
1221
|
+
firstName: z.string().nullable().optional(),
|
|
1222
|
+
lastName: z.string().nullable().optional(),
|
|
1223
|
+
nickname: z.string().nullable().optional(),
|
|
1224
|
+
organization: z.string().nullable().optional(),
|
|
1225
|
+
jobTitle: z.string().nullable().optional(),
|
|
1226
|
+
department: z.string().nullable().optional(),
|
|
1227
|
+
note: z.string().nullable().optional(),
|
|
1228
|
+
company: z.boolean().optional().describe("True for an organisation rather than a person — Contacts shows it differently.")
|
|
1229
|
+
};
|
|
1230
|
+
const registerActionTools = (server, client) => {
|
|
1231
|
+
server.registerTool("apple_contacts_create_contact", {
|
|
1232
|
+
description: "Create a new contact in the address book. This is a real card in the user's real Contacts, and on an iCloud account it syncs to their other devices within seconds. Give at least one of firstName, lastName or organization. Contacts keeps edits in an unsaved buffer and its save command commits EVERYTHING pending, so this also saves any edit someone has half-typed in the Contacts window. That is how the app's scripting works, not a choice this server makes. The result is re-read after saving, so what comes back is what Contacts stored rather than what was asked for.",
|
|
1233
|
+
inputSchema: {
|
|
1234
|
+
...fields,
|
|
1235
|
+
phones: z.array(labelledValue).optional(),
|
|
1236
|
+
emails: z.array(labelledValue).optional()
|
|
1237
|
+
},
|
|
1238
|
+
annotations: {
|
|
1239
|
+
readOnlyHint: false,
|
|
1240
|
+
destructiveHint: false,
|
|
1241
|
+
idempotentHint: false
|
|
1242
|
+
}
|
|
1243
|
+
}, async ({ phones, emails, ...rest }) => wrap(async () => {
|
|
1244
|
+
if (!rest.firstName && !rest.lastName && !rest.organization) return fail("A contact needs at least one of firstName, lastName or organization — Contacts will otherwise create a nameless card that is hard to find again.");
|
|
1245
|
+
return ok(await client.createContact({
|
|
1246
|
+
fields: rest,
|
|
1247
|
+
...phones ? { phones } : {},
|
|
1248
|
+
...emails ? { emails } : {}
|
|
1249
|
+
}));
|
|
1250
|
+
}));
|
|
1251
|
+
server.registerTool("apple_contacts_update_contact", {
|
|
1252
|
+
description: "Change an existing contact, or add a phone number or email address to one. Omitting a field leaves it alone; passing null clears it. Phones and emails are ADDED, never replaced — Contacts models them as separate objects, and there is no way to remove one through its scripting dictionary. Contacts keeps edits in an unsaved buffer and its save command commits EVERYTHING pending, so this also saves any edit someone has half-typed in the Contacts window. That is how the app's scripting works, not a choice this server makes. The result is re-read after saving, so what comes back is what Contacts stored rather than what was asked for.",
|
|
1253
|
+
inputSchema: {
|
|
1254
|
+
ref: z.string().min(1).describe("A contact ref from a search or resolve result (looks like \"k1:<account>/<id>\"). Do not construct one by hand."),
|
|
1255
|
+
...fields,
|
|
1256
|
+
phones: z.array(labelledValue).optional().describe("Added to whatever is already there."),
|
|
1257
|
+
emails: z.array(labelledValue).optional().describe("Added to whatever is already there.")
|
|
1258
|
+
},
|
|
1259
|
+
annotations: {
|
|
1260
|
+
readOnlyHint: false,
|
|
1261
|
+
destructiveHint: false,
|
|
1262
|
+
idempotentHint: false
|
|
1263
|
+
}
|
|
1264
|
+
}, async ({ ref, phones, emails, ...rest }) => wrap(async () => {
|
|
1265
|
+
const decoded = decodeRef(ref);
|
|
1266
|
+
const contact = client.get(decoded.source, decoded.recordPk);
|
|
1267
|
+
if (!contact) return fail(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`);
|
|
1268
|
+
if (!contact.uniqueId) return fail(`The contact "${contact.displayName}" has no stable identifier in the address book database, so it cannot be addressed through Contacts' scripting interface. Edit it in Contacts.app instead.`);
|
|
1269
|
+
return ok(await client.updateContact({
|
|
1270
|
+
personId: contact.uniqueId,
|
|
1271
|
+
fields: rest,
|
|
1272
|
+
...phones ? { phones } : {},
|
|
1273
|
+
...emails ? { emails } : {}
|
|
1274
|
+
}));
|
|
1275
|
+
}));
|
|
1276
|
+
};
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/tools/contacts.ts
|
|
1279
|
+
/**
|
|
1280
|
+
* NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because most
|
|
1281
|
+
* surfaces reach Apple Events. Contacts reads synchronous SQLite and never
|
|
1282
|
+
* leaves the process, so the thunks are marked async here rather than widening a
|
|
1283
|
+
* shared signature for every surface to accommodate one.
|
|
1284
|
+
*/
|
|
1285
|
+
const registerContactTools = (server, client) => {
|
|
1286
|
+
server.registerTool("apple_contacts_search_contacts", {
|
|
1287
|
+
description: "Search the address book by name, nickname or organisation. Returns a ref for each match, plus which account it came from — the same person can legitimately appear twice if they are in two accounts. Use apple_contacts_get_contact for phone numbers and email addresses.",
|
|
1288
|
+
inputSchema: {
|
|
1289
|
+
query: z.string().min(1).describe("Text to look for in names and organisations."),
|
|
1290
|
+
limit: limitArg
|
|
1291
|
+
},
|
|
1292
|
+
annotations: { readOnlyHint: true }
|
|
1293
|
+
}, async ({ query, limit }) => wrap(async () => client.search(query, limit).map((c) => ({
|
|
1294
|
+
ref: encodeRef(c.source, c.recordPk),
|
|
1295
|
+
name: c.displayName,
|
|
1296
|
+
organization: c.organization,
|
|
1297
|
+
jobTitle: c.jobTitle,
|
|
1298
|
+
account: c.source
|
|
1299
|
+
}))));
|
|
1300
|
+
server.registerTool("apple_contacts_list_contacts", {
|
|
1301
|
+
description: "List contacts across every account. This is the whole address book, so prefer apple_contacts_search_contacts when you are looking for someone specific.",
|
|
1302
|
+
inputSchema: { limit: limitArg },
|
|
1303
|
+
annotations: { readOnlyHint: true }
|
|
1304
|
+
}, async ({ limit }) => wrap(async () => client.list(limit).map((c) => ({
|
|
1305
|
+
ref: encodeRef(c.source, c.recordPk),
|
|
1306
|
+
name: c.displayName,
|
|
1307
|
+
organization: c.organization,
|
|
1308
|
+
account: c.source
|
|
1309
|
+
}))));
|
|
1310
|
+
server.registerTool("apple_contacts_get_contact", {
|
|
1311
|
+
description: "One contact in full: name, organisation, job title, every phone number and every email address, each with its label.",
|
|
1312
|
+
inputSchema: { ref: z.string().min(1).describe("An opaque contact ref from a list or search result (looks like \"k1:<account>/<id>\"). Do not construct one by hand.") },
|
|
1313
|
+
annotations: { readOnlyHint: true }
|
|
1314
|
+
}, async ({ ref }) => wrap(async () => {
|
|
1315
|
+
const decoded = decodeRef(ref);
|
|
1316
|
+
const contact = client.get(decoded.source, decoded.recordPk);
|
|
1317
|
+
if (!contact) return fail(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`);
|
|
1318
|
+
return ok({
|
|
1319
|
+
ref,
|
|
1320
|
+
name: contact.displayName,
|
|
1321
|
+
firstName: contact.firstName,
|
|
1322
|
+
lastName: contact.lastName,
|
|
1323
|
+
nickname: contact.nickname,
|
|
1324
|
+
organization: contact.organization,
|
|
1325
|
+
jobTitle: contact.jobTitle,
|
|
1326
|
+
account: contact.source,
|
|
1327
|
+
isMe: contact.isMe,
|
|
1328
|
+
phones: contact.phones,
|
|
1329
|
+
emails: contact.emails
|
|
1330
|
+
});
|
|
1331
|
+
}));
|
|
1332
|
+
};
|
|
1333
|
+
//#endregion
|
|
1334
|
+
//#region src/tools/diagnostics.ts
|
|
1335
|
+
/**
|
|
1336
|
+
* What this server can currently do, and why not more.
|
|
1337
|
+
*
|
|
1338
|
+
* The caveats are the point. Two of them are specific to this surface and both
|
|
1339
|
+
* produce a plausible wrong answer rather than an error, which is exactly the
|
|
1340
|
+
* kind of thing a caller cannot discover for itself.
|
|
1341
|
+
*/
|
|
1342
|
+
const registerDiagnosticsTools = (server, client) => {
|
|
1343
|
+
server.registerTool("apple_contacts_diagnostics", {
|
|
1344
|
+
description: "Report which Contacts stores were opened, how many contacts each holds, and what this server cannot do. Start here when a lookup returns nothing.",
|
|
1345
|
+
inputSchema: {},
|
|
1346
|
+
annotations: { readOnlyHint: true }
|
|
1347
|
+
}, async () => wrap(async () => {
|
|
1348
|
+
const status = client.status();
|
|
1349
|
+
const located = status.located;
|
|
1350
|
+
return {
|
|
1351
|
+
server: {
|
|
1352
|
+
name: BUILD_INFO.name,
|
|
1353
|
+
version: BUILD_INFO.version
|
|
1354
|
+
},
|
|
1355
|
+
lane: {
|
|
1356
|
+
reads: "file lane (read-only SQLite)",
|
|
1357
|
+
writes: "none — this server registers no mutating tool",
|
|
1358
|
+
appleEvents: "not used at all, so no Automation grant is needed or requested"
|
|
1359
|
+
},
|
|
1360
|
+
stores: {
|
|
1361
|
+
directory: located.dirPath,
|
|
1362
|
+
directoryListable: located.dirListable,
|
|
1363
|
+
found: located.candidates.length,
|
|
1364
|
+
opened: status.shards.length,
|
|
1365
|
+
sourcesSeen: located.sourceCount,
|
|
1366
|
+
totalContacts: status.totalContacts,
|
|
1367
|
+
shards: status.shards,
|
|
1368
|
+
indexMode: status.indexMode,
|
|
1369
|
+
reason: located.reason
|
|
1370
|
+
},
|
|
1371
|
+
resolution: {
|
|
1372
|
+
phoneSuffixDigits: client.config.phoneSuffixDigits,
|
|
1373
|
+
note: "Phone matching uses the last N digits because Contacts stores numbers as typed. Fewer digits resolves more handles and collides more; the ambiguous count in a resolve result is how to tell whether a change helped."
|
|
1374
|
+
},
|
|
1375
|
+
caveats: [
|
|
1376
|
+
"Contacts is protected by its own privacy permission, NOT by Full Disk Access, and unlike Full Disk Access macOS prompts for it. A store that cannot be opened usually means that prompt was dismissed — re-enable this app under System Settings > Privacy & Security > Contacts.",
|
|
1377
|
+
"The address book is spread across several databases: one per account, plus a root store that is normally almost empty. All readable ones are unioned. If `opened` is lower than `found`, some accounts are missing from every answer here.",
|
|
1378
|
+
"A handle that resolves to more than one contact is reported as ambiguous with no name, never as a guess. Putting the wrong name on a message is worse than putting none, because it does not look wrong.",
|
|
1379
|
+
"Contacts held in two accounts are folded together on their link id, matching what Contacts.app shows as one unified card. A contact with no link id is not folded.",
|
|
1380
|
+
"This server is read-only by construction. It cannot create, edit or delete a contact, and enabling writes does not add a tool."
|
|
1381
|
+
]
|
|
1382
|
+
};
|
|
1383
|
+
}));
|
|
1384
|
+
};
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/tools/resolve.ts
|
|
1387
|
+
/**
|
|
1388
|
+
* The tool this surface was built for.
|
|
1389
|
+
*
|
|
1390
|
+
* Everything else here is an ordinary address book server. This one exists
|
|
1391
|
+
* because `chat.db` — and a caller ID, and an email header — carries an
|
|
1392
|
+
* identifier and no name, and turning one into the other is the only thing on
|
|
1393
|
+
* this machine that can.
|
|
1394
|
+
*/
|
|
1395
|
+
const registerResolveTools = (server, client) => {
|
|
1396
|
+
server.registerTool("apple_contacts_resolve_handles", {
|
|
1397
|
+
description: "Turn phone numbers or email addresses into contact names. Give it the raw identifiers from somewhere else — Messages handles, a caller ID, an email header — and it returns one result per handle.\n\nRead `status` on every result rather than assuming a name came back:\n- \"resolved\" — exactly one contact. `name` is set.\n- \"unknown\" — nobody in the address book has this number. COMMON AND NOT AN ERROR: measured on a real store, about one in six of even the busiest correspondents does not resolve. Show the raw handle.\n- \"ambiguous\" — more than one contact has it, so no name is returned. Do not guess; `matches` says how many.\n- \"shortcode\" — a bank, a courier, a 2FA sender. Can never be a contact.\n\nPhone matching is by trailing digits, because Contacts stores numbers as typed (\"06 12 34 56 78\") while most systems hand you E.164 (\"+33612345678\"). Exact string matching resolves almost nothing and is not used.",
|
|
1398
|
+
inputSchema: { handles: z.array(z.string().min(1)).min(1).max(500).describe("Phone numbers or email addresses, in any format — \"+33612345678\", \"06 12 34 56 78\" and \"user@example.com\" all work.") },
|
|
1399
|
+
annotations: { readOnlyHint: true }
|
|
1400
|
+
}, async ({ handles }) => wrap(async () => {
|
|
1401
|
+
const { results, summary } = client.resolve(handles);
|
|
1402
|
+
return {
|
|
1403
|
+
summary,
|
|
1404
|
+
results: results.map((r) => ({
|
|
1405
|
+
handle: r.handle,
|
|
1406
|
+
kind: r.kind,
|
|
1407
|
+
status: r.status,
|
|
1408
|
+
name: r.name,
|
|
1409
|
+
matches: r.matches,
|
|
1410
|
+
...r.contact ? {
|
|
1411
|
+
ref: encodeRef(r.contact.source, r.contact.recordPk),
|
|
1412
|
+
organization: r.contact.organization,
|
|
1413
|
+
account: r.contact.source
|
|
1414
|
+
} : {}
|
|
1415
|
+
}))
|
|
1416
|
+
};
|
|
1417
|
+
}));
|
|
1418
|
+
};
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/tools/index.ts
|
|
1421
|
+
/**
|
|
1422
|
+
* Register the Apple Contacts tools.
|
|
1423
|
+
*
|
|
1424
|
+
* READS are file-lane and ask for no Automation grant. WRITES are Apple Events,
|
|
1425
|
+
* always — the store is opened `PRAGMA query_only` because Contacts owns it and
|
|
1426
|
+
* reconciles it against iCloud, so writing to it would corrupt sync state.
|
|
1427
|
+
*
|
|
1428
|
+
* That split has a cost worth stating plainly: this surface used to need no
|
|
1429
|
+
* Automation grant at all, which docs/distribution.md calls the strongest
|
|
1430
|
+
* argument for file-first. Turning writes on gives that up — the first write
|
|
1431
|
+
* prompts for permission to control Contacts. With `allowWrites` off, nothing
|
|
1432
|
+
* here ever sends an Apple Event and the old property still holds.
|
|
1433
|
+
*
|
|
1434
|
+
* There is no delete. Contacts' scripting dictionary has no delete command of
|
|
1435
|
+
* any kind; see `client/jxa/core.ts` for the measurement.
|
|
1436
|
+
*
|
|
1437
|
+
* The registered set does NOT vary with whether the store is readable. That is a
|
|
1438
|
+
* runtime condition which can change while the process lives, and MCP clients
|
|
1439
|
+
* cache the tool list, so a tool that appeared and disappeared would leave
|
|
1440
|
+
* clients calling names the server no longer has. Tools that need the store
|
|
1441
|
+
* report what is missing instead.
|
|
1442
|
+
*/
|
|
1443
|
+
const registerTools = (server, client, ctx) => {
|
|
1444
|
+
registerDiagnosticsTools(server, client);
|
|
1445
|
+
registerContactTools(server, client);
|
|
1446
|
+
registerResolveTools(server, client);
|
|
1447
|
+
if (!ctx.allowWrites) return;
|
|
1448
|
+
registerActionTools(server, client);
|
|
1449
|
+
};
|
|
1450
|
+
//#endregion
|
|
1451
|
+
//#region src/server.ts
|
|
1452
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
1453
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
1454
|
+
/**
|
|
1455
|
+
* Build the server. Side-effect free: it opens no database and reads no file,
|
|
1456
|
+
* so a test can construct it freely and every external dependency arrives
|
|
1457
|
+
* through an option.
|
|
1458
|
+
*/
|
|
1459
|
+
const createServer = (opts) => {
|
|
1460
|
+
const { config } = opts;
|
|
1461
|
+
const server = new McpServer({
|
|
1462
|
+
name: SERVER_NAME,
|
|
1463
|
+
version: SERVER_VERSION
|
|
1464
|
+
});
|
|
1465
|
+
const client = new AppleContactsClient({
|
|
1466
|
+
config,
|
|
1467
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
1468
|
+
...opts.osascript ? { osascript: opts.osascript } : {},
|
|
1469
|
+
...opts.home ? { home: opts.home } : {}
|
|
1470
|
+
});
|
|
1471
|
+
registerTools(server, client, { allowWrites: config.allowWrites });
|
|
1472
|
+
return {
|
|
1473
|
+
server,
|
|
1474
|
+
client
|
|
1475
|
+
};
|
|
1476
|
+
};
|
|
1477
|
+
//#endregion
|
|
1478
|
+
export { AppleContactsError as A, isShortcode as C, STORE_FILENAME as D, SOURCES_DIRNAME as E, IndexUnavailableError$1 as F, SchemaDriftError$1 as I, BUILD_INFO as L, CONTACTS_SURFACE as M, ContactNotFoundError as N, defaultDirPath as O, ContactsUnavailableError as P, handleKind as S, ADDRESSBOOK_DIR as T, resolveHandles as _, loadConfig as a, digitsOf as b, decodeRef as c, ContactsIndex as d, countContacts as f, resolveHandle as g, openShard as h, registerTools as i, CONTACTS_BUNDLE_ID as j, locateStores as k, encodeRef as l, introspect as m, SERVER_VERSION as n, InvalidContactRefError as o, displayNameOf as p, createServer as r, REF_VERSION as s, SERVER_NAME as t, AppleContactsClient as u, summarise as v, suffixKey as w, emailKey as x, SUFFIX_DIGITS as y };
|
|
1479
|
+
|
|
1480
|
+
//# sourceMappingURL=server-BjWSCaMN.js.map
|