@mgcrea/mcp-apple-maps 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 +60 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +633 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-DgIy0w0S.js +2080 -0
- package/dist/server-DgIy0w0S.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
import { AppleAutomationError, AppleAutomationError as AppleMapsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, compact, describeStore, detectEpoch, escapeLike, fail, fingerprintSchema, inspectFile, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, trimmed, wrap, wrapResult } 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 { execFileSync } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
//#region src/build-info.ts
|
|
11
|
+
const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
12
|
+
name: "@mgcrea/mcp-apple-maps",
|
|
13
|
+
version: "0.0.0"
|
|
14
|
+
});
|
|
15
|
+
const BUILD_INFO = {
|
|
16
|
+
name: pkg.name,
|
|
17
|
+
version: pkg.version,
|
|
18
|
+
gitCommit: "1779320",
|
|
19
|
+
gitCommitDate: "2026-08-30T12:29:09+02:00"
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/client/dates.ts
|
|
23
|
+
/**
|
|
24
|
+
* Date handling for the Maps tools.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately much smaller than `packages/safari/src/client/dates.ts`. That
|
|
27
|
+
* surface needed a whole input grammar because history is a range query; Maps'
|
|
28
|
+
* entities are lists a person curates, and v1 ships no tool taking a date
|
|
29
|
+
* argument. So this module solves only the OUTPUT half — turning a stored
|
|
30
|
+
* number into an instant. Inventing a parser nothing calls would be a fourth
|
|
31
|
+
* copy of a grammar `packages/safari` has already noted should be hoisted into
|
|
32
|
+
* core rather than duplicated again.
|
|
33
|
+
*
|
|
34
|
+
* ## The epoch is DETECTED, never assumed
|
|
35
|
+
*
|
|
36
|
+
* `docs/maps.md` records the measurement: `ZCREATETIME` resolves to
|
|
37
|
+
* **apple-seconds** (Core Data, 2001 anchor). The same value read as unix
|
|
38
|
+
* seconds lands in **1995** — well-formed, plausible at a glance, and wrong by
|
|
39
|
+
* 31 years. `pnpm probe:maps` prints both readings side by side precisely
|
|
40
|
+
* because the wrong one does not announce itself.
|
|
41
|
+
*
|
|
42
|
+
* So the offset is resolved from the store's own newest timestamp at open time,
|
|
43
|
+
* and when it cannot be resolved every date renders `null` rather than being
|
|
44
|
+
* guessed. An absent timestamp is a visible gap somebody can report; a
|
|
45
|
+
* confidently wrong one is not. This is the discipline `packages/safari`
|
|
46
|
+
* arrived at after a probe misread its own column — adopted here rather than
|
|
47
|
+
* re-derived.
|
|
48
|
+
*/
|
|
49
|
+
/** What `detectEpoch` says when it has matched nothing. Same shape as Safari's. */
|
|
50
|
+
const GAVE_UP = /^(no dated rows|neither epoch)/;
|
|
51
|
+
const resolveEpoch = (maxTimestamp, now = Date.now()) => {
|
|
52
|
+
const { offset, reason } = detectEpoch(maxTimestamp, now);
|
|
53
|
+
return {
|
|
54
|
+
offset,
|
|
55
|
+
reason,
|
|
56
|
+
confident: !GAVE_UP.test(reason)
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** The expectation docs/maps.md carries, used only where no store is open. */
|
|
60
|
+
const APPLE_SECONDS = {
|
|
61
|
+
offset: CORE_DATA_EPOCH_OFFSET,
|
|
62
|
+
reason: "assumed apple-seconds; no store was opened to measure against",
|
|
63
|
+
confident: false
|
|
64
|
+
};
|
|
65
|
+
/** A stored timestamp to a JS Date, or null when it cannot be placed. */
|
|
66
|
+
const fromStoreTime = (value, epoch) => {
|
|
67
|
+
if (value === null || !Number.isFinite(value) || value === 0) return null;
|
|
68
|
+
if (!epoch.confident) return null;
|
|
69
|
+
const date = /* @__PURE__ */ new Date((value + epoch.offset) * 1e3);
|
|
70
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
71
|
+
};
|
|
72
|
+
/** ISO-8601, or null. What every date field on a result carries. */
|
|
73
|
+
const renderInstant = (value, epoch) => fromStoreTime(value, epoch)?.toISOString() ?? null;
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/client/errors.ts
|
|
76
|
+
/**
|
|
77
|
+
* Maps' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
|
|
78
|
+
* belongs here is the identity those messages are written against, plus the
|
|
79
|
+
* failures no other surface has.
|
|
80
|
+
*/
|
|
81
|
+
const MAPS_SURFACE = {
|
|
82
|
+
appName: "Maps",
|
|
83
|
+
envPrefix: "APPLE_MAPS"
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Maps is one of the few Apple apps whose bundle id matches its display name —
|
|
87
|
+
* unlike `com.apple.iCal`, `com.apple.AddressBook` and `com.apple.MobileSMS`.
|
|
88
|
+
* Stated rather than assumed, because this repo has been caught by the opposite
|
|
89
|
+
* three times.
|
|
90
|
+
*
|
|
91
|
+
* It is recorded here and used by almost nothing. Maps ships **no scripting
|
|
92
|
+
* dictionary** — `/System/Applications/Maps.app` contains no `.sdef`, checked
|
|
93
|
+
* directly rather than inferred from `NSAppleScriptEnabled` — so there is no
|
|
94
|
+
* Apple Events lane to address, and this server never sends one.
|
|
95
|
+
*/
|
|
96
|
+
const MAPS_BUNDLE_ID = "com.apple.Maps";
|
|
97
|
+
/**
|
|
98
|
+
* The store could not be read.
|
|
99
|
+
*
|
|
100
|
+
* This surface has **one lane and no fallback**, which puts it with Messages
|
|
101
|
+
* rather than with Safari: without Full Disk Access there is no degraded Maps
|
|
102
|
+
* server, there is no Maps server. So every read throws this rather than
|
|
103
|
+
* returning an empty list — an empty `favorites` reads exactly like a person
|
|
104
|
+
* who has saved no places, and that is the failure this error exists to
|
|
105
|
+
* prevent.
|
|
106
|
+
*
|
|
107
|
+
* The hint names the trap that actually catches people: the store lives at a
|
|
108
|
+
* path with **no file extension**, inside the one directory in Maps' container
|
|
109
|
+
* that Full Disk Access gates. A sweep for `*.db` finds nothing and concludes
|
|
110
|
+
* the data is not on disk. It is.
|
|
111
|
+
*/
|
|
112
|
+
var MapsStoreUnavailableError = class extends AppleAutomationError {
|
|
113
|
+
name = "MapsStoreUnavailableError";
|
|
114
|
+
constructor(reason) {
|
|
115
|
+
super(`${reason} Maps has no second lane — it ships no scripting dictionary, so there is no Apple Events fallback and nothing at all can be read without the grant.`, {});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/** A ref no longer resolves — the place was removed, or the store was re-synced. */
|
|
119
|
+
var PlaceNotFoundError = class extends AppleAutomationError {
|
|
120
|
+
name = "PlaceNotFoundError";
|
|
121
|
+
constructor(ref) {
|
|
122
|
+
super(`No place for ref "${ref}". It may have been removed in Maps, or iCloud may have re-synced the store and renumbered it. Re-run the listing to get a current ref.`, { ref });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* The epoch could not be identified from the data.
|
|
127
|
+
*
|
|
128
|
+
* The same discipline `packages/safari` applies, for the same measured reason:
|
|
129
|
+
* a wrong epoch produces dates that are well-formed and wrong by 31 years.
|
|
130
|
+
* `docs/maps.md` records that this store's `ZCREATETIME` read as apple-seconds
|
|
131
|
+
* and that a unix-seconds reading of the same value lands in 1995 while still
|
|
132
|
+
* looking entirely plausible. So the offset is detected from the store at open
|
|
133
|
+
* time and dates are WITHHELD when it cannot be.
|
|
134
|
+
*/
|
|
135
|
+
var UndatableStoreError = class extends AppleAutomationError {
|
|
136
|
+
name = "UndatableStoreError";
|
|
137
|
+
constructor(reason) {
|
|
138
|
+
super(`Maps' timestamps could not be placed on a known epoch (${reason}). Dates are withheld rather than guessed — a timestamp wrong by decades reads exactly like a correct one. Everything that does not depend on a date still works.`, {});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/client/locate.ts
|
|
143
|
+
/**
|
|
144
|
+
* Find Maps' store.
|
|
145
|
+
*
|
|
146
|
+
* ## The path, and the three ways it was missed
|
|
147
|
+
*
|
|
148
|
+
* `~/Library/Containers/com.apple.Maps/Data/Maps/MapsSync_0.0.1` — 3.3 MB of
|
|
149
|
+
* Core Data on the probed machine. `docs/maps.md` records that this surface was
|
|
150
|
+
* declared "no file lane" three separate times before the file was found, and
|
|
151
|
+
* each failure is a rule this locator is shaped by:
|
|
152
|
+
*
|
|
153
|
+
* 1. **The file has no extension.** A sweep for `*.db` / `*.sqlite*` finds
|
|
154
|
+
* nothing here. Nothing in this file matches on a suffix.
|
|
155
|
+
* 2. **`Data/Maps/` is gated.** It was the ONE unreadable directory in the
|
|
156
|
+
* container, so a listing taken without Full Disk Access omits it, and the
|
|
157
|
+
* omission reads as absence. `inspectFile` splits exists from readable
|
|
158
|
+
* precisely so those two never collapse into one answer.
|
|
159
|
+
* 3. **`group.com.apple.Maps` is a decoy.** It exists, it is EPERM without the
|
|
160
|
+
* grant, and it holds three files that are not the store. A probe that
|
|
161
|
+
* could not descend into it reported it as empty.
|
|
162
|
+
*
|
|
163
|
+
* The rule underneath all three is the one `docs/surfaces.md` states: "'Absent'
|
|
164
|
+
* and 'EPERM' are different findings." Keeping them apart, and saying which one
|
|
165
|
+
* happened, is most of this module's job.
|
|
166
|
+
*
|
|
167
|
+
* ## The device-local cache is located and never read
|
|
168
|
+
*
|
|
169
|
+
* `MapsSync_0.0.1_deviceLocalCache.db` sits beside the store with the same 33
|
|
170
|
+
* entities and, on the probed machine, **zero rows in every one of them**. It is
|
|
171
|
+
* reported by diagnostics so anyone comparing the directory against this
|
|
172
|
+
* server's output can see it was considered, and it is never opened.
|
|
173
|
+
*/
|
|
174
|
+
/** The container path. `Data/Maps` is the gated directory, not `Data/Library`. */
|
|
175
|
+
const MAPS_DIR = [
|
|
176
|
+
"Library",
|
|
177
|
+
"Containers",
|
|
178
|
+
"com.apple.Maps",
|
|
179
|
+
"Data",
|
|
180
|
+
"Maps"
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* No extension, and a version in the name.
|
|
184
|
+
*
|
|
185
|
+
* The `0.0.1` was stable on every machine this project has measured, but it is a
|
|
186
|
+
* version string and it will move. `locateStore` falls back to scanning for a
|
|
187
|
+
* `MapsSync_*` sibling rather than reporting a rename as "Maps has never run".
|
|
188
|
+
*/
|
|
189
|
+
const STORE_FILENAME = "MapsSync_0.0.1";
|
|
190
|
+
const LOCAL_CACHE_SUFFIX = "_deviceLocalCache.db";
|
|
191
|
+
const FDA_HINT = "Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Maps.app does nothing — the reader needs the permission, not the app.";
|
|
192
|
+
const defaultDirectory = (home = homedir()) => join(home, ...MAPS_DIR);
|
|
193
|
+
const defaultStorePath = (home = homedir()) => join(defaultDirectory(home), STORE_FILENAME);
|
|
194
|
+
const locateFile = (path) => ({
|
|
195
|
+
...inspectFile(path),
|
|
196
|
+
path
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Look for a `MapsSync_*` that is neither the local cache nor a SQLite sidecar.
|
|
200
|
+
*
|
|
201
|
+
* Only reached when the constant path is absent, and it needs the directory to
|
|
202
|
+
* be listable — the same grant the store itself needs, so this asks for no new
|
|
203
|
+
* permission and simply survives a version bump.
|
|
204
|
+
*/
|
|
205
|
+
const scanForStore = (directory, readdir) => {
|
|
206
|
+
let names;
|
|
207
|
+
try {
|
|
208
|
+
names = readdir(directory);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const candidate = names.filter((n) => n.startsWith("MapsSync_") && !n.endsWith("_deviceLocalCache.db") && !n.endsWith("-wal") && !n.endsWith("-shm")).toSorted().at(-1);
|
|
213
|
+
return candidate ? join(directory, candidate) : null;
|
|
214
|
+
};
|
|
215
|
+
const locateStore = (opts = {}) => {
|
|
216
|
+
const directory = defaultDirectory(opts.home);
|
|
217
|
+
const readdir = opts.readdir ?? ((p) => readdirSync(p));
|
|
218
|
+
const explicit = opts.storePath;
|
|
219
|
+
const constant = defaultStorePath(opts.home);
|
|
220
|
+
let storePath = explicit ?? constant;
|
|
221
|
+
let resolvedByScan = false;
|
|
222
|
+
if (!explicit && !describeStore(constant).exists) {
|
|
223
|
+
const scanned = scanForStore(directory, readdir);
|
|
224
|
+
if (scanned) {
|
|
225
|
+
storePath = scanned;
|
|
226
|
+
resolvedByScan = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const facts = describeStore(storePath);
|
|
230
|
+
const reason = facts.readable ? null : facts.exists ? `Found Maps' store at ${storePath} but cannot read it. ${FDA_HINT}` : explicit ? `No file at ${explicit}. APPLE_MAPS_STORE points at nothing.` : `No Maps store at ${storePath}, and no MapsSync_* file in ${directory}. Either Maps has never been used on this account, or the directory itself is not readable — those look identical from outside and only the grant tells them apart. ${FDA_HINT}`;
|
|
231
|
+
return {
|
|
232
|
+
...facts,
|
|
233
|
+
directory,
|
|
234
|
+
storePath: facts.exists || !explicit ? storePath : null,
|
|
235
|
+
localCache: locateFile(`${constant}${LOCAL_CACHE_SUFFIX}`),
|
|
236
|
+
resolvedByScan,
|
|
237
|
+
reason
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/client/ref.ts
|
|
242
|
+
/**
|
|
243
|
+
* Refs for places and collections.
|
|
244
|
+
*
|
|
245
|
+
* ## Why the ref is not `ZMUID`
|
|
246
|
+
*
|
|
247
|
+
* `ZMUID` is Apple's own place identifier and looks like the better key — it is
|
|
248
|
+
* the same number for the same restaurant across every device. It is not used,
|
|
249
|
+
* for two measured reasons recorded in `docs/maps.md`:
|
|
250
|
+
*
|
|
251
|
+
* * It is populated **20 of 23** favourites. The three without it are the
|
|
252
|
+
* rows that have no linked place at all, but a ref scheme that cannot
|
|
253
|
+
* address three rows in twenty-three is not a ref scheme.
|
|
254
|
+
* * It identifies a PLACE, not an entry. The same café saved as a favourite
|
|
255
|
+
* AND filed in a collection carries one `ZMUID` across both, so a ref built
|
|
256
|
+
* on it could not say which of the two a caller meant.
|
|
257
|
+
*
|
|
258
|
+
* The keys below address the ENTRY, which is what every tool here returns. The
|
|
259
|
+
* kind is carried in the prefix so a favourite ref and a collection-item ref
|
|
260
|
+
* can never be confused for one another.
|
|
261
|
+
*
|
|
262
|
+
* ## What the ref carries: a uuid when the store has one, a row id when it does not
|
|
263
|
+
*
|
|
264
|
+
* `ZIDENTIFIER` — a 16-byte Core Data UUID — is set on every favourite,
|
|
265
|
+
* collection, collection item and recent on a real store, and is distinct on
|
|
266
|
+
* every one. It addresses the ENTRY, survives a delete elsewhere in the table,
|
|
267
|
+
* and survives an iCloud re-sync renumbering rows. It is the ref whenever
|
|
268
|
+
* `store.ts` can confirm total coverage.
|
|
269
|
+
*
|
|
270
|
+
* It was found by watching Maps write, not by reading the schema — see
|
|
271
|
+
* `docs/maps.md`. The read probe never reported it, because a probe can only
|
|
272
|
+
* report columns it thought to look for.
|
|
273
|
+
*
|
|
274
|
+
* When a store has no usable identifier the ref falls back to the Core Data row
|
|
275
|
+
* id, and then the old caveat applies in full: `Z_PK` is reused after a delete
|
|
276
|
+
* and renumbered by a re-sync, so such a ref is only good for the session.
|
|
277
|
+
* `packages/messages` rejected row ids outright for that reason; here they are
|
|
278
|
+
* the degraded mode rather than the design.
|
|
279
|
+
*
|
|
280
|
+
* The two are told apart BY SHAPE — a uuid is 32 hex characters, a row id is
|
|
281
|
+
* decimal — and the resolver refuses to try a uuid against a store with no
|
|
282
|
+
* identifier column rather than reinterpreting it as a number. Silently
|
|
283
|
+
* resolving one key space in the other would find a real but wrong place, which
|
|
284
|
+
* is worse than finding nothing.
|
|
285
|
+
*
|
|
286
|
+
* ## Why `p1:` and `pc1:`
|
|
287
|
+
*
|
|
288
|
+
* `c1:` is Calendar's, `r1:` Reminders', `k1:` Contacts', `n1:` Notes',
|
|
289
|
+
* `m1:`/`mc1:` Messages', `s1:`/`sb1:` Safari's. A ref that decodes under two
|
|
290
|
+
* surfaces is worse than one that decodes under none, so each prefix is claimed
|
|
291
|
+
* once and the version digit keeps a future change additive rather than a
|
|
292
|
+
* silent reinterpretation of refs already sitting in a conversation.
|
|
293
|
+
*/
|
|
294
|
+
const PLACE_REF_VERSION = "p1";
|
|
295
|
+
const COLLECTION_REF_VERSION = "pc1";
|
|
296
|
+
const KIND_CODE = {
|
|
297
|
+
favorite: "f",
|
|
298
|
+
"collection-item": "c",
|
|
299
|
+
history: "h"
|
|
300
|
+
};
|
|
301
|
+
const CODE_KIND = {
|
|
302
|
+
f: "favorite",
|
|
303
|
+
c: "collection-item",
|
|
304
|
+
h: "history"
|
|
305
|
+
};
|
|
306
|
+
const PLACE_PATTERN = /^p1:([fch]):([0-9a-f]{32}|\d+)$/;
|
|
307
|
+
const COLLECTION_PATTERN = /^pc1:([0-9a-f]{32}|\d+)$/;
|
|
308
|
+
/** 32 hex characters is a uuid; anything else that matched the pattern is a row id. */
|
|
309
|
+
const toKey = (raw) => /^[0-9a-f]{32}$/.test(raw) ? { uuid: `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}` } : { rowId: Number(raw) };
|
|
310
|
+
/** A uuid goes into the ref undashed, so the ref stays one opaque token. */
|
|
311
|
+
const fromKey = (key) => "uuid" in key ? key.uuid.replaceAll("-", "").toLowerCase() : String(key.rowId);
|
|
312
|
+
const otherSurface = (raw) => {
|
|
313
|
+
if (raw.startsWith("c1:")) return " That one is a Calendar event ref.";
|
|
314
|
+
if (raw.startsWith("r1:")) return " That one is a Reminders ref.";
|
|
315
|
+
if (raw.startsWith("k1:")) return " That one is a Contacts ref.";
|
|
316
|
+
if (raw.startsWith("n1:")) return " That one is a Notes ref.";
|
|
317
|
+
if (raw.startsWith("mc1:")) return " That one is a Messages chat ref.";
|
|
318
|
+
if (raw.startsWith("m1:")) return " That one is a Messages message ref.";
|
|
319
|
+
if (raw.startsWith("sb1:")) return " That one is a Safari bookmark ref.";
|
|
320
|
+
if (raw.startsWith("s1:")) return " That one is a Safari history ref.";
|
|
321
|
+
if (raw.startsWith("pc1:")) return " That one is a COLLECTION ref — this wants a place ref.";
|
|
322
|
+
if (raw.startsWith("p1:")) return " That one is a PLACE ref — this wants a collection ref.";
|
|
323
|
+
return "";
|
|
324
|
+
};
|
|
325
|
+
var InvalidMapsRefError = class extends AppleAutomationError {
|
|
326
|
+
name = "InvalidMapsRefError";
|
|
327
|
+
constructor(raw, want) {
|
|
328
|
+
super(`"${raw}" is not a ${want} ref. Refs come from apple_maps_* results and look like ${want === "place" ? "\"p1:<kind>:<id>\"" : "\"pc1:<id>\""} — they are opaque and must not be constructed by hand.${otherSurface(raw)}`, { ref: raw });
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const encodePlaceRef = (kind, key) => `p1:${KIND_CODE[kind]}:${fromKey(key)}`;
|
|
332
|
+
const encodeCollectionRef = (key) => `pc1:${fromKey(key)}`;
|
|
333
|
+
const decodePlaceRef = (raw) => {
|
|
334
|
+
const m = PLACE_PATTERN.exec(raw.trim());
|
|
335
|
+
const kind = m?.[1] ? CODE_KIND[m[1]] : void 0;
|
|
336
|
+
if (!m?.[2] || !kind) throw new InvalidMapsRefError(raw, "place");
|
|
337
|
+
return {
|
|
338
|
+
kind,
|
|
339
|
+
key: toKey(m[2])
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
const decodeCollectionRef = (raw) => {
|
|
343
|
+
const m = COLLECTION_PATTERN.exec(raw.trim());
|
|
344
|
+
if (!m?.[1]) throw new InvalidMapsRefError(raw, "collection");
|
|
345
|
+
return toKey(m[1]);
|
|
346
|
+
};
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/client/store.ts
|
|
349
|
+
/**
|
|
350
|
+
* Maps' file lane. There is no other lane.
|
|
351
|
+
*
|
|
352
|
+
* ## What is measured, and what is NOT
|
|
353
|
+
*
|
|
354
|
+
* MEASURED by `pnpm probe:maps` on macOS 26.6 — 146 schema objects, fingerprint
|
|
355
|
+
* `2bbc03143125`, Core Data, `ZCREATETIME` on apple-seconds:
|
|
356
|
+
*
|
|
357
|
+
* ZFAVORITEITEM 24 rows ZCOLLECTION 10 rows
|
|
358
|
+
* ZCOLLECTIONITEM 30 rows ZHISTORYITEM 34 rows
|
|
359
|
+
* ZMIXINMAPITEM 71 rows ZREVIEWEDPLACE 24 rows
|
|
360
|
+
*
|
|
361
|
+
* and the id bridge, verified by running the join rather than by reading column
|
|
362
|
+
* names: `ZMIXINMAPITEM`'s inverse relationships partition exactly —
|
|
363
|
+
* 30 collection items + 21 favourites + 20 history rows = 71 = every row.
|
|
364
|
+
*
|
|
365
|
+
* NOT measured: this file has never been run against the real store by its
|
|
366
|
+
* author, who has no Full Disk Access. Everything below is written against a
|
|
367
|
+
* probe REPORT, which is one step further from the data than
|
|
368
|
+
* `packages/safari/src/client/store.ts` was, and that file already treats every
|
|
369
|
+
* column as optional. This one goes further in two ways.
|
|
370
|
+
*
|
|
371
|
+
* ## Columns are resolved BY COVERAGE, not by first name match
|
|
372
|
+
*
|
|
373
|
+
* The probe found `ZHISTORYITEM` carrying both `ZLATITUDE` (1 of 33 rows) and
|
|
374
|
+
* `ZLATITUDE1` (19 of 33). A resolver that took the first candidate it
|
|
375
|
+
* recognised would pick the column that is null 97% of the time and report that
|
|
376
|
+
* Maps holds almost no coordinates. So each logical field names several
|
|
377
|
+
* candidates and the one with the **most non-null values** wins, counted at
|
|
378
|
+
* open time. That is a real query per candidate, run once, on tables of tens of
|
|
379
|
+
* rows.
|
|
380
|
+
*
|
|
381
|
+
* ## Collection membership is a JOIN TABLE, and it is VALIDATED not guessed
|
|
382
|
+
*
|
|
383
|
+
* MEASURED: membership is `Z_6PLACES(Z_6COLLECTIONS, Z_7PLACES)`, a Core Data
|
|
384
|
+
* many-to-many. `Z_PRIMARYKEY` decodes ordinal 6 as Collection and 7 as
|
|
385
|
+
* CollectionItem, so the relationship is `Collection.places`.
|
|
386
|
+
*
|
|
387
|
+
* Four column names were guessed here before it was found — ZCOLLECTION,
|
|
388
|
+
* ZCOLLECTION1, ZPARENTCOLLECTION, ZOWNINGCOLLECTION — and the store has none
|
|
389
|
+
* of them, so every guide listed empty. A many-to-many leaves NO COLUMN on
|
|
390
|
+
* either entity, so no list of column names could have contained the answer.
|
|
391
|
+
* The near-miss is what makes this worth stating: `ZCOLLECTIONITEM.ZMAPITEM`
|
|
392
|
+
* joins `ZCOLLECTION` for 3 of 10 collections, and a resolver picking the
|
|
393
|
+
* best-covered joinable column would have chosen it and been confidently wrong.
|
|
394
|
+
*
|
|
395
|
+
* So membership is resolved BY RUNNING THE JOIN and scoring it against an
|
|
396
|
+
* oracle the store hands over for free: `ZCOLLECTION.ZPLACESCOUNT`, Maps' own
|
|
397
|
+
* count per guide. A candidate is accepted only when it reproduces all ten
|
|
398
|
+
* numbers exactly with no key pointing at a missing collection. That survives
|
|
399
|
+
* Apple renaming the relationship, which a hard-coded `Z_6PLACES` would not.
|
|
400
|
+
*
|
|
401
|
+
* The oracle is not independent evidence, and it is stronger for it. Core Data
|
|
402
|
+
* maintains `ZPLACESCOUNT` with a trigger that reads:
|
|
403
|
+
*
|
|
404
|
+
* UPDATE ZCOLLECTION SET ZPLACESCOUNT =
|
|
405
|
+
* (SELECT IFNULL(COUNT(Z_6COLLECTIONS), 0)
|
|
406
|
+
* FROM Z_6PLACES WHERE Z_6COLLECTIONS = NEW.Z_PK)
|
|
407
|
+
*
|
|
408
|
+
* so Apple's own schema states the relationship this resolver re-derives. The
|
|
409
|
+
* count match is therefore guaranteed for the true mechanism rather than lucky,
|
|
410
|
+
* and coincidental for anything else. The triggers are visible only in the
|
|
411
|
+
* captured fixture — `pnpm probe:maps --write` omits them from the replay
|
|
412
|
+
* because they call Core Data's private SQLite functions, which is recorded in
|
|
413
|
+
* `writeFixture`.
|
|
414
|
+
*
|
|
415
|
+
* Because it is many-to-many, one place can sit in several guides, and the
|
|
416
|
+
* membership query uses `IN (SELECT ...)` rather than a JOIN so a place in two
|
|
417
|
+
* guides is not returned twice from one of them.
|
|
418
|
+
*
|
|
419
|
+
* MEASURED: 30 item rows, 18 of them in a guide. The other 12 belong to no
|
|
420
|
+
* collection at all; all 30 link to a `ZMIXINMAPITEM`, so they are intact
|
|
421
|
+
* places rather than broken rows.
|
|
422
|
+
*
|
|
423
|
+
* `SchemaDriftError` fires for exactly one condition — none of the three
|
|
424
|
+
* place-bearing tables exists — because without them there is no surface.
|
|
425
|
+
* Everything else is a capability downgrade that is reported, never a throw.
|
|
426
|
+
*/
|
|
427
|
+
const FAVORITES_TABLE = "ZFAVORITEITEM";
|
|
428
|
+
const COLLECTIONS_TABLE = "ZCOLLECTION";
|
|
429
|
+
const COLLECTION_ITEMS_TABLE = "ZCOLLECTIONITEM";
|
|
430
|
+
const HISTORY_TABLE = "ZHISTORYITEM";
|
|
431
|
+
const MAP_ITEMS_TABLE = "ZMIXINMAPITEM";
|
|
432
|
+
/**
|
|
433
|
+
* Candidates per logical field, best-known first — but order only breaks ties.
|
|
434
|
+
* Coverage decides. See the header.
|
|
435
|
+
*/
|
|
436
|
+
const NAME_CANDIDATES = [
|
|
437
|
+
"ZMAPITEMNAME",
|
|
438
|
+
"ZCUSTOMNAME",
|
|
439
|
+
"ZLOCATIONDISPLAY",
|
|
440
|
+
"ZTITLE"
|
|
441
|
+
];
|
|
442
|
+
const LAT_CANDIDATES = ["ZLATITUDE", "ZLATITUDE1"];
|
|
443
|
+
const LON_CANDIDATES = ["ZLONGITUDE", "ZLONGITUDE1"];
|
|
444
|
+
const ADDRESS_CANDIDATES = ["ZMAPITEMADDRESS", "ZORIGINATINGADDRESSSTRING"];
|
|
445
|
+
const MUID_CANDIDATES = ["ZMUID"];
|
|
446
|
+
const MAP_ITEM_FK_CANDIDATES = ["ZMAPITEM"];
|
|
447
|
+
const CREATED_CANDIDATES = ["ZCREATETIME"];
|
|
448
|
+
const MODIFIED_CANDIDATES = ["ZMODIFICATIONTIME"];
|
|
449
|
+
const COLLECTION_TITLE_CANDIDATES = [
|
|
450
|
+
"ZTITLE",
|
|
451
|
+
"ZNAME",
|
|
452
|
+
"ZCUSTOMNAME"
|
|
453
|
+
];
|
|
454
|
+
/**
|
|
455
|
+
* The stable per-entry identifier, if this store has one.
|
|
456
|
+
*
|
|
457
|
+
* FOUND BY WATCHING MAPS WRITE, not by reading the schema: every row Maps
|
|
458
|
+
* created during `pnpm probe:maps-write` carried `ZIDENTIFIER`, a 16-byte blob
|
|
459
|
+
* — a UUID. It is not in the read probe's report because that probe only looked
|
|
460
|
+
* for columns it already had candidates for, which is a good argument for
|
|
461
|
+
* diffing a live write even when the read surface already works.
|
|
462
|
+
*
|
|
463
|
+
* `ZORIGINALIDENTIFIER` is deliberately NOT a candidate. It exists on
|
|
464
|
+
* `ZCOLLECTIONITEM` and is populated on 3 rows of 30 — it records where an entry
|
|
465
|
+
* was copied FROM, so it is neither complete nor unique to the entry.
|
|
466
|
+
*/
|
|
467
|
+
const IDENTIFIER_CANDIDATES = ["ZIDENTIFIER"];
|
|
468
|
+
/**
|
|
469
|
+
* Maps' own count of the places in a guide, and the oracle every membership
|
|
470
|
+
* candidate is scored against. See the header.
|
|
471
|
+
*/
|
|
472
|
+
const COLLECTION_COUNT_COLUMN = "ZPLACESCOUNT";
|
|
473
|
+
/** Core Data's own tables, which are never a relationship. */
|
|
474
|
+
const RESERVED_JOIN_TABLES = /* @__PURE__ */ new Set([
|
|
475
|
+
"Z_METADATA",
|
|
476
|
+
"Z_MODELCACHE",
|
|
477
|
+
"Z_PRIMARYKEY"
|
|
478
|
+
]);
|
|
479
|
+
const isRealTable = (name) => !name.startsWith("sqlite_");
|
|
480
|
+
/**
|
|
481
|
+
* `HEX()` of a 16-byte blob to a canonical UUID.
|
|
482
|
+
*
|
|
483
|
+
* Returns null on anything that is not exactly 32 hex characters. Core Data
|
|
484
|
+
* stores a UUID attribute as 16 raw bytes, so a different length means the
|
|
485
|
+
* column is not what this code thinks it is — and a malformed ref that still
|
|
486
|
+
* looks ref-shaped would be resolved against the wrong row rather than rejected.
|
|
487
|
+
*/
|
|
488
|
+
const toUuid = (hex) => {
|
|
489
|
+
if (hex === null || hex === void 0) return null;
|
|
490
|
+
const h = String(hex).toLowerCase();
|
|
491
|
+
if (!/^[0-9a-f]{32}$/.test(h)) return null;
|
|
492
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
493
|
+
};
|
|
494
|
+
/** A `NULL AS alias` column reads back as null; every other value is coerced. */
|
|
495
|
+
const num = (v) => v === null || v === void 0 ? null : Number(v);
|
|
496
|
+
const str = (v) => v === null || v === void 0 ? null : String(v);
|
|
497
|
+
/**
|
|
498
|
+
* Pick the candidate column with the most non-null values.
|
|
499
|
+
*
|
|
500
|
+
* Returns null when none of the candidates exists, and also when they all exist
|
|
501
|
+
* and are all empty — an all-null column is not a usable field, and reporting
|
|
502
|
+
* one as resolved would produce a result whose every value is null with no
|
|
503
|
+
* explanation.
|
|
504
|
+
*/
|
|
505
|
+
const resolveField = (db, table, columns, candidates, rows) => {
|
|
506
|
+
const present = candidates.filter((c) => columns.has(c));
|
|
507
|
+
if (present.length === 0) return null;
|
|
508
|
+
if (rows === 0) return present[0] ?? null;
|
|
509
|
+
let best = null;
|
|
510
|
+
for (const name of present) {
|
|
511
|
+
let count = 0;
|
|
512
|
+
try {
|
|
513
|
+
count = Number(db.prepare(`SELECT COUNT("${name}") AS c FROM "${table}"`).get().c);
|
|
514
|
+
} catch {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!best || count > best.count) best = {
|
|
518
|
+
name,
|
|
519
|
+
count
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return best && best.count > 0 ? best.name : present[0] ?? null;
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* The identifier column, but ONLY if it can actually carry every ref.
|
|
526
|
+
*
|
|
527
|
+
* Coverage is not enough here, and this is the difference between a fix and a
|
|
528
|
+
* trap. A column populated on the rows Maps has written lately and null on the
|
|
529
|
+
* ones that predate it would work perfectly for every place the user saves from
|
|
530
|
+
* now on and fail silently for the places they already had — the failure mode
|
|
531
|
+
* hardest to notice and worst to hit, since the older entries are the ones a
|
|
532
|
+
* person is most likely to ask about.
|
|
533
|
+
*
|
|
534
|
+
* So the bar is TOTAL: set on every row, and distinct across every row. Anything
|
|
535
|
+
* less returns null and the store falls back to `Z_PK`, which is worse but
|
|
536
|
+
* uniformly worse. Measured on a real store: 24/24 favourites, 30/30 collection
|
|
537
|
+
* items, 10/10 collections, 33/33 recents, all distinct.
|
|
538
|
+
*/
|
|
539
|
+
const resolveIdentifier = (db, table, columns, rows) => {
|
|
540
|
+
const present = IDENTIFIER_CANDIDATES.filter((c) => columns.has(c));
|
|
541
|
+
if (present.length === 0 || rows === 0) return null;
|
|
542
|
+
for (const name of present) try {
|
|
543
|
+
const r = db.prepare(`SELECT COUNT("${name}") AS nset, COUNT(DISTINCT "${name}") AS ndistinct FROM "${table}"`).get();
|
|
544
|
+
if (Number(r.nset) === rows && Number(r.ndistinct) === rows) return name;
|
|
545
|
+
} catch {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
};
|
|
550
|
+
const countRows = (db, table) => {
|
|
551
|
+
try {
|
|
552
|
+
return Number(db.prepare(`SELECT COUNT(*) AS c FROM "${table}"`).get().c);
|
|
553
|
+
} catch {
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
const describeEntity = (db, table, tables, nameCandidates = NAME_CANDIDATES) => {
|
|
558
|
+
const present = tables.includes(table);
|
|
559
|
+
if (!present) return {
|
|
560
|
+
table,
|
|
561
|
+
present: false,
|
|
562
|
+
rows: 0,
|
|
563
|
+
columns: /* @__PURE__ */ new Set(),
|
|
564
|
+
fields: {
|
|
565
|
+
identifier: null,
|
|
566
|
+
name: null,
|
|
567
|
+
customName: null,
|
|
568
|
+
latitude: null,
|
|
569
|
+
longitude: null,
|
|
570
|
+
address: null,
|
|
571
|
+
muid: null,
|
|
572
|
+
mapItem: null,
|
|
573
|
+
created: null,
|
|
574
|
+
modified: null
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
const columns = new Set(columnsOf(db, table));
|
|
578
|
+
const rows = countRows(db, table);
|
|
579
|
+
const pick = (cands) => resolveField(db, table, columns, cands, rows);
|
|
580
|
+
return {
|
|
581
|
+
table,
|
|
582
|
+
present,
|
|
583
|
+
rows,
|
|
584
|
+
columns,
|
|
585
|
+
fields: {
|
|
586
|
+
identifier: resolveIdentifier(db, table, columns, rows),
|
|
587
|
+
name: pick(nameCandidates),
|
|
588
|
+
customName: columns.has("ZCUSTOMNAME") ? "ZCUSTOMNAME" : null,
|
|
589
|
+
latitude: pick(LAT_CANDIDATES),
|
|
590
|
+
longitude: pick(LON_CANDIDATES),
|
|
591
|
+
address: pick(ADDRESS_CANDIDATES),
|
|
592
|
+
muid: pick(MUID_CANDIDATES),
|
|
593
|
+
mapItem: pick(MAP_ITEM_FK_CANDIDATES),
|
|
594
|
+
created: pick(CREATED_CANDIDATES),
|
|
595
|
+
modified: pick(MODIFIED_CANDIDATES)
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
/**
|
|
600
|
+
* Maps' own places-per-guide, keyed by collection row id.
|
|
601
|
+
*
|
|
602
|
+
* Null when the store has no `ZPLACESCOUNT`, which changes what the resolver
|
|
603
|
+
* below is allowed to accept — see `resolveMembership`.
|
|
604
|
+
*/
|
|
605
|
+
const declaredCounts = (db, collections) => {
|
|
606
|
+
if (!collections.present || !collections.columns.has(COLLECTION_COUNT_COLUMN)) return null;
|
|
607
|
+
try {
|
|
608
|
+
const rows = db.prepare(`SELECT "Z_PK" AS pk, "${COLLECTION_COUNT_COLUMN}" AS n FROM "${collections.table}"`).all();
|
|
609
|
+
return new Map(rows.map((r) => [Number(r.pk), Number(r.n ?? 0)]));
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
/**
|
|
615
|
+
* Group a candidate's keys and count the items under each.
|
|
616
|
+
*
|
|
617
|
+
* Keys are read as TEXT because `ZMUID` is an INTEGER column holding 64-bit
|
|
618
|
+
* place ids, and `node:sqlite` THROWS on those rather than truncating — reading
|
|
619
|
+
* raw would drop the candidate into a catch instead of rejecting it on the
|
|
620
|
+
* evidence. See `PlaceRow.muid`.
|
|
621
|
+
*/
|
|
622
|
+
const tally = (db, sql) => {
|
|
623
|
+
try {
|
|
624
|
+
const rows = db.prepare(sql).all();
|
|
625
|
+
return new Map(rows.filter((r) => r.pk !== null).map((r) => [Number(r.pk), Number(r.n)]));
|
|
626
|
+
} catch {
|
|
627
|
+
return /* @__PURE__ */ new Map();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* Does this candidate reproduce Maps' own counts, exactly, for every guide?
|
|
632
|
+
*
|
|
633
|
+
* Exactness is the whole test. `ZCOLLECTIONITEM.ZMAPITEM` matches 3 of 10
|
|
634
|
+
* collections by coincidence, so "mostly right" is precisely the answer that
|
|
635
|
+
* must be rejected. A key pointing at no collection at all (`unknown`) is
|
|
636
|
+
* disqualifying for the same reason.
|
|
637
|
+
*/
|
|
638
|
+
const reproducesCounts = (declared, tallies) => {
|
|
639
|
+
for (const pk of tallies.keys()) if (!declared.has(pk)) return false;
|
|
640
|
+
for (const [pk, n] of declared) if ((tallies.get(pk) ?? 0) !== n) return false;
|
|
641
|
+
return true;
|
|
642
|
+
};
|
|
643
|
+
/**
|
|
644
|
+
* Find how an item belongs to a collection, by running each candidate join.
|
|
645
|
+
*
|
|
646
|
+
* Scalar columns are tried before join tables only so the cheaper query runs
|
|
647
|
+
* first; the verdict does not depend on order, because a candidate is accepted
|
|
648
|
+
* only when it reproduces every count. When several would qualify the first is
|
|
649
|
+
* taken, and that ambiguity cannot arise on a store whose counts are distinct.
|
|
650
|
+
*
|
|
651
|
+
* WITHOUT the oracle the bar changes rather than disappearing: a single join
|
|
652
|
+
* table whose every key resolves on both sides is accepted, and anything
|
|
653
|
+
* ambiguous is refused. That is weaker evidence, and it is the reason the
|
|
654
|
+
* result is reported to callers rather than assumed.
|
|
655
|
+
*/
|
|
656
|
+
const resolveMembership = (db, collectionItems, collections, tables) => {
|
|
657
|
+
if (!collectionItems.present || !collections.present) return null;
|
|
658
|
+
const declared = declaredCounts(db, collections);
|
|
659
|
+
if (declared) for (const column of collectionItems.columns) {
|
|
660
|
+
if ([
|
|
661
|
+
"Z_PK",
|
|
662
|
+
"Z_ENT",
|
|
663
|
+
"Z_OPT"
|
|
664
|
+
].includes(column)) continue;
|
|
665
|
+
const tallies = tally(db, `SELECT CAST(t."${column}" AS TEXT) AS pk, COUNT(*) AS n FROM "${collectionItems.table}" t
|
|
666
|
+
WHERE t."${column}" IS NOT NULL GROUP BY t."${column}"`);
|
|
667
|
+
if (tallies.size > 0 && reproducesCounts(declared, tallies)) return {
|
|
668
|
+
kind: "column",
|
|
669
|
+
column
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const joinTables = tables.filter((t) => t.startsWith("Z_") && !RESERVED_JOIN_TABLES.has(t));
|
|
673
|
+
const accepted = [];
|
|
674
|
+
for (const table of joinTables) {
|
|
675
|
+
const columns = columnsOf(db, table);
|
|
676
|
+
for (const collectionColumn of columns) for (const itemColumn of columns) {
|
|
677
|
+
if (collectionColumn === itemColumn) continue;
|
|
678
|
+
const tallies = tally(db, `SELECT CAST(j."${collectionColumn}" AS TEXT) AS pk, COUNT(*) AS n FROM "${table}" j
|
|
679
|
+
JOIN "${collectionItems.table}" t ON t."Z_PK" = j."${itemColumn}"
|
|
680
|
+
GROUP BY j."${collectionColumn}"`);
|
|
681
|
+
if (tallies.size === 0) continue;
|
|
682
|
+
if (declared) {
|
|
683
|
+
if (reproducesCounts(declared, tallies)) return {
|
|
684
|
+
kind: "joinTable",
|
|
685
|
+
table,
|
|
686
|
+
collectionColumn,
|
|
687
|
+
itemColumn
|
|
688
|
+
};
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (tally(db, `SELECT CAST(j."${collectionColumn}" AS TEXT) AS pk, COUNT(*) AS n FROM "${table}" j
|
|
692
|
+
LEFT JOIN "${collections.table}" c ON c."Z_PK" = j."${collectionColumn}"
|
|
693
|
+
WHERE c."Z_PK" IS NULL GROUP BY j."${collectionColumn}"`).size === 0) accepted.push({
|
|
694
|
+
kind: "joinTable",
|
|
695
|
+
table,
|
|
696
|
+
collectionColumn,
|
|
697
|
+
itemColumn
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return accepted.length === 1 ? accepted[0] ?? null : null;
|
|
702
|
+
};
|
|
703
|
+
/** The mechanism as one short string, for diagnostics. */
|
|
704
|
+
const describeMembership = (m) => {
|
|
705
|
+
if (!m) return null;
|
|
706
|
+
return m.kind === "column" ? m.column : `${m.table}(${m.collectionColumn}, ${m.itemColumn})`;
|
|
707
|
+
};
|
|
708
|
+
const introspect = (db, now = Date.now()) => {
|
|
709
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all().map((r) => r.name).filter(isRealTable);
|
|
710
|
+
const favorites = describeEntity(db, FAVORITES_TABLE, tables);
|
|
711
|
+
const collectionItems = describeEntity(db, COLLECTION_ITEMS_TABLE, tables);
|
|
712
|
+
const history = describeEntity(db, HISTORY_TABLE, tables);
|
|
713
|
+
if (!favorites.present && !collectionItems.present && !history.present) throw new SchemaDriftError(`Maps' store has none of "${FAVORITES_TABLE}", "${COLLECTION_ITEMS_TABLE}" or "${HISTORY_TABLE}" (found: ${tables.join(", ") || "nothing"}). This is either not a MapsSync store or Apple has restructured it. Nothing can be read from it.`);
|
|
714
|
+
const collections = describeEntity(db, COLLECTIONS_TABLE, tables, COLLECTION_TITLE_CANDIDATES);
|
|
715
|
+
const mapItems = describeEntity(db, MAP_ITEMS_TABLE, tables);
|
|
716
|
+
const membership = resolveMembership(db, collectionItems, collections, tables);
|
|
717
|
+
const dated = [
|
|
718
|
+
favorites,
|
|
719
|
+
collectionItems,
|
|
720
|
+
history,
|
|
721
|
+
mapItems
|
|
722
|
+
].filter((e) => e.present && e.rows > 0 && e.fields.created).toSorted((a, b) => b.rows - a.rows)[0];
|
|
723
|
+
let maxCreated = null;
|
|
724
|
+
if (dated?.fields.created) try {
|
|
725
|
+
maxCreated = db.prepare(`SELECT MAX(CAST("${dated.fields.created}" AS REAL)) AS m FROM "${dated.table}"`).get().m;
|
|
726
|
+
} catch {
|
|
727
|
+
maxCreated = null;
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
fingerprint: fingerprintSchema(db),
|
|
731
|
+
tables,
|
|
732
|
+
favorites,
|
|
733
|
+
collections,
|
|
734
|
+
collectionItems,
|
|
735
|
+
history,
|
|
736
|
+
mapItems,
|
|
737
|
+
membership,
|
|
738
|
+
collectionFk: describeMembership(membership),
|
|
739
|
+
epoch: resolveEpoch(maxCreated, now)
|
|
740
|
+
};
|
|
741
|
+
};
|
|
742
|
+
var MapsStore = class {
|
|
743
|
+
db;
|
|
744
|
+
caps;
|
|
745
|
+
path;
|
|
746
|
+
mode;
|
|
747
|
+
constructor(opts) {
|
|
748
|
+
this.db = opts.db;
|
|
749
|
+
this.caps = opts.caps;
|
|
750
|
+
this.path = opts.path;
|
|
751
|
+
this.mode = opts.mode;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* One column, or `NULL` under the same alias when it could not be resolved.
|
|
755
|
+
*
|
|
756
|
+
* The same guard `packages/safari` uses, and needed more here: this schema was
|
|
757
|
+
* read from a probe report rather than from the database, so a wrong
|
|
758
|
+
* expectation should cost one field instead of the whole lane.
|
|
759
|
+
*/
|
|
760
|
+
#col(column, alias, table = "t") {
|
|
761
|
+
return column ? `${table}."${column}" AS ${alias}` : `NULL AS ${alias}`;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* The same, for a column whose value may not fit in a JS double.
|
|
765
|
+
*
|
|
766
|
+
* SQLite holds 64-bit integers; a JS number holds 53 bits of them, and
|
|
767
|
+
* `node:sqlite` refuses rather than silently losing precision. Casting in SQL
|
|
768
|
+
* keeps the exact value and moves the decision about what to do with it out
|
|
769
|
+
* of the driver.
|
|
770
|
+
*/
|
|
771
|
+
#colText(column, alias, table = "t") {
|
|
772
|
+
return column ? `CAST(${table}."${column}" AS TEXT) AS ${alias}` : `NULL AS ${alias}`;
|
|
773
|
+
}
|
|
774
|
+
#placeSelect(e) {
|
|
775
|
+
const f = e.fields;
|
|
776
|
+
return [
|
|
777
|
+
`t."Z_PK" AS id`,
|
|
778
|
+
f.identifier ? `HEX(t."${f.identifier}") AS uuid` : `NULL AS uuid`,
|
|
779
|
+
this.#col(f.name, "name"),
|
|
780
|
+
this.#col(f.customName, "customName"),
|
|
781
|
+
this.#col(f.latitude, "latitude"),
|
|
782
|
+
this.#col(f.longitude, "longitude"),
|
|
783
|
+
this.#col(f.address, "address"),
|
|
784
|
+
this.#colText(f.muid, "muid"),
|
|
785
|
+
this.#col(f.created, "createdRaw"),
|
|
786
|
+
this.#col(f.modified, "modifiedRaw"),
|
|
787
|
+
f.mapItem ? `t."${f.mapItem}" AS mapItem` : `NULL AS mapItem`
|
|
788
|
+
].join(", ");
|
|
789
|
+
}
|
|
790
|
+
#toPlace(r) {
|
|
791
|
+
return {
|
|
792
|
+
id: Number(r.id),
|
|
793
|
+
uuid: toUuid(r.uuid),
|
|
794
|
+
name: str(r.name),
|
|
795
|
+
customName: str(r.customName),
|
|
796
|
+
latitude: num(r.latitude),
|
|
797
|
+
longitude: num(r.longitude),
|
|
798
|
+
address: str(r.address),
|
|
799
|
+
muid: r.muid === null || r.muid === void 0 || String(r.muid) === "0" ? null : String(r.muid),
|
|
800
|
+
createdRaw: num(r.createdRaw),
|
|
801
|
+
modifiedRaw: num(r.modifiedRaw),
|
|
802
|
+
linked: r.mapItem !== null && r.mapItem !== void 0
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
#entityFor(kind) {
|
|
806
|
+
if (kind === "favorite") return this.caps.favorites;
|
|
807
|
+
if (kind === "collection-item") return this.caps.collectionItems;
|
|
808
|
+
return this.caps.history;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Items belonging to no collection at all, or null when unanswerable.
|
|
812
|
+
*
|
|
813
|
+
* MEASURED: 30 collection items, 18 filed in a guide, 12 in none — and Core
|
|
814
|
+
* Data has deleted nothing (`Z_PRIMARYKEY.Z_MAX` equals the live count for
|
|
815
|
+
* both `Collection` and `CollectionItem`), so these are not the debris of
|
|
816
|
+
* removed guides. 7 of the 12 appear nowhere else in the store: not as a
|
|
817
|
+
* favourite, not in another guide, not in recents. They are places the user
|
|
818
|
+
* saved that no other tool can reach.
|
|
819
|
+
*
|
|
820
|
+
* `NOT IN` needs the null guard. A subquery yielding a single NULL makes
|
|
821
|
+
* `NOT IN` false for EVERY row, so the filter would silently return nothing
|
|
822
|
+
* and read as "you have no unfiled places" — the failure this whole surface
|
|
823
|
+
* keeps having to design against.
|
|
824
|
+
*/
|
|
825
|
+
#unfiledClause() {
|
|
826
|
+
const m = this.caps.membership;
|
|
827
|
+
if (!m) return null;
|
|
828
|
+
if (m.kind === "column") return `t."${m.column}" IS NULL`;
|
|
829
|
+
return `t."Z_PK" NOT IN (SELECT j."${m.itemColumn}" FROM "${m.table}" j WHERE j."${m.itemColumn}" IS NOT NULL)`;
|
|
830
|
+
}
|
|
831
|
+
/** How many collection items are filed in no collection; null when unknown. */
|
|
832
|
+
unfiledCount() {
|
|
833
|
+
const e = this.caps.collectionItems;
|
|
834
|
+
const clause = this.#unfiledClause();
|
|
835
|
+
if (!e.present || !clause) return null;
|
|
836
|
+
try {
|
|
837
|
+
return Number(this.db.prepare(`SELECT COUNT(*) AS c FROM "${e.table}" t WHERE ${clause}`).get().c);
|
|
838
|
+
} catch {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** One entity's places, newest first when a date is available. */
|
|
843
|
+
places(kind, opts) {
|
|
844
|
+
const e = this.#entityFor(kind);
|
|
845
|
+
if (!e.present || e.rows === 0) return {
|
|
846
|
+
rows: [],
|
|
847
|
+
truncated: false
|
|
848
|
+
};
|
|
849
|
+
const where = [];
|
|
850
|
+
const params = [];
|
|
851
|
+
if (opts.collectionId !== void 0) {
|
|
852
|
+
const m = this.caps.membership;
|
|
853
|
+
if (!m) return {
|
|
854
|
+
rows: [],
|
|
855
|
+
truncated: false
|
|
856
|
+
};
|
|
857
|
+
if (m.kind === "column") where.push(`t."${m.column}" = ?`);
|
|
858
|
+
else where.push(`t."Z_PK" IN (SELECT j."${m.itemColumn}" FROM "${m.table}" j WHERE j."${m.collectionColumn}" = ?)`);
|
|
859
|
+
params.push(opts.collectionId);
|
|
860
|
+
}
|
|
861
|
+
if (opts.unfiled) {
|
|
862
|
+
const clause = this.#unfiledClause();
|
|
863
|
+
if (!clause) return {
|
|
864
|
+
rows: [],
|
|
865
|
+
truncated: false
|
|
866
|
+
};
|
|
867
|
+
where.push(clause);
|
|
868
|
+
}
|
|
869
|
+
if (opts.query) {
|
|
870
|
+
const pattern = `%${escapeLike(opts.query)}%`;
|
|
871
|
+
const clauses = [];
|
|
872
|
+
for (const col of [
|
|
873
|
+
e.fields.name,
|
|
874
|
+
e.fields.customName,
|
|
875
|
+
e.fields.address
|
|
876
|
+
]) {
|
|
877
|
+
if (!col) continue;
|
|
878
|
+
clauses.push(`t."${col}" LIKE ? ESCAPE '\\'`);
|
|
879
|
+
params.push(pattern);
|
|
880
|
+
}
|
|
881
|
+
if (clauses.length === 0) return {
|
|
882
|
+
rows: [],
|
|
883
|
+
truncated: false
|
|
884
|
+
};
|
|
885
|
+
where.push(`(${clauses.join(" OR ")})`);
|
|
886
|
+
}
|
|
887
|
+
const limit = Math.max(1, opts.limit);
|
|
888
|
+
params.push(limit + 1);
|
|
889
|
+
const order = e.fields.modified ? `t."${e.fields.modified}" DESC` : e.fields.created ? `t."${e.fields.created}" DESC` : `t."Z_PK" DESC`;
|
|
890
|
+
const sql = `SELECT ${this.#placeSelect(e)} FROM "${e.table}" t ` + (where.length ? `WHERE ${where.join(" AND ")} ` : "") + `ORDER BY ${order} LIMIT ?`;
|
|
891
|
+
const raw = this.db.prepare(sql).all(...params);
|
|
892
|
+
return {
|
|
893
|
+
rows: raw.slice(0, limit).map((r) => this.#toPlace(r)),
|
|
894
|
+
truncated: raw.length > limit
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The WHERE clause and parameter for one key, or null when the key cannot be
|
|
899
|
+
* honoured against this entity.
|
|
900
|
+
*
|
|
901
|
+
* A uuid key against a store with no resolved identifier column returns null
|
|
902
|
+
* rather than falling back to the row id. The two number spaces are unrelated,
|
|
903
|
+
* so a fallback would resolve to a real but WRONG place — the one outcome
|
|
904
|
+
* worse than not finding it.
|
|
905
|
+
*
|
|
906
|
+
* `HEX()` on both sides rather than `X'..'` literal: it keeps the comparison
|
|
907
|
+
* in one form, and these tables are tens of rows, so the lost index is free.
|
|
908
|
+
*/
|
|
909
|
+
#keyClause(e, key) {
|
|
910
|
+
if ("uuid" in key) {
|
|
911
|
+
if (!e.fields.identifier) return null;
|
|
912
|
+
return {
|
|
913
|
+
sql: `HEX(t."${e.fields.identifier}") = ?`,
|
|
914
|
+
param: key.uuid.replaceAll("-", "").toUpperCase()
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
sql: `t."Z_PK" = ?`,
|
|
919
|
+
param: key.rowId
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/** One place by entity and key. */
|
|
923
|
+
place(kind, key) {
|
|
924
|
+
const e = this.#entityFor(kind);
|
|
925
|
+
if (!e.present) return null;
|
|
926
|
+
const clause = this.#keyClause(e, key);
|
|
927
|
+
if (!clause) return null;
|
|
928
|
+
const sql = `SELECT ${this.#placeSelect(e)} FROM "${e.table}" t WHERE ${clause.sql} LIMIT 1`;
|
|
929
|
+
const r = this.db.prepare(sql).get(clause.param);
|
|
930
|
+
return r ? this.#toPlace(r) : null;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* A collection key to the row id its items point at.
|
|
934
|
+
*
|
|
935
|
+
* Collection membership is a Core Data foreign key, so it holds `Z_PK` values
|
|
936
|
+
* whatever the ref carries. A uuid ref has to be translated before it can
|
|
937
|
+
* filter items, and this is the one place that happens.
|
|
938
|
+
*/
|
|
939
|
+
collectionRowId(key) {
|
|
940
|
+
if (!("uuid" in key)) return key.rowId;
|
|
941
|
+
const e = this.caps.collections;
|
|
942
|
+
const clause = this.#keyClause(e, key);
|
|
943
|
+
if (!e.present || !clause) return null;
|
|
944
|
+
const r = this.db.prepare(`SELECT t."Z_PK" AS id FROM "${e.table}" t WHERE ${clause.sql} LIMIT 1`).get(clause.param);
|
|
945
|
+
return r ? Number(r.id) : null;
|
|
946
|
+
}
|
|
947
|
+
collections(opts) {
|
|
948
|
+
const e = this.caps.collections;
|
|
949
|
+
if (!e.present || e.rows === 0) return {
|
|
950
|
+
rows: [],
|
|
951
|
+
truncated: false
|
|
952
|
+
};
|
|
953
|
+
const limit = Math.max(1, opts.limit);
|
|
954
|
+
const hasCount = e.columns.has("ZPLACESCOUNT");
|
|
955
|
+
const order = e.fields.modified ? `t."${e.fields.modified}" DESC` : `t."Z_PK" DESC`;
|
|
956
|
+
const sql = `SELECT t."Z_PK" AS id, ${e.fields.identifier ? `HEX(t."${e.fields.identifier}") AS uuid` : `NULL AS uuid`}, ${this.#col(e.fields.name, "title")}, ${hasCount ? `t."ZPLACESCOUNT" AS placesCount` : `NULL AS placesCount`}, ${this.#col(e.fields.created, "createdRaw")}, ${this.#col(e.fields.modified, "modifiedRaw")} FROM "${e.table}" t ORDER BY ${order} LIMIT ?`;
|
|
957
|
+
const raw = this.db.prepare(sql).all(limit + 1);
|
|
958
|
+
return {
|
|
959
|
+
rows: raw.slice(0, limit).map((r) => ({
|
|
960
|
+
id: Number(r.id),
|
|
961
|
+
uuid: toUuid(r.uuid),
|
|
962
|
+
title: r.title === null || r.title === void 0 ? null : String(r.title),
|
|
963
|
+
placesCount: r.placesCount === null ? null : Number(r.placesCount),
|
|
964
|
+
createdRaw: r.createdRaw === null ? null : Number(r.createdRaw),
|
|
965
|
+
modifiedRaw: r.modifiedRaw === null ? null : Number(r.modifiedRaw)
|
|
966
|
+
})),
|
|
967
|
+
truncated: raw.length > limit
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
close() {
|
|
971
|
+
try {
|
|
972
|
+
this.db.close();
|
|
973
|
+
} catch {}
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const openStore = (opts) => {
|
|
977
|
+
const opened = openReadOnly(opts.path, opts.mode ?? "auto", {
|
|
978
|
+
envVar: "APPLE_MAPS_INDEX_MODE",
|
|
979
|
+
label: "Maps' place store",
|
|
980
|
+
...opts.hint ? { hint: opts.hint } : {},
|
|
981
|
+
validate: (db) => introspect(db, opts.now),
|
|
982
|
+
fatal: (err) => err instanceof SchemaDriftError,
|
|
983
|
+
onFallback: () => opts.logger?.warn?.("maps: opened immutable; places saved since the last checkpoint may be missing")
|
|
984
|
+
});
|
|
985
|
+
return new MapsStore({
|
|
986
|
+
db: opened.db,
|
|
987
|
+
caps: opened.validated,
|
|
988
|
+
path: opts.path,
|
|
989
|
+
mode: opened.mode
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/client/write.ts
|
|
994
|
+
/**
|
|
995
|
+
* Writing to Maps' store.
|
|
996
|
+
*
|
|
997
|
+
* ## Why this is SQL and not the app's own API
|
|
998
|
+
*
|
|
999
|
+
* Maps ships no scripting dictionary and registers no App Intents on macOS, so
|
|
1000
|
+
* there is no lane where the app performs the write on our behalf. Every other
|
|
1001
|
+
* surface in this repo writes through an Apple Event; this one cannot, and the
|
|
1002
|
+
* alternatives were measured rather than assumed — see `docs/maps.md`.
|
|
1003
|
+
*
|
|
1004
|
+
* ## The one thing that cannot be synthesised, and how it is obtained anyway
|
|
1005
|
+
*
|
|
1006
|
+
* A place is only real to Maps if it has a `ZMAPITEMSTORAGE` — a GEO protobuf of
|
|
1007
|
+
* one to four kilobytes that this repo has never decoded and cannot generate.
|
|
1008
|
+
*
|
|
1009
|
+
* It does not have to. **Opening a place makes Maps write one.**
|
|
1010
|
+
* `maps://?q=<name>&ll=<lat>,<lon>` goes through LaunchServices — not Apple
|
|
1011
|
+
* Events, not Accessibility — and Maps resolves the place and records it in
|
|
1012
|
+
* Recents with a full record attached. That record is then copied into the new
|
|
1013
|
+
* favourite. The blob is always minted by Maps, which is the only thing that can
|
|
1014
|
+
* mint one correctly.
|
|
1015
|
+
*
|
|
1016
|
+
* MEASURED consequences of that design, all of them in `docs/maps.md`:
|
|
1017
|
+
*
|
|
1018
|
+
* * The insert needs THREE tables — the favourite, its map item, and the two
|
|
1019
|
+
* `Z_PRIMARYKEY` counters. Not the eight the app touches.
|
|
1020
|
+
* * No persistent history and no `NSCK*` metadata is written. Core Data
|
|
1021
|
+
* reconciles unregistered objects on the app's next save and mirrors them to
|
|
1022
|
+
* iCloud by itself; a favourite written this way reached another device.
|
|
1023
|
+
* * It works with Maps RUNNING, and survives a subsequent save by the app.
|
|
1024
|
+
* There is no need to quit anything.
|
|
1025
|
+
*
|
|
1026
|
+
* ## The cost the caller must be told about
|
|
1027
|
+
*
|
|
1028
|
+
* Seeding leaves the place in the user's **Recents**, whether or not the
|
|
1029
|
+
* favourite is kept. That is unavoidable — it is the mechanism — so every tool
|
|
1030
|
+
* built on this says so in its description rather than springing it on somebody.
|
|
1031
|
+
*
|
|
1032
|
+
* ## Why writes are dangerous here in a way the other surfaces are not
|
|
1033
|
+
*
|
|
1034
|
+
* The store is mirrored by `NSPersistentCloudKitContainer`, and mirroring does
|
|
1035
|
+
* not wait to be told. A malformed row is not a local mistake: it reaches every
|
|
1036
|
+
* device on the account as soon as Maps next runs. There is no such thing as a
|
|
1037
|
+
* local-only insert here — that was believed for a while and measured false.
|
|
1038
|
+
* Hence: never fabricate a place record, only ever copy one Maps wrote.
|
|
1039
|
+
*/
|
|
1040
|
+
const STORAGE_POLL_MS = 250;
|
|
1041
|
+
/**
|
|
1042
|
+
* Progress, on stderr.
|
|
1043
|
+
*
|
|
1044
|
+
* Adding a favourite can take tens of seconds — Maps has to resolve a place over
|
|
1045
|
+
* the network before there is anything to copy — and the first version said
|
|
1046
|
+
* NOTHING for the whole of it. That is indistinguishable from a hang, and was
|
|
1047
|
+
* reported as one three times while three different theories were tried. A long
|
|
1048
|
+
* operation that reports nothing cannot be diagnosed, only guessed at.
|
|
1049
|
+
*
|
|
1050
|
+
* stderr because this is an MCP stdio server: stdout carries the protocol, and
|
|
1051
|
+
* the server already writes its banner here.
|
|
1052
|
+
*/
|
|
1053
|
+
const progress = (message) => {
|
|
1054
|
+
process.stderr.write(`[apple-maps-mcp] ${message}\n`);
|
|
1055
|
+
};
|
|
1056
|
+
var MapsWriteError = class extends AppleAutomationError {
|
|
1057
|
+
name = "MapsWriteError";
|
|
1058
|
+
};
|
|
1059
|
+
const defaultOpenUrl = (url) => {
|
|
1060
|
+
execFileSync("/usr/bin/open", ["-g", url], {
|
|
1061
|
+
timeout: 1e4,
|
|
1062
|
+
stdio: "ignore"
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
const uuidBytes = () => Uint8Array.from(Buffer.from(randomUUID().replaceAll("-", ""), "hex"));
|
|
1066
|
+
const uuidOf = (bytes) => {
|
|
1067
|
+
const h = Buffer.from(bytes).toString("hex");
|
|
1068
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
1069
|
+
};
|
|
1070
|
+
/**
|
|
1071
|
+
* Coordinates are floats and never compare exactly across sources, so identity
|
|
1072
|
+
* is "within about a metre" rather than equality. 1e-5 degrees is ~1.1 m of
|
|
1073
|
+
* latitude — close enough that two rows are the same doorway, far enough apart
|
|
1074
|
+
* that neighbouring shops are not merged.
|
|
1075
|
+
*/
|
|
1076
|
+
const NEAR = 1e-5;
|
|
1077
|
+
/** Same doorway, within about a metre. */
|
|
1078
|
+
const near = (aLat, aLon, bLat, bLon) => Math.abs(aLat - bLat) <= SEED_TOLERANCE && Math.abs(aLon - bLon) <= SEED_TOLERANCE;
|
|
1079
|
+
/**
|
|
1080
|
+
* How far a seeded place may sit from the coordinate the caller gave.
|
|
1081
|
+
*
|
|
1082
|
+
* Far looser than `NEAR`: the caller's coordinate and Apple's for the same place
|
|
1083
|
+
* routinely differ by a building's width, and a search resolves to the entrance
|
|
1084
|
+
* rather than the pin. ~250 m accepts that and still rejects a different town.
|
|
1085
|
+
*/
|
|
1086
|
+
const SEED_TOLERANCE = .0025;
|
|
1087
|
+
const toRad = (deg) => deg * Math.PI / 180;
|
|
1088
|
+
/** Metres between two coordinates, for the error message only. */
|
|
1089
|
+
const haversineMetres = (aLat, aLon, bLat, bLon) => {
|
|
1090
|
+
const dLat = toRad(bLat - aLat);
|
|
1091
|
+
const dLon = toRad(bLon - aLon);
|
|
1092
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
1093
|
+
return 12742e3 * Math.asin(Math.sqrt(h));
|
|
1094
|
+
};
|
|
1095
|
+
var MapsWriter = class {
|
|
1096
|
+
#path;
|
|
1097
|
+
#openUrl;
|
|
1098
|
+
#seedTimeoutMs;
|
|
1099
|
+
constructor(opts) {
|
|
1100
|
+
this.#path = opts.storePath;
|
|
1101
|
+
this.#openUrl = opts.openUrl ?? defaultOpenUrl;
|
|
1102
|
+
this.#seedTimeoutMs = opts.seedTimeoutMs ?? 3e4;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* A read-write handle.
|
|
1106
|
+
*
|
|
1107
|
+
* `busy_timeout` is not optional: Maps and `mapssyncd` hold this store open,
|
|
1108
|
+
* and without it a write during ordinary use is refused outright rather than
|
|
1109
|
+
* waiting for the lock the way any other SQLite client would.
|
|
1110
|
+
*/
|
|
1111
|
+
#open() {
|
|
1112
|
+
const db = new DatabaseSync(this.#path);
|
|
1113
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
1114
|
+
return db;
|
|
1115
|
+
}
|
|
1116
|
+
#entity(db, name) {
|
|
1117
|
+
const row = db.prepare(`SELECT Z_ENT AS e FROM Z_PRIMARYKEY WHERE Z_NAME = ?`).get(name);
|
|
1118
|
+
if (!row) throw new MapsWriteError(`This store has no "${name}" entity, so it cannot be written.`);
|
|
1119
|
+
return Number(row.e);
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* The next primary key: one past whichever is HIGHER, the counter or the
|
|
1123
|
+
* highest row actually present.
|
|
1124
|
+
*
|
|
1125
|
+
* `Z_PRIMARYKEY.Z_MAX` is the counter Core Data maintains, and trusting it
|
|
1126
|
+
* alone is what a first version did. It is not always current — Maps can be
|
|
1127
|
+
* mid-write, a crash can leave it behind, and a foreign writer that forgot to
|
|
1128
|
+
* bump it (a mistake this very file guards against making) leaves it stale
|
|
1129
|
+
* forever. Taking `MAX(Z_PK)` as well costs one indexed lookup and makes a
|
|
1130
|
+
* collision impossible rather than unlikely.
|
|
1131
|
+
*
|
|
1132
|
+
* Found by a test whose stand-in for Maps inserted a row without bumping the
|
|
1133
|
+
* counter. The stand-in was unfaithful; the bug it exposed was not.
|
|
1134
|
+
*/
|
|
1135
|
+
#nextPk(db, name, table) {
|
|
1136
|
+
const counter = db.prepare(`SELECT Z_MAX AS m FROM Z_PRIMARYKEY WHERE Z_NAME = ?`).get(name);
|
|
1137
|
+
const highest = db.prepare(`SELECT MAX(Z_PK) AS m FROM "${table}"`).get();
|
|
1138
|
+
return Math.max(Number(counter?.m ?? 0), Number(highest?.m ?? 0)) + 1;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* An existing favourite for this place.
|
|
1142
|
+
*
|
|
1143
|
+
* Two ways to match, because a coordinate alone is not enough in either
|
|
1144
|
+
* direction. TIGHT coordinate (~1 m) catches the canonical case, where both
|
|
1145
|
+
* numbers came from Maps. NAME plus a LOOSE coordinate (~250 m) catches the
|
|
1146
|
+
* case that matters to a caller: it passed its own coordinate, which differs
|
|
1147
|
+
* from Apple's by a building's width — measured at 6.9e-5 for Sagrada Família.
|
|
1148
|
+
*
|
|
1149
|
+
* Without the second, every repeated call re-seeded before discovering the
|
|
1150
|
+
* duplicate, leaving a Recents entry each time for a favourite it then did not
|
|
1151
|
+
* create. Loosening the coordinate ALONE was the wrong fix: two different shops
|
|
1152
|
+
* 200 m apart would merge. Requiring the name as well makes the loose radius
|
|
1153
|
+
* safe, because it only ever merges rows the caller already calls the same
|
|
1154
|
+
* thing.
|
|
1155
|
+
*/
|
|
1156
|
+
#existingFavorite(db, lat, lon, name) {
|
|
1157
|
+
const tight = db.prepare(`SELECT Z_PK AS pk, HEX(ZIDENTIFIER) AS hex, ZMAPITEMNAME AS name,
|
|
1158
|
+
ZLATITUDE AS lat, ZLONGITUDE AS lon
|
|
1159
|
+
FROM ZFAVORITEITEM
|
|
1160
|
+
WHERE ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
|
|
1161
|
+
LIMIT 1`).get(lat - NEAR, lat + NEAR, lon - NEAR, lon + NEAR);
|
|
1162
|
+
if (tight || !name) return tight;
|
|
1163
|
+
return db.prepare(`SELECT Z_PK AS pk, HEX(ZIDENTIFIER) AS hex, ZMAPITEMNAME AS name,
|
|
1164
|
+
ZLATITUDE AS lat, ZLONGITUDE AS lon
|
|
1165
|
+
FROM ZFAVORITEITEM
|
|
1166
|
+
WHERE ZMAPITEMNAME = ?
|
|
1167
|
+
AND ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
|
|
1168
|
+
LIMIT 1`).get(name, lat - SEED_TOLERANCE, lat + SEED_TOLERANCE, lon - SEED_TOLERANCE, lon + SEED_TOLERANCE);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* A place record already in the store for this coordinate.
|
|
1172
|
+
*
|
|
1173
|
+
* Checked before seeding, so asking for somewhere the user has already looked
|
|
1174
|
+
* at costs no Recents entry and no network round trip. Recents, collection
|
|
1175
|
+
* items and other favourites are all valid sources — the record is the same
|
|
1176
|
+
* object whichever row points at it.
|
|
1177
|
+
*/
|
|
1178
|
+
#donorNear(db, lat, lon) {
|
|
1179
|
+
return db.prepare(`SELECT m."Z_PK" AS mapPk, m."ZLATITUDE" AS lat, m."ZLONGITUDE" AS lon,
|
|
1180
|
+
m."ZMAPITEMSTORAGE" AS storage,
|
|
1181
|
+
COALESCE(f."ZMAPITEMNAME", ci."ZMAPITEMNAME") AS name,
|
|
1182
|
+
COALESCE(f."ZMAPITEMADDRESS", ci."ZMAPITEMADDRESS") AS address,
|
|
1183
|
+
COALESCE(f."ZMAPITEMCATEGORY", ci."ZMAPITEMCATEGORY") AS category,
|
|
1184
|
+
CAST(COALESCE(f."ZMUID", ci."ZMUID") AS TEXT) AS muid
|
|
1185
|
+
FROM "ZMIXINMAPITEM" m
|
|
1186
|
+
LEFT JOIN "ZFAVORITEITEM" f ON f."ZMAPITEM" = m."Z_PK"
|
|
1187
|
+
LEFT JOIN "ZCOLLECTIONITEM" ci ON ci."ZMAPITEM" = m."Z_PK"
|
|
1188
|
+
WHERE m."ZMAPITEMSTORAGE" IS NOT NULL
|
|
1189
|
+
AND m."ZLATITUDE" BETWEEN ? AND ? AND m."ZLONGITUDE" BETWEEN ? AND ?
|
|
1190
|
+
LIMIT 1`).get(lat - NEAR, lat + NEAR, lon - NEAR, lon + NEAR) ?? null;
|
|
1191
|
+
}
|
|
1192
|
+
/** The newest recent carrying a place record, above a watermark. */
|
|
1193
|
+
#seededDonor(db, sinceHistoryPk) {
|
|
1194
|
+
return db.prepare(`SELECT m."Z_PK" AS mapPk, m."ZLATITUDE" AS lat, m."ZLONGITUDE" AS lon,
|
|
1195
|
+
m."ZMAPITEMSTORAGE" AS storage,
|
|
1196
|
+
h."ZCUSTOMNAME" AS name, NULL AS address, NULL AS category,
|
|
1197
|
+
CAST(h."ZMUID" AS TEXT) AS muid
|
|
1198
|
+
FROM "ZHISTORYITEM" h
|
|
1199
|
+
JOIN "ZMIXINMAPITEM" m ON m."Z_PK" = h."ZMAPITEM"
|
|
1200
|
+
WHERE h."Z_PK" > ? AND m."ZMAPITEMSTORAGE" IS NOT NULL
|
|
1201
|
+
ORDER BY h."Z_PK" DESC LIMIT 1`).get(sinceHistoryPk) ?? null;
|
|
1202
|
+
}
|
|
1203
|
+
#maxHistory(db) {
|
|
1204
|
+
const row = db.prepare(`SELECT MAX(Z_PK) AS m FROM ZHISTORYITEM`).get();
|
|
1205
|
+
return Number(row?.m ?? 0);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Add a favourite, in three phases with a connection open for as little of it
|
|
1209
|
+
* as possible.
|
|
1210
|
+
*
|
|
1211
|
+
* THE PHASES ARE THE POINT. A first version opened one read-write handle at the
|
|
1212
|
+
* top and held it across the whole seed — including the wait for Maps to
|
|
1213
|
+
* resolve a place, which can run to tens of seconds. That hung: Maps is being
|
|
1214
|
+
* asked to WRITE the very record being waited for, into a store this process is
|
|
1215
|
+
* holding open for writing. Whatever the precise interaction, the shape was a
|
|
1216
|
+
* departure from the sequence that had been proven by hand, and the proven
|
|
1217
|
+
* sequence never holds a write handle while waiting on the app.
|
|
1218
|
+
*
|
|
1219
|
+
* 1. READ — look for an existing favourite and an existing place record.
|
|
1220
|
+
* 2. SEED — ask Maps to mint a record, holding NO connection at all, and
|
|
1221
|
+
* poll with a short-lived read-only handle each time.
|
|
1222
|
+
* 3. WRITE — open read-write, insert, close.
|
|
1223
|
+
*/
|
|
1224
|
+
addFavorite(input) {
|
|
1225
|
+
const hasCoords = input.latitude !== void 0 && input.longitude !== void 0;
|
|
1226
|
+
const lat = input.latitude ?? 0;
|
|
1227
|
+
const lon = input.longitude ?? 0;
|
|
1228
|
+
progress(`add_favorite ${JSON.stringify(input.query)}: reading the store`);
|
|
1229
|
+
const ro = new DatabaseSync(this.#path, { readOnly: true });
|
|
1230
|
+
let existing = null;
|
|
1231
|
+
let donor = null;
|
|
1232
|
+
let watermark = 0;
|
|
1233
|
+
try {
|
|
1234
|
+
if (hasCoords) {
|
|
1235
|
+
existing = this.#existingFavorite(ro, lat, lon, input.name ?? input.query) ?? null;
|
|
1236
|
+
donor = this.#donorNear(ro, lat, lon);
|
|
1237
|
+
}
|
|
1238
|
+
watermark = this.#maxHistory(ro);
|
|
1239
|
+
} finally {
|
|
1240
|
+
ro.close();
|
|
1241
|
+
}
|
|
1242
|
+
if (existing) return {
|
|
1243
|
+
rowId: Number(existing.pk),
|
|
1244
|
+
uuid: uuidOf(Buffer.from(existing.hex, "hex")),
|
|
1245
|
+
name: existing.name,
|
|
1246
|
+
latitude: Number(existing.lat),
|
|
1247
|
+
longitude: Number(existing.lon),
|
|
1248
|
+
created: false,
|
|
1249
|
+
seeded: false
|
|
1250
|
+
};
|
|
1251
|
+
let seeded = false;
|
|
1252
|
+
if (!donor) {
|
|
1253
|
+
const url = `maps://?q=${encodeURIComponent(input.query)}`;
|
|
1254
|
+
progress(`asking Maps to resolve ${JSON.stringify(input.query)}…`);
|
|
1255
|
+
this.#openUrl(url);
|
|
1256
|
+
seeded = true;
|
|
1257
|
+
progress(`opened, waiting up to ${Math.round(this.#seedTimeoutMs / 1e3)}s for a record`);
|
|
1258
|
+
const deadline = Date.now() + this.#seedTimeoutMs;
|
|
1259
|
+
let polls = 0;
|
|
1260
|
+
while (Date.now() < deadline && !donor) {
|
|
1261
|
+
const probe = new DatabaseSync(this.#path, { readOnly: true });
|
|
1262
|
+
try {
|
|
1263
|
+
donor = this.#seededDonor(probe, watermark);
|
|
1264
|
+
} finally {
|
|
1265
|
+
probe.close();
|
|
1266
|
+
}
|
|
1267
|
+
if (!donor) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STORAGE_POLL_MS);
|
|
1268
|
+
polls += 1;
|
|
1269
|
+
if (polls % 8 === 0) progress(`still waiting (${polls * (STORAGE_POLL_MS / 1e3)}s)…`);
|
|
1270
|
+
}
|
|
1271
|
+
if (donor && hasCoords && !near(donor.lat, donor.lon, lat, lon)) throw new MapsWriteError(`Maps resolved ${JSON.stringify(input.query)} to a place about ${Math.round(haversineMetres(donor.lat, donor.lon, lat, lon))}m from the coordinate given, so it is probably not the place meant. Nothing was saved — try a more specific name.`, { query: input.query });
|
|
1272
|
+
if (donor) progress(`got a place record (${donor.storage?.length ?? 0} bytes) after ${polls} polls`);
|
|
1273
|
+
if (!donor) throw new MapsWriteError(`Maps did not produce a place record for "${input.query}" within ${Math.round(this.#seedTimeoutMs / 1e3)}s. Maps must be RUNNING for this to work — it is the only thing that can create a place record. It may also have resolved the query to a search rather than a place, since searches get no record. Try a more specific name, or pass latitude and longitude.`, { query: input.query });
|
|
1274
|
+
}
|
|
1275
|
+
const canonical = new DatabaseSync(this.#path, { readOnly: true });
|
|
1276
|
+
let duplicate = null;
|
|
1277
|
+
try {
|
|
1278
|
+
duplicate = this.#existingFavorite(canonical, donor.lat, donor.lon) ?? null;
|
|
1279
|
+
} finally {
|
|
1280
|
+
canonical.close();
|
|
1281
|
+
}
|
|
1282
|
+
if (duplicate) {
|
|
1283
|
+
progress("already a favourite; nothing written");
|
|
1284
|
+
return {
|
|
1285
|
+
rowId: Number(duplicate.pk),
|
|
1286
|
+
uuid: uuidOf(Buffer.from(duplicate.hex, "hex")),
|
|
1287
|
+
name: duplicate.name,
|
|
1288
|
+
latitude: Number(duplicate.lat),
|
|
1289
|
+
longitude: Number(duplicate.lon),
|
|
1290
|
+
created: false,
|
|
1291
|
+
seeded
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
progress("writing the favourite");
|
|
1295
|
+
const db = this.#open();
|
|
1296
|
+
try {
|
|
1297
|
+
const favEnt = this.#entity(db, "FavoriteItem");
|
|
1298
|
+
const mixEnt = this.#entity(db, "MixinMapItem");
|
|
1299
|
+
const favPk = this.#nextPk(db, "FavoriteItem", "ZFAVORITEITEM");
|
|
1300
|
+
const mixPk = this.#nextPk(db, "MixinMapItem", "ZMIXINMAPITEM");
|
|
1301
|
+
const now = Date.now() / 1e3 - CORE_DATA_EPOCH_OFFSET;
|
|
1302
|
+
const id = uuidBytes();
|
|
1303
|
+
const label = input.name ?? donor.name ?? input.query;
|
|
1304
|
+
const position = Number(db.prepare(`SELECT COUNT(*) AS c FROM ZFAVORITEITEM`).get().c);
|
|
1305
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1306
|
+
try {
|
|
1307
|
+
db.prepare(`INSERT INTO "ZFAVORITEITEM"
|
|
1308
|
+
(Z_PK, Z_ENT, Z_OPT, ZHIDDEN, ZPOSITIONINDEX, ZSOURCE, ZTYPE, ZVERSION,
|
|
1309
|
+
ZMAPITEM, ZMUID, ZCREATETIME, ZMODIFICATIONTIME, ZMAPITEMLASTREFRESHED,
|
|
1310
|
+
ZLATITUDE, ZLONGITUDE, ZMAPITEMNAME, ZMAPITEMADDRESS, ZMAPITEMCATEGORY,
|
|
1311
|
+
ZIDENTIFIER)
|
|
1312
|
+
VALUES (?, ?, 1, 0, ?, 0, 1, 2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(favPk, favEnt, position, mixPk, donor.muid === null ? null : Number(donor.muid), now, now, now, donor.lat, donor.lon, label, donor.address, donor.category, id);
|
|
1313
|
+
db.prepare(`INSERT INTO "ZMIXINMAPITEM"
|
|
1314
|
+
(Z_PK, Z_ENT, Z_OPT, ZFAVORITEITEM, ZCREATETIME, ZMODIFICATIONTIME,
|
|
1315
|
+
ZLATITUDE, ZLONGITUDE, ZMAPITEMSTORAGE)
|
|
1316
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`).run(mixPk, mixEnt, favPk, now, now, donor.lat, donor.lon, donor.storage);
|
|
1317
|
+
db.prepare(`UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'FavoriteItem'`).run(favPk);
|
|
1318
|
+
db.prepare(`UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'MixinMapItem'`).run(mixPk);
|
|
1319
|
+
db.exec("COMMIT");
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
try {
|
|
1322
|
+
db.exec("ROLLBACK");
|
|
1323
|
+
} catch {}
|
|
1324
|
+
throw err;
|
|
1325
|
+
}
|
|
1326
|
+
return {
|
|
1327
|
+
rowId: favPk,
|
|
1328
|
+
uuid: uuidOf(id),
|
|
1329
|
+
name: label,
|
|
1330
|
+
latitude: Number(donor.lat),
|
|
1331
|
+
longitude: Number(donor.lon),
|
|
1332
|
+
created: true,
|
|
1333
|
+
seeded
|
|
1334
|
+
};
|
|
1335
|
+
} finally {
|
|
1336
|
+
db.close();
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Remove a favourite and the place record it owns.
|
|
1341
|
+
*
|
|
1342
|
+
* `Z_MAX` is deliberately NOT rolled back. Core Data never reuses a primary
|
|
1343
|
+
* key, and decrementing the counter would hand the next insert one that is
|
|
1344
|
+
* already spoken for by a row still referenced elsewhere.
|
|
1345
|
+
*/
|
|
1346
|
+
removeFavorite(key) {
|
|
1347
|
+
const db = this.#open();
|
|
1348
|
+
try {
|
|
1349
|
+
const where = "uuid" in key ? `HEX(ZIDENTIFIER) = ?` : `Z_PK = ?`;
|
|
1350
|
+
const param = "uuid" in key ? key.uuid.replaceAll("-", "").toUpperCase() : key.rowId;
|
|
1351
|
+
const row = db.prepare(`SELECT Z_PK AS pk, ZMAPITEM AS mapPk FROM ZFAVORITEITEM WHERE ${where} LIMIT 1`).get(param);
|
|
1352
|
+
if (!row) return false;
|
|
1353
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1354
|
+
try {
|
|
1355
|
+
if (row.mapPk !== null) db.prepare(`DELETE FROM ZMIXINMAPITEM WHERE Z_PK = ?`).run(row.mapPk);
|
|
1356
|
+
db.prepare(`DELETE FROM ZFAVORITEITEM WHERE Z_PK = ?`).run(row.pk);
|
|
1357
|
+
db.exec("COMMIT");
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
try {
|
|
1360
|
+
db.exec("ROLLBACK");
|
|
1361
|
+
} catch {}
|
|
1362
|
+
throw err;
|
|
1363
|
+
}
|
|
1364
|
+
return true;
|
|
1365
|
+
} finally {
|
|
1366
|
+
db.close();
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/client/maps.ts
|
|
1372
|
+
/**
|
|
1373
|
+
* What diagnostics says about one entity: how many rows, which column won for
|
|
1374
|
+
* each field, and which fields resolved to nothing. `resolved` is the part that
|
|
1375
|
+
* matters when a result looks wrong — it names the column actually being read.
|
|
1376
|
+
*/
|
|
1377
|
+
const summariseEntity = (e) => ({
|
|
1378
|
+
rows: e.rows,
|
|
1379
|
+
resolved: Object.fromEntries(Object.entries(e.fields).filter(([, v]) => v !== null)),
|
|
1380
|
+
unresolved: Object.entries(e.fields).filter(([, v]) => v === null).map(([k]) => k)
|
|
1381
|
+
});
|
|
1382
|
+
var AppleMapsClient = class {
|
|
1383
|
+
#config;
|
|
1384
|
+
#logger;
|
|
1385
|
+
#home;
|
|
1386
|
+
#located = null;
|
|
1387
|
+
#store = null;
|
|
1388
|
+
#storeError = null;
|
|
1389
|
+
constructor(opts) {
|
|
1390
|
+
this.#config = opts.config;
|
|
1391
|
+
this.#logger = opts.logger;
|
|
1392
|
+
this.#home = opts.home;
|
|
1393
|
+
}
|
|
1394
|
+
get config() {
|
|
1395
|
+
return this.#config;
|
|
1396
|
+
}
|
|
1397
|
+
located() {
|
|
1398
|
+
this.#located ??= locateStore({
|
|
1399
|
+
storePath: this.#config.storePath,
|
|
1400
|
+
...this.#home ? { home: this.#home } : {}
|
|
1401
|
+
});
|
|
1402
|
+
return this.#located;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* A writer bound to the located store, or an error explaining why not.
|
|
1406
|
+
*
|
|
1407
|
+
* Writes go through their own read-write connection rather than the shared
|
|
1408
|
+
* read-only one: `store()` opens with `query_only` and an `immutable` fallback
|
|
1409
|
+
* precisely so a read can never mutate by accident, and widening it would
|
|
1410
|
+
* throw that guarantee away for every read on the surface.
|
|
1411
|
+
*/
|
|
1412
|
+
writer(openUrl) {
|
|
1413
|
+
const located = this.located();
|
|
1414
|
+
if (!located.readable || !located.storePath) throw new MapsStoreUnavailableError(located.reason ?? "Maps' store could not be opened.");
|
|
1415
|
+
return new MapsWriter({
|
|
1416
|
+
storePath: located.storePath,
|
|
1417
|
+
...openUrl ? { openUrl } : {}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
/** Open the store, once, lazily. Every read goes through here. */
|
|
1421
|
+
store() {
|
|
1422
|
+
if (this.#store) return this.#store;
|
|
1423
|
+
if (this.#storeError !== null) throw new MapsStoreUnavailableError(this.#storeError);
|
|
1424
|
+
const located = this.located();
|
|
1425
|
+
if (!located.readable || !located.storePath) {
|
|
1426
|
+
this.#storeError = located.reason ?? "Maps' store could not be opened.";
|
|
1427
|
+
throw new MapsStoreUnavailableError(this.#storeError);
|
|
1428
|
+
}
|
|
1429
|
+
try {
|
|
1430
|
+
this.#store = openStore({
|
|
1431
|
+
path: located.storePath,
|
|
1432
|
+
mode: this.#config.indexMode,
|
|
1433
|
+
...this.#logger ? { logger: this.#logger } : {}
|
|
1434
|
+
});
|
|
1435
|
+
return this.#store;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
this.#storeError = err instanceof Error ? err.message : String(err);
|
|
1438
|
+
throw err;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
get epoch() {
|
|
1442
|
+
return this.store().caps.epoch;
|
|
1443
|
+
}
|
|
1444
|
+
#render(kind, row, epoch) {
|
|
1445
|
+
return {
|
|
1446
|
+
ref: encodePlaceRef(kind, row.uuid ? { uuid: row.uuid } : { rowId: row.id }),
|
|
1447
|
+
kind,
|
|
1448
|
+
name: row.customName ?? row.name,
|
|
1449
|
+
placeName: row.name,
|
|
1450
|
+
address: row.address,
|
|
1451
|
+
latitude: row.latitude,
|
|
1452
|
+
longitude: row.longitude,
|
|
1453
|
+
muid: row.muid,
|
|
1454
|
+
created: renderInstant(row.createdRaw, epoch),
|
|
1455
|
+
modified: renderInstant(row.modifiedRaw, epoch),
|
|
1456
|
+
linked: row.linked
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
#renderCollection(row, epoch) {
|
|
1460
|
+
return {
|
|
1461
|
+
ref: encodeCollectionRef(row.uuid ? { uuid: row.uuid } : { rowId: row.id }),
|
|
1462
|
+
title: row.title,
|
|
1463
|
+
placesCount: row.placesCount,
|
|
1464
|
+
created: renderInstant(row.createdRaw, epoch),
|
|
1465
|
+
modified: renderInstant(row.modifiedRaw, epoch)
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
places(kind, opts) {
|
|
1469
|
+
const store = this.store();
|
|
1470
|
+
const { rows, truncated } = store.places(kind, opts);
|
|
1471
|
+
return {
|
|
1472
|
+
places: rows.map((r) => this.#render(kind, r, store.caps.epoch)),
|
|
1473
|
+
truncated,
|
|
1474
|
+
datesAvailable: store.caps.epoch.confident
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
place(kind, key) {
|
|
1478
|
+
const store = this.store();
|
|
1479
|
+
const row = store.place(kind, key);
|
|
1480
|
+
return row ? this.#render(kind, row, store.caps.epoch) : null;
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* A collection ref to the row id its items point at, or null when the ref
|
|
1484
|
+
* addresses nothing in this store.
|
|
1485
|
+
*
|
|
1486
|
+
* Collection membership is a Core Data foreign key and always holds `Z_PK`,
|
|
1487
|
+
* so a uuid ref has to be translated before it can filter items.
|
|
1488
|
+
*/
|
|
1489
|
+
collectionRowId(key) {
|
|
1490
|
+
return this.store().collectionRowId(key);
|
|
1491
|
+
}
|
|
1492
|
+
collections(opts) {
|
|
1493
|
+
const store = this.store();
|
|
1494
|
+
const { rows, truncated } = store.collections(opts);
|
|
1495
|
+
return {
|
|
1496
|
+
collections: rows.map((r) => this.#renderCollection(r, store.caps.epoch)),
|
|
1497
|
+
truncated,
|
|
1498
|
+
itemsEnumerable: store.caps.membership !== null,
|
|
1499
|
+
unfiled: store.unfiledCount()
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Search every place-bearing entity at once.
|
|
1504
|
+
*
|
|
1505
|
+
* Three separate queries rather than a UNION, because the three tables have
|
|
1506
|
+
* different resolved columns and a UNION would have to flatten them to the
|
|
1507
|
+
* narrowest — which on this store means losing history's coordinates, since
|
|
1508
|
+
* they live in a differently named column from the other two.
|
|
1509
|
+
*/
|
|
1510
|
+
search(opts) {
|
|
1511
|
+
const store = this.store();
|
|
1512
|
+
const kinds = [
|
|
1513
|
+
"favorite",
|
|
1514
|
+
"collection-item",
|
|
1515
|
+
"history"
|
|
1516
|
+
];
|
|
1517
|
+
const places = [];
|
|
1518
|
+
let truncated = false;
|
|
1519
|
+
for (const kind of kinds) {
|
|
1520
|
+
const { rows, truncated: t } = store.places(kind, {
|
|
1521
|
+
limit: opts.limit,
|
|
1522
|
+
query: opts.query
|
|
1523
|
+
});
|
|
1524
|
+
truncated ||= t;
|
|
1525
|
+
for (const r of rows) places.push(this.#render(kind, r, store.caps.epoch));
|
|
1526
|
+
}
|
|
1527
|
+
return {
|
|
1528
|
+
places: places.slice(0, opts.limit),
|
|
1529
|
+
truncated: truncated || places.length > opts.limit,
|
|
1530
|
+
datesAvailable: store.caps.epoch.confident
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
/** Everything diagnostics needs, with no failure allowed to fail the call. */
|
|
1534
|
+
status() {
|
|
1535
|
+
const located = this.located();
|
|
1536
|
+
let store = null;
|
|
1537
|
+
let reason = located.reason;
|
|
1538
|
+
try {
|
|
1539
|
+
store = this.store();
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
located,
|
|
1545
|
+
store: {
|
|
1546
|
+
opened: store !== null,
|
|
1547
|
+
mode: store?.mode ?? null,
|
|
1548
|
+
reason
|
|
1549
|
+
},
|
|
1550
|
+
capabilities: store ? {
|
|
1551
|
+
fingerprint: store.caps.fingerprint,
|
|
1552
|
+
tableCount: store.caps.tables.length,
|
|
1553
|
+
epoch: store.caps.epoch,
|
|
1554
|
+
collectionFk: store.caps.collectionFk,
|
|
1555
|
+
collectionMembership: store.caps.membership,
|
|
1556
|
+
entities: {
|
|
1557
|
+
favorites: summariseEntity(store.caps.favorites),
|
|
1558
|
+
collections: summariseEntity(store.caps.collections),
|
|
1559
|
+
collectionItems: summariseEntity(store.caps.collectionItems),
|
|
1560
|
+
history: summariseEntity(store.caps.history),
|
|
1561
|
+
mapItems: summariseEntity(store.caps.mapItems)
|
|
1562
|
+
}
|
|
1563
|
+
} : null
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
close() {
|
|
1567
|
+
this.#store?.close();
|
|
1568
|
+
this.#store = null;
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
//#endregion
|
|
1572
|
+
//#region src/config.ts
|
|
1573
|
+
/**
|
|
1574
|
+
* Configuration is environment-only — this server holds no secret at all. Its
|
|
1575
|
+
* access is the macOS permission the user granted, which is the whole point.
|
|
1576
|
+
*
|
|
1577
|
+
* `allowWrites` gates two mutating tools, and gates them harder than elsewhere.
|
|
1578
|
+
* Maps has no scripting dictionary and no registered App Intents, so a write is
|
|
1579
|
+
* SQL straight into a Core Data store that `NSPersistentCloudKitContainer`
|
|
1580
|
+
* mirrors — which means it reaches every device on the account, not just this
|
|
1581
|
+
* Mac. That was measured, along with the rule that keeps it safe: never
|
|
1582
|
+
* fabricate a place record, only ever copy one Maps wrote itself. See
|
|
1583
|
+
* `docs/maps.md` and `client/write.ts`.
|
|
1584
|
+
*/
|
|
1585
|
+
const ConfigSchema = BaseConfigSchema.extend({
|
|
1586
|
+
/** Explicit store path. Bypasses discovery — for tests and forensic copies. */
|
|
1587
|
+
storePath: z.string().optional(),
|
|
1588
|
+
indexMode: z.enum([
|
|
1589
|
+
"auto",
|
|
1590
|
+
"ro",
|
|
1591
|
+
"immutable",
|
|
1592
|
+
"off"
|
|
1593
|
+
]).default("auto")
|
|
1594
|
+
}).strict();
|
|
1595
|
+
const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
1596
|
+
allowWrites: parseBool(env.APPLE_MAPS_ALLOW_WRITES),
|
|
1597
|
+
exposePrompts: parseBool(env.APPLE_MAPS_EXPOSE_PROMPTS),
|
|
1598
|
+
debug: parseBool(env.APPLE_MAPS_DEBUG),
|
|
1599
|
+
storePath: trimmed(env.APPLE_MAPS_STORE),
|
|
1600
|
+
indexMode: trimmed(env.APPLE_MAPS_INDEX_MODE),
|
|
1601
|
+
osascriptPath: trimmed(env.APPLE_MAPS_OSASCRIPT_PATH),
|
|
1602
|
+
osascriptTimeoutMs: parseIntOpt(env.APPLE_MAPS_OSASCRIPT_TIMEOUT_MS),
|
|
1603
|
+
maxResults: parseIntOpt(env.APPLE_MAPS_MAX_RESULTS)
|
|
1604
|
+
});
|
|
1605
|
+
//#endregion
|
|
1606
|
+
//#region src/guide.ts
|
|
1607
|
+
/**
|
|
1608
|
+
* The Maps operating manual, served as `cupertino://maps/guide` and embedded
|
|
1609
|
+
* ahead of every Maps prompt. Static by design — see the note in the Mail guide.
|
|
1610
|
+
*/
|
|
1611
|
+
const MAPS_GUIDE = `# Maps — how to drive this server
|
|
1612
|
+
|
|
1613
|
+
## What this surface is, and is not
|
|
1614
|
+
|
|
1615
|
+
It reads the places **saved on this Mac**: favourites, collections (Guides) and
|
|
1616
|
+
recents. It does **not** search Apple's map of the world, geocode an address,
|
|
1617
|
+
give directions, or compute a travel time. If asked for any of those, say so
|
|
1618
|
+
plainly rather than searching the saved places and presenting a near-miss —
|
|
1619
|
+
"the nearest coffee shop" is not a question this server can answer, and a
|
|
1620
|
+
favourite called "Coffee" is not the answer to it.
|
|
1621
|
+
|
|
1622
|
+
## One lane, and no fallback
|
|
1623
|
+
|
|
1624
|
+
Maps ships **no scripting dictionary** — there is no \`.sdef\` in the app bundle
|
|
1625
|
+
— so unlike every other surface here there is no Apple Events lane to fall back
|
|
1626
|
+
to. Everything comes from a Core Data store under **Full Disk Access**.
|
|
1627
|
+
|
|
1628
|
+
The consequence matters: without the grant this server returns an **error**, not
|
|
1629
|
+
an empty list. If a tool ever does return an empty list, that means the user has
|
|
1630
|
+
genuinely saved nothing of that kind. Check
|
|
1631
|
+
\`cupertino://maps/diagnostics\` before concluding either way.
|
|
1632
|
+
|
|
1633
|
+
## Which tool answers which question
|
|
1634
|
+
|
|
1635
|
+
| Question | Tool |
|
|
1636
|
+
| --- | --- |
|
|
1637
|
+
| What places has the user saved? | \`apple_maps_list_favorites\` |
|
|
1638
|
+
| What Guides do they keep? | \`apple_maps_list_collections\` |
|
|
1639
|
+
| What is filed in one Guide? | \`apple_maps_list_collection_places\` |
|
|
1640
|
+
| Where have they looked recently? | \`apple_maps_list_recents\` |
|
|
1641
|
+
| Find a saved place by name or address | \`apple_maps_search_places\` |
|
|
1642
|
+
| One place, in full | \`apple_maps_get_place\` |
|
|
1643
|
+
|
|
1644
|
+
## Four things not to say
|
|
1645
|
+
|
|
1646
|
+
**\`linked: false\` is not a broken row.** Some favourites have no place
|
|
1647
|
+
attached, no name and no coordinate. They are Maps' unconfigured Home / Work /
|
|
1648
|
+
School slots. Report them as unset, not as places.
|
|
1649
|
+
|
|
1650
|
+
**A null date is unknown, not old.** Timestamps are placed on an epoch detected
|
|
1651
|
+
from the store. When detection fails every date reads \`null\` rather than being
|
|
1652
|
+
guessed, because a date wrong by 31 years reads exactly like a correct one.
|
|
1653
|
+
|
|
1654
|
+
**A collection with no listable places may still have places.** How an item
|
|
1655
|
+
belongs to a collection is not exposed by every version of this store. When the
|
|
1656
|
+
key is missing, \`placesCount\` is still Maps' own accurate number and the
|
|
1657
|
+
places simply cannot be enumerated. Say that, rather than reporting an empty
|
|
1658
|
+
Guide.
|
|
1659
|
+
|
|
1660
|
+
**Refs expire.** They address a row in a store that iCloud re-syncs, and Core
|
|
1661
|
+
Data reuses row ids after a delete. A ref is good for this conversation. Do not
|
|
1662
|
+
store one or hand it back later.
|
|
1663
|
+
|
|
1664
|
+
## Privacy, worth holding in mind
|
|
1665
|
+
|
|
1666
|
+
Saved places are a home address, a doctor, a school, a partner's flat. This is
|
|
1667
|
+
among the most sensitive data any surface here reads. Answer what was asked;
|
|
1668
|
+
do not enumerate everything because a listing tool is available.
|
|
1669
|
+
`;
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/prompts.ts
|
|
1672
|
+
const CTX = {
|
|
1673
|
+
surface: "maps",
|
|
1674
|
+
guide: MAPS_GUIDE
|
|
1675
|
+
};
|
|
1676
|
+
/**
|
|
1677
|
+
* Maps' one workflow prompt.
|
|
1678
|
+
*
|
|
1679
|
+
* It exists because "where was that place" spans three lists that hold the same
|
|
1680
|
+
* kind of thing for different reasons — a favourite is deliberate, a collection
|
|
1681
|
+
* entry is filed, a recent is incidental — and searching only one produces a
|
|
1682
|
+
* confident answer to a question nobody asked. The prompt also carries the
|
|
1683
|
+
* refusal: this server does not know about places the user has never saved, and
|
|
1684
|
+
* the most likely way to be wrong here is to answer anyway.
|
|
1685
|
+
*/
|
|
1686
|
+
const registerPrompts = (server) => {
|
|
1687
|
+
registerWorkflowPrompt(server, CTX, {
|
|
1688
|
+
name: "apple_maps_where_was_that_place",
|
|
1689
|
+
title: "Find a place I saved",
|
|
1690
|
+
description: "Track down a saved place across favourites, Guides and recents — three lists that hold places for different reasons. Read-only.",
|
|
1691
|
+
argsSchema: { about: promptArg("What the place was, e.g. \"the ramen place near the office\".") },
|
|
1692
|
+
build: ({ about }) => `Find the saved place ${about ? `matching: ${about}` : "I am after"}.
|
|
1693
|
+
|
|
1694
|
+
Start with \`apple_maps_search_places\` — it covers favourites, Guide entries and
|
|
1695
|
+
recents in one call, and tells you which kind each match came from. That
|
|
1696
|
+
distinction is the answer, not a detail: a favourite is somewhere I chose to
|
|
1697
|
+
keep, a recent is somewhere I merely looked at once.
|
|
1698
|
+
|
|
1699
|
+
If that finds nothing, list the three directly before concluding it is not
|
|
1700
|
+
there — a place I saved under my own label ("Mum's") will not match a search for
|
|
1701
|
+
what it actually is.
|
|
1702
|
+
|
|
1703
|
+
Then report what you found, with the address and how it was saved.
|
|
1704
|
+
|
|
1705
|
+
Three things to get right:
|
|
1706
|
+
- If nothing matches, say I have not saved it. Do **not** fall back to guessing
|
|
1707
|
+
a place from general knowledge — this server only knows what is on this Mac,
|
|
1708
|
+
and inventing an address is the worst possible failure here.
|
|
1709
|
+
- An entry with \`linked: false\` is an unconfigured Home/Work/School slot, not
|
|
1710
|
+
a place. Do not report it as one.
|
|
1711
|
+
- If a date reads null, the date is unknown. Do not describe it as old.
|
|
1712
|
+
|
|
1713
|
+
If every list comes back empty, check \`cupertino://maps/diagnostics\` before
|
|
1714
|
+
concluding I have saved nothing — this surface has no second lane, so a missing
|
|
1715
|
+
Full Disk Access grant takes all of it at once.`
|
|
1716
|
+
});
|
|
1717
|
+
};
|
|
1718
|
+
//#endregion
|
|
1719
|
+
//#region src/tools/util.ts
|
|
1720
|
+
const placeRefArg = z.string().min(1).describe("An opaque place ref from a listing (looks like \"p1:f:12\"). Do not construct one by hand. Refs address a row in the local store, so they are only valid for this session — an iCloud re-sync can renumber them.");
|
|
1721
|
+
const collectionRefArg = z.string().min(1).describe("An opaque collection ref from apple_maps_list_collections (looks like \"pc1:3\").");
|
|
1722
|
+
const queryArg = z.string().min(1).describe("Text to look for in a place's name, the label you gave it, or its address. Wildcards are escaped, so searching for \"100%\" finds that literal string.");
|
|
1723
|
+
//#endregion
|
|
1724
|
+
//#region src/tools/diagnostics.ts
|
|
1725
|
+
/**
|
|
1726
|
+
* Build the report.
|
|
1727
|
+
*
|
|
1728
|
+
* Split out of the tool registration so the `cupertino://maps/diagnostics`
|
|
1729
|
+
* resource can serve the same bytes. Two renderings of one probe: duplicated,
|
|
1730
|
+
* the resource and the tool would drift, and the disagreement would surface as
|
|
1731
|
+
* "the diagnostics lied" — the one thing this file must never do.
|
|
1732
|
+
*/
|
|
1733
|
+
const buildDiagnostics = async (client) => {
|
|
1734
|
+
const status = client.status();
|
|
1735
|
+
const located = status.located;
|
|
1736
|
+
return {
|
|
1737
|
+
server: {
|
|
1738
|
+
name: BUILD_INFO.name,
|
|
1739
|
+
version: BUILD_INFO.version
|
|
1740
|
+
},
|
|
1741
|
+
settings: { exposePrompts: client.config.exposePrompts },
|
|
1742
|
+
lanes: {
|
|
1743
|
+
summary: "Maps has ONE lane. Maps.app ships no scripting dictionary — there is no .sdef in the bundle, checked directly — so there is no Apple Events fallback. Without Full Disk Access this server cannot read anything at all, and it says so rather than returning empty lists.",
|
|
1744
|
+
fileLane: {
|
|
1745
|
+
needs: "Full Disk Access",
|
|
1746
|
+
answers: "favourites, collections (guides), recents",
|
|
1747
|
+
working: status.store.opened
|
|
1748
|
+
},
|
|
1749
|
+
appleEvents: "none — Maps is not scriptable",
|
|
1750
|
+
writes: "none — this server registers no mutating tool. The store is mirrored to iCloud by NSPersistentCloudKitContainer, so a write is an edit to one replica of a synchronising graph underneath a running app. That was never probed."
|
|
1751
|
+
},
|
|
1752
|
+
store: {
|
|
1753
|
+
path: located.storePath,
|
|
1754
|
+
exists: located.exists,
|
|
1755
|
+
readable: located.readable,
|
|
1756
|
+
opened: status.store.opened,
|
|
1757
|
+
mode: status.store.mode,
|
|
1758
|
+
reason: status.store.reason,
|
|
1759
|
+
resolvedByScan: located.resolvedByScan,
|
|
1760
|
+
...status.capabilities
|
|
1761
|
+
},
|
|
1762
|
+
files: {
|
|
1763
|
+
directory: located.directory,
|
|
1764
|
+
localCache: {
|
|
1765
|
+
path: located.localCache.path,
|
|
1766
|
+
exists: located.localCache.exists,
|
|
1767
|
+
note: "The device-local cache. Same entities as the sync store and, on the probed machine, zero rows in every one of them. Located so it is visibly considered, never opened."
|
|
1768
|
+
}
|
|
1769
|
+
},
|
|
1770
|
+
caveats: [
|
|
1771
|
+
"This store was missed three times before it was found, and the reasons are worth knowing if it ever looks absent: the file has NO EXTENSION (MapsSync_0.0.1), it lives in the one directory of Maps' container that Full Disk Access gates, and `group.com.apple.Maps` is a decoy that is EPERM rather than empty.",
|
|
1772
|
+
"Columns are resolved BY COVERAGE, not by name. ZHISTORYITEM carries both ZLATITUDE (1 row of 33) and ZLATITUDE1 (19 of 33); picking the first recognised name would report that Maps holds almost no coordinates. `entities.*.resolved` above shows which column actually won for each field.",
|
|
1773
|
+
"Some favourites have no linked place: 3 of 23 on the probed machine, with no name and no coordinate. Almost certainly the unconfigured Home / Work / School slots. They are returned with `linked: false` rather than dropped, because silently omitting rows reads as a deletion.",
|
|
1774
|
+
"Collection membership is a many-to-many join table, Z_6PLACES(Z_6COLLECTIONS, Z_7PLACES) on the probed machine, NOT a column on ZCOLLECTIONITEM — which is why four guessed column names all missed it. It is re-proved at open time by reproducing ZPLACESCOUNT exactly, never by matching a name, because ZCOLLECTIONITEM.ZMAPITEM joins 3 of 10 collections by coincidence. When nothing reproduces those counts, `collectionMembership` is null, collections list without their places and every result says so.",
|
|
1775
|
+
"Refs address a local row id. Core Data reuses those after a delete and this store is mirrored from CloudKit, so a re-sync can renumber rows. A ref is good for the current session and should not be stored.",
|
|
1776
|
+
"Timestamps are placed on an epoch DETECTED from the store, never assumed. The same value read as unix seconds instead of Core Data seconds lands in 1995 and looks entirely plausible. When detection fails every date reads null rather than guessed."
|
|
1777
|
+
]
|
|
1778
|
+
};
|
|
1779
|
+
};
|
|
1780
|
+
const registerDiagnosticsTools = (server, client) => {
|
|
1781
|
+
server.registerTool("apple_maps_diagnostics", {
|
|
1782
|
+
description: "Report whether Maps' store can be read, which columns were resolved for each entity, and what this server deliberately cannot do. Start here when a read returns nothing — this surface has no second lane, so a missing grant means no data at all rather than slower data.",
|
|
1783
|
+
inputSchema: {},
|
|
1784
|
+
annotations: { readOnlyHint: true }
|
|
1785
|
+
}, async () => wrap(() => buildDiagnostics(client)));
|
|
1786
|
+
};
|
|
1787
|
+
//#endregion
|
|
1788
|
+
//#region src/tools/places.ts
|
|
1789
|
+
/**
|
|
1790
|
+
* The place tools.
|
|
1791
|
+
*
|
|
1792
|
+
* Every description states the permission fact plainly. On this surface it is
|
|
1793
|
+
* not the usual "slower without the grant" — there is no second lane, so
|
|
1794
|
+
* without Full Disk Access these tools have nothing to read. Saying so in the
|
|
1795
|
+
* description is what stops a model concluding, from an error it half-read,
|
|
1796
|
+
* that the person simply has not saved any places.
|
|
1797
|
+
*/
|
|
1798
|
+
const registerPlaceTools = (server, client) => {
|
|
1799
|
+
server.registerTool("apple_maps_list_favorites", {
|
|
1800
|
+
description: "List the places saved as favourites in Maps, with coordinates, address and the label the user gave them. Needs Full Disk Access — Maps is not scriptable, so without the grant this returns an error rather than an empty list. Some entries have no linked place (`linked: false`); those are the unconfigured Home/Work/School slots, not broken rows. This is everything SAVED: Maps' own Pinned panel applies display rules that are not in the store and can show fewer.",
|
|
1801
|
+
inputSchema: { limit: limitArg },
|
|
1802
|
+
annotations: {
|
|
1803
|
+
readOnlyHint: true,
|
|
1804
|
+
idempotentHint: true
|
|
1805
|
+
}
|
|
1806
|
+
}, async ({ limit }) => wrapResult(async () => {
|
|
1807
|
+
const result = client.places("favorite", { limit: limit ?? client.config.maxResults });
|
|
1808
|
+
const unlinked = result.places.filter((p) => !p.linked).length;
|
|
1809
|
+
return ok(compact({
|
|
1810
|
+
favorites: result.places,
|
|
1811
|
+
count: result.places.length,
|
|
1812
|
+
unlinked: unlinked ? `${unlinked} entr${unlinked === 1 ? "y has" : "ies have"} no linked place — these are Maps' unconfigured Home/Work/School slots. They are returned rather than dropped, because silently omitting rows reads as a deletion.` : void 0,
|
|
1813
|
+
/**
|
|
1814
|
+
* MEASURED, and the reason the note above no longer claims this count
|
|
1815
|
+
* matches the app: on a real store Maps' Pinned panel showed 17 while
|
|
1816
|
+
* this tool returned 24. Three extras were the unlinked slots; four
|
|
1817
|
+
* were ordinary favourites with names, addresses and coordinates, one
|
|
1818
|
+
* an exact duplicate of a shown entry.
|
|
1819
|
+
*
|
|
1820
|
+
* No column in ZFAVORITEITEM separates them — ZHIDDEN, ZSOURCE, ZTYPE
|
|
1821
|
+
* and ZVERSION were all cross-tabulated and none yields a group of
|
|
1822
|
+
* four. `ZVERSION = 2` has exactly 17 rows and is a COINCIDENCE OF
|
|
1823
|
+
* TOTALS: 16 of them are linked while all 17 shown entries are. So
|
|
1824
|
+
* Maps applies display rules this server cannot see — de-duplication
|
|
1825
|
+
* is the visible one — and no filter written here could reproduce
|
|
1826
|
+
* them. Saying so beats guessing, and beats the old note, which
|
|
1827
|
+
* asserted the opposite of what was measured. See docs/maps.md.
|
|
1828
|
+
*/
|
|
1829
|
+
mayExceedApp: "Maps' own Pinned panel can show FEWER places than this. It applies display rules — de-duplication at least — that are not recorded in the store, so this is everything saved, not everything shown.",
|
|
1830
|
+
truncated: result.truncated ? `More favourites exist beyond limit=${limit ?? client.config.maxResults}.` : void 0,
|
|
1831
|
+
datesUnavailable: result.datesAvailable ? void 0 : "Timestamps could not be placed on a known epoch, so every date reads null. They are withheld rather than guessed — see apple_maps_diagnostics."
|
|
1832
|
+
}));
|
|
1833
|
+
}));
|
|
1834
|
+
server.registerTool("apple_maps_list_collections", {
|
|
1835
|
+
description: "List the collections (Guides) in Maps. Each carries Maps' own count of the places in it. Use apple_maps_list_collection_places to enumerate one. Needs Full Disk Access.",
|
|
1836
|
+
inputSchema: { limit: limitArg },
|
|
1837
|
+
annotations: {
|
|
1838
|
+
readOnlyHint: true,
|
|
1839
|
+
idempotentHint: true
|
|
1840
|
+
}
|
|
1841
|
+
}, async ({ limit }) => wrapResult(async () => {
|
|
1842
|
+
const result = client.collections({ limit: limit ?? client.config.maxResults });
|
|
1843
|
+
return ok(compact({
|
|
1844
|
+
collections: result.collections,
|
|
1845
|
+
count: result.collections.length,
|
|
1846
|
+
unfiled: result.unfiled && result.unfiled > 0 ? `${result.unfiled} saved place(s) are in no collection. List them with apple_maps_list_unfiled_places.` : void 0,
|
|
1847
|
+
itemsUnavailable: result.itemsEnumerable ? void 0 : "This store does not expose how an item belongs to a collection, so the places inside a collection cannot be listed. `placesCount` is Maps' own number and is still accurate; apple_maps_list_collection_places will return nothing.",
|
|
1848
|
+
truncated: result.truncated ? `More collections exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
|
|
1849
|
+
}));
|
|
1850
|
+
}));
|
|
1851
|
+
server.registerTool("apple_maps_list_collection_places", {
|
|
1852
|
+
description: "List the places filed in one collection, by its ref. Needs Full Disk Access. Returns nothing when this store does not expose collection membership — check apple_maps_diagnostics, which reports whether the key was resolved.",
|
|
1853
|
+
inputSchema: {
|
|
1854
|
+
ref: collectionRefArg,
|
|
1855
|
+
limit: limitArg
|
|
1856
|
+
},
|
|
1857
|
+
annotations: {
|
|
1858
|
+
readOnlyHint: true,
|
|
1859
|
+
idempotentHint: true
|
|
1860
|
+
}
|
|
1861
|
+
}, async ({ ref, limit }) => wrapResult(async () => {
|
|
1862
|
+
const collectionId = client.collectionRowId(decodeCollectionRef(ref));
|
|
1863
|
+
const result = client.places("collection-item", {
|
|
1864
|
+
limit: limit ?? client.config.maxResults,
|
|
1865
|
+
collectionId: collectionId ?? void 0
|
|
1866
|
+
});
|
|
1867
|
+
if (!client.collections({ limit: 1e3 }).itemsEnumerable) return fail("This store does not expose which collection an item belongs to, so its places cannot be listed. The collection itself and its place count are still readable through apple_maps_list_collections.");
|
|
1868
|
+
return ok(compact({
|
|
1869
|
+
places: result.places,
|
|
1870
|
+
count: result.places.length,
|
|
1871
|
+
truncated: result.truncated ? `More places exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
|
|
1872
|
+
}));
|
|
1873
|
+
}));
|
|
1874
|
+
server.registerTool("apple_maps_list_unfiled_places", {
|
|
1875
|
+
description: "List saved places that are in no collection. Maps files a saved place into a Guide through a join table, and a place can exist with no row there — 12 of 30 on the probed machine, 7 of which appear nowhere else in the store: not as a favourite, not in another Guide, not in recents. Those are reachable through no other tool. Needs Full Disk Access. Returns nothing when collection membership could not be resolved, which apple_maps_diagnostics reports.",
|
|
1876
|
+
inputSchema: { limit: limitArg },
|
|
1877
|
+
annotations: {
|
|
1878
|
+
readOnlyHint: true,
|
|
1879
|
+
idempotentHint: true
|
|
1880
|
+
}
|
|
1881
|
+
}, async ({ limit }) => wrapResult(async () => {
|
|
1882
|
+
if (!client.collections({ limit: 1 }).itemsEnumerable) return fail("This store does not expose which collection an item belongs to, so places in no collection cannot be told apart from places in one. apple_maps_list_collections still works.");
|
|
1883
|
+
const result = client.places("collection-item", {
|
|
1884
|
+
limit: limit ?? client.config.maxResults,
|
|
1885
|
+
unfiled: true
|
|
1886
|
+
});
|
|
1887
|
+
return ok(compact({
|
|
1888
|
+
places: result.places,
|
|
1889
|
+
count: result.places.length,
|
|
1890
|
+
truncated: result.truncated ? `More places exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
|
|
1891
|
+
}));
|
|
1892
|
+
}));
|
|
1893
|
+
server.registerTool("apple_maps_list_recents", {
|
|
1894
|
+
description: "List the places recently looked at in Maps, newest first. This is Maps' Recents list, not a search history: it holds places, directions and searches the user actually opened. Needs Full Disk Access.",
|
|
1895
|
+
inputSchema: { limit: limitArg },
|
|
1896
|
+
annotations: {
|
|
1897
|
+
readOnlyHint: true,
|
|
1898
|
+
idempotentHint: true
|
|
1899
|
+
}
|
|
1900
|
+
}, async ({ limit }) => wrapResult(async () => {
|
|
1901
|
+
const result = client.places("history", { limit: limit ?? client.config.maxResults });
|
|
1902
|
+
const named = result.places.filter((p) => p.name).length;
|
|
1903
|
+
return ok(compact({
|
|
1904
|
+
recents: result.places,
|
|
1905
|
+
count: result.places.length,
|
|
1906
|
+
namesUnavailable: result.places.length > 0 && named * 4 < result.places.length ? `Only ${named} of ${result.places.length} entries carry a name. Maps keeps a recent's place name in an encoded record this server does not decode, so most rows have coordinates and dates but no label. This is the store's shape, not missing data.` : void 0,
|
|
1907
|
+
truncated: result.truncated ? `More entries exist beyond limit=${limit ?? client.config.maxResults}.` : void 0,
|
|
1908
|
+
datesUnavailable: result.datesAvailable ? void 0 : "Timestamps could not be placed on a known epoch, so every date reads null."
|
|
1909
|
+
}));
|
|
1910
|
+
}));
|
|
1911
|
+
server.registerTool("apple_maps_search_places", {
|
|
1912
|
+
description: "Search every saved place — favourites, collection entries and recents — by name, by the label the user gave it, or by address. Returns each match with the kind of entry it came from. Needs Full Disk Access. This searches what is SAVED on this Mac; it does not search Apple's map of the world.",
|
|
1913
|
+
inputSchema: {
|
|
1914
|
+
query: queryArg,
|
|
1915
|
+
limit: limitArg
|
|
1916
|
+
},
|
|
1917
|
+
annotations: {
|
|
1918
|
+
readOnlyHint: true,
|
|
1919
|
+
idempotentHint: true
|
|
1920
|
+
}
|
|
1921
|
+
}, async ({ query, limit }) => wrapResult(async () => {
|
|
1922
|
+
const result = client.search({
|
|
1923
|
+
query,
|
|
1924
|
+
limit: limit ?? client.config.maxResults
|
|
1925
|
+
});
|
|
1926
|
+
return ok(compact({
|
|
1927
|
+
places: result.places,
|
|
1928
|
+
count: result.places.length,
|
|
1929
|
+
truncated: result.truncated ? `More matches exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
|
|
1930
|
+
}));
|
|
1931
|
+
}));
|
|
1932
|
+
server.registerTool("apple_maps_get_place", {
|
|
1933
|
+
description: "Get one saved place by its ref, with coordinates, address and dates. Needs Full Disk Access.",
|
|
1934
|
+
inputSchema: { ref: placeRefArg },
|
|
1935
|
+
annotations: {
|
|
1936
|
+
readOnlyHint: true,
|
|
1937
|
+
idempotentHint: true
|
|
1938
|
+
}
|
|
1939
|
+
}, async ({ ref }) => wrapResult(async () => {
|
|
1940
|
+
const { kind, key } = decodePlaceRef(ref);
|
|
1941
|
+
const place = client.place(kind, key);
|
|
1942
|
+
if (!place) return fail("uuid" in key ? "No place for that ref. It was removed in Maps, or it belongs to a different store than the one being read." : "No place for that ref. It may have been removed in Maps, or iCloud may have re-synced the store and renumbered the rows since the listing ran — this ref carries a row id, which only this store's current numbering can resolve. Re-run the listing for a current ref.");
|
|
1943
|
+
return ok({ place });
|
|
1944
|
+
}));
|
|
1945
|
+
};
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/tools/writes.ts
|
|
1948
|
+
/**
|
|
1949
|
+
* The mutating tools.
|
|
1950
|
+
*
|
|
1951
|
+
* Registered only when `APPLE_MAPS_ALLOW_WRITES` is true, so a host that has not
|
|
1952
|
+
* opted in is never told they exist — the same gate every other surface uses.
|
|
1953
|
+
*
|
|
1954
|
+
* ## Two things these descriptions must say, because they are surprising
|
|
1955
|
+
*
|
|
1956
|
+
* **Adding a favourite leaves an entry in Recents.** A place is only real to
|
|
1957
|
+
* Maps once it has a GEO record, and the only thing that can produce one is Maps
|
|
1958
|
+
* itself: the place is opened through the `maps://` URL scheme, Maps resolves it
|
|
1959
|
+
* and files it in Recents, and that record is copied into the favourite. There
|
|
1960
|
+
* is no way to have the first without the second, so the caller is told rather
|
|
1961
|
+
* than surprised. When the place is already known to the store, no seeding
|
|
1962
|
+
* happens and no Recents entry appears.
|
|
1963
|
+
*
|
|
1964
|
+
* **It reaches the user's other devices.** The store is mirrored by
|
|
1965
|
+
* `NSPersistentCloudKitContainer`, which reconciles on the app's next save
|
|
1966
|
+
* whether or not it was told anything. A favourite added here arrived on an
|
|
1967
|
+
* iPhone. That makes this the one write in the bundle whose blast radius is
|
|
1968
|
+
* larger than the machine it runs on, and the descriptions say so.
|
|
1969
|
+
*/
|
|
1970
|
+
const registerWriteTools = (server, client) => {
|
|
1971
|
+
server.registerTool("apple_maps_add_favorite", {
|
|
1972
|
+
description: "Save a place to Maps' favourites (the Pinned list). Give the place NAME as `query` — a bare coordinate does not identify a place to Maps — and latitude/longitude when known, which makes the match exact and skips the lookup. SIDE EFFECT: unless the place is already in the store, Maps is asked to resolve it and the place also appears in the user's Recents; there is no way to add a favourite without that. The favourite syncs to the user's other Apple devices through iCloud. Idempotent: asking twice for the same place returns the existing favourite rather than creating a second. Needs Full Disk Access.",
|
|
1973
|
+
inputSchema: {
|
|
1974
|
+
query: z.string().min(1).describe("The place's name, as you would type it into Maps' search field."),
|
|
1975
|
+
latitude: z.number().min(-90).max(90).optional().describe("Latitude, when known."),
|
|
1976
|
+
longitude: z.number().min(-180).max(180).optional().describe("Longitude, when known."),
|
|
1977
|
+
name: z.string().min(1).optional().describe("Label to save it under. Defaults to `query`.")
|
|
1978
|
+
},
|
|
1979
|
+
annotations: {
|
|
1980
|
+
readOnlyHint: false,
|
|
1981
|
+
idempotentHint: true
|
|
1982
|
+
}
|
|
1983
|
+
}, async ({ query, latitude, longitude, name }) => wrapResult(async () => {
|
|
1984
|
+
const result = client.writer().addFavorite({
|
|
1985
|
+
query,
|
|
1986
|
+
latitude,
|
|
1987
|
+
longitude,
|
|
1988
|
+
name
|
|
1989
|
+
});
|
|
1990
|
+
return ok(compact({
|
|
1991
|
+
favorite: {
|
|
1992
|
+
ref: `p1:f:${result.uuid.replaceAll("-", "")}`,
|
|
1993
|
+
name: result.name,
|
|
1994
|
+
latitude: result.latitude,
|
|
1995
|
+
longitude: result.longitude
|
|
1996
|
+
},
|
|
1997
|
+
created: result.created,
|
|
1998
|
+
alreadyExisted: result.created ? void 0 : "A favourite for this place already existed and was returned unchanged.",
|
|
1999
|
+
recentsNote: result.seeded ? "Maps was asked to resolve this place, so it now also appears in the user's Recents. That is how the place record is obtained and cannot be avoided." : void 0,
|
|
2000
|
+
syncNote: "This favourite will reach the user's other Apple devices through iCloud once Maps next runs."
|
|
2001
|
+
}));
|
|
2002
|
+
}));
|
|
2003
|
+
server.registerTool("apple_maps_remove_favorite", {
|
|
2004
|
+
description: "Remove a place from Maps' favourites, by a ref from apple_maps_list_favorites. This deletes the favourite on the user's other Apple devices too, through iCloud. It does not affect Guides, Recents, or anything else that references the same place. Needs Full Disk Access.",
|
|
2005
|
+
inputSchema: { ref: placeRefArg },
|
|
2006
|
+
annotations: {
|
|
2007
|
+
readOnlyHint: false,
|
|
2008
|
+
idempotentHint: true,
|
|
2009
|
+
destructiveHint: true
|
|
2010
|
+
}
|
|
2011
|
+
}, async ({ ref }) => wrapResult(async () => {
|
|
2012
|
+
const { kind, key } = decodePlaceRef(ref);
|
|
2013
|
+
if (kind !== "favorite") return fail(`That ref points at a ${kind === "history" ? "recent" : "collection entry"}, not a favourite. Only favourites can be removed; pass a ref from apple_maps_list_favorites.`);
|
|
2014
|
+
if (!client.writer().removeFavorite(key)) return fail("No favourite matched that ref. It may already have been removed, or the listing it came from may predate a change. Re-run apple_maps_list_favorites.");
|
|
2015
|
+
return ok({
|
|
2016
|
+
removed: true,
|
|
2017
|
+
syncNote: "The removal will reach the user's other Apple devices through iCloud once Maps next runs."
|
|
2018
|
+
});
|
|
2019
|
+
}));
|
|
2020
|
+
};
|
|
2021
|
+
//#endregion
|
|
2022
|
+
//#region src/tools/index.ts
|
|
2023
|
+
/**
|
|
2024
|
+
* Register the Apple Maps tools.
|
|
2025
|
+
*
|
|
2026
|
+
* The registered set does NOT vary with whether the store opened. That is a
|
|
2027
|
+
* runtime condition and MCP clients cache the tool list, so a list that shrank
|
|
2028
|
+
* without Full Disk Access would stay shrunk after the grant was given. Every
|
|
2029
|
+
* tool is registered on a machine with no grant at all; each fails with an
|
|
2030
|
+
* error that says which permission is missing.
|
|
2031
|
+
*/
|
|
2032
|
+
const registerTools = (server, client, ctx) => {
|
|
2033
|
+
registerDiagnosticsTools(server, client);
|
|
2034
|
+
registerPlaceTools(server, client);
|
|
2035
|
+
if (!ctx.allowWrites) return;
|
|
2036
|
+
registerWriteTools(server, client);
|
|
2037
|
+
};
|
|
2038
|
+
//#endregion
|
|
2039
|
+
//#region src/server.ts
|
|
2040
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
2041
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
2042
|
+
/**
|
|
2043
|
+
* Build the server. Side-effect free: it opens no database and reads no file,
|
|
2044
|
+
* so a test can construct it freely and every external dependency arrives
|
|
2045
|
+
* through an option.
|
|
2046
|
+
*
|
|
2047
|
+
* There is no `osascript` seam here, unlike every other surface. This server
|
|
2048
|
+
* never spawns one — Maps is not scriptable, so there is no Apple Events lane
|
|
2049
|
+
* to inject a fake for. Its absence is the point.
|
|
2050
|
+
*/
|
|
2051
|
+
const createServer = (opts) => {
|
|
2052
|
+
const { config } = opts;
|
|
2053
|
+
const server = new McpServer({
|
|
2054
|
+
name: SERVER_NAME,
|
|
2055
|
+
version: SERVER_VERSION
|
|
2056
|
+
});
|
|
2057
|
+
const client = new AppleMapsClient({
|
|
2058
|
+
config,
|
|
2059
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
2060
|
+
...opts.home ? { home: opts.home } : {}
|
|
2061
|
+
});
|
|
2062
|
+
registerTools(server, client, { allowWrites: config.allowWrites });
|
|
2063
|
+
if (config.exposePrompts) {
|
|
2064
|
+
registerPrompts(server);
|
|
2065
|
+
registerSurfaceResources(server, {
|
|
2066
|
+
surface: "maps",
|
|
2067
|
+
displayName: "Maps",
|
|
2068
|
+
guide: MAPS_GUIDE,
|
|
2069
|
+
diagnostics: () => buildDiagnostics(client)
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
return {
|
|
2073
|
+
server,
|
|
2074
|
+
client
|
|
2075
|
+
};
|
|
2076
|
+
};
|
|
2077
|
+
//#endregion
|
|
2078
|
+
export { fromStoreTime as A, MAPS_SURFACE as C, UndatableStoreError as D, SchemaDriftError$1 as E, resolveEpoch as M, BUILD_INFO as N, APPLE_SECONDS as O, MAPS_BUNDLE_ID as S, PlaceNotFoundError as T, defaultDirectory as _, loadConfig as a, AppleMapsError as b, introspect as c, InvalidMapsRefError as d, PLACE_REF_VERSION as f, encodePlaceRef as g, encodeCollectionRef as h, registerTools as i, renderInstant as j, CORE_DATA_EPOCH_OFFSET as k, openStore as l, decodePlaceRef as m, SERVER_VERSION as n, AppleMapsClient as o, decodeCollectionRef as p, createServer as r, MapsStore as s, SERVER_NAME as t, COLLECTION_REF_VERSION as u, defaultStorePath as v, MapsStoreUnavailableError as w, IndexUnavailableError as x, locateStore as y };
|
|
2079
|
+
|
|
2080
|
+
//# sourceMappingURL=server-DgIy0w0S.js.map
|