react-x11 2.8.3 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1415 @@
1
+ // The calendars the user's desktop already has — read, not owned.
2
+ //
3
+ // The point of this module is that an app gets the user's real events —
4
+ // iCloud, Google, Microsoft, CalDAV, local — without asking for a single
5
+ // credential and without an OAuth flow, because the desktop already did all
6
+ // of that. Every account in System Settings > Internet Accounts, every
7
+ // account in GNOME's Online Accounts, is already in a store this can read.
8
+ //
9
+ // docs/filedialog.md's ladder again, chosen in order and never by name:
10
+ //
11
+ // 1. **EventKit through the bridge** — the cocoa backend, where the app
12
+ // the tree renders through carries `calendars` (src/cocoa/calendar.js).
13
+ // One framework for every account, recurrences expanded by it, and one
14
+ // notification for any change.
15
+ // 2. **EventKit through `osascript`** — macOS with no bridge, which is
16
+ // every XQuartz install and every cocoa app over a bridge older than
17
+ // 0.8. A long-lived `osascript -l JavaScript` child holds an
18
+ // `EKEventStore` and answers JSON lines: the same framework, a slower
19
+ // transport, and the rung that shipped before the bridge did.
20
+ // 3. **EDS over the session bus** — `org.gnome.evolution.dataserver.*`,
21
+ // the store every GNOME calendar UI reads and the one GNOME Online
22
+ // Accounts feeds. Recurrences are expanded here with `ical.js`,
23
+ // because EDS answers with unexpanded masters.
24
+ // 4. **nothing** — `desktopCalendar()` answers `null`, which is an
25
+ // ordinary answer about a machine rather than a failure. Only
26
+ // `{ required: true }` turns it into a typed rejection.
27
+ //
28
+ // ## What the ladder had to agree on
29
+ //
30
+ // - **`end` is exclusive.** EventKit reports an all-day event ending at the
31
+ // last second of its last day and iCalendar ends it at the next midnight;
32
+ // an app cannot branch on which store it happens to be reading, so the
33
+ // macOS rungs normalise (`withExclusiveEnd`) and `byDay` may assume it.
34
+ // - **A change is "something moved", not a patch.** EDS names the objects,
35
+ // EventKit's notification names nothing at all, and a recurrence master
36
+ // edited three months away changes what today looks like either way. So
37
+ // `watch` reports a change and the answer is always to query again.
38
+ // - **"Not allowed to look" is not "no events".** The macOS rungs need the
39
+ // TCC grant; without it a read would answer an empty list, which is a lie.
40
+ // They ask on the first read and reject with {@link CalendarAccessError}
41
+ // when the answer is no, and `'denied'` (the user's answer) stays apart
42
+ // from `null` (the machine's).
43
+ //
44
+ // Nothing here opens a D-Bus connection of its own: `sessionBus()` hands
45
+ // over react-x11's shared one, so an app is one name on the bus however many
46
+ // features it turns on. Nothing here writes, either — issue #504's third
47
+ // milestone.
48
+
49
+ import { hasService } from './portal.js';
50
+ import { openPrivacySettings } from './permissions.js';
51
+ import { sessionBus } from './bus.js';
52
+ import { liveApps } from './trace-registry.js';
53
+
54
+ const SOURCES_NAME = 'org.gnome.evolution.dataserver.Sources5';
55
+ const SOURCES_PATH = '/org/gnome/evolution/dataserver/SourceManager';
56
+ const OBJECT_MANAGER = 'org.freedesktop.DBus.ObjectManager';
57
+ const FACTORY_NAME = 'org.gnome.evolution.dataserver.Calendar8';
58
+ const FACTORY_PATH = '/org/gnome/evolution/dataserver/CalendarFactory';
59
+ const FACTORY_IFACE = 'org.gnome.evolution.dataserver.CalendarFactory';
60
+ const CAL_IFACE = 'org.gnome.evolution.dataserver.Calendar';
61
+ const VIEW_IFACE = 'org.gnome.evolution.dataserver.CalendarView';
62
+
63
+ // --------------------------------------------------------------------------
64
+ // What "nothing here can answer" looks like
65
+ // --------------------------------------------------------------------------
66
+
67
+ /**
68
+ * No calendar service on this machine: no bridge, not a Mac, and no
69
+ * Evolution Data Server on the session bus.
70
+ *
71
+ * A **typed** rejection, the {@link NoFileDialogError} rule — a caller keeps
72
+ * the feature behind it rather than crashing. Only `{ required: true }`
73
+ * raises it; by default `desktopCalendar()` answers `null`, because a
74
+ * machine with no calendar service is an ordinary machine.
75
+ */
76
+ export class NoCalendarServiceError extends Error {
77
+ constructor(cause) {
78
+ super(
79
+ 'react-x11: no desktop calendar here — this backend has no EventKit ' +
80
+ 'bridge, this is not macOS, and there is no Evolution Data Server ' +
81
+ 'on the session bus. Call desktopCalendar() without `required` and ' +
82
+ 'branch on null, or calendarBackend() to ask first.',
83
+ { cause },
84
+ );
85
+ this.name = 'NoCalendarServiceError';
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The user has not allowed this app to read their calendars.
91
+ *
92
+ * `status` is the permission vocabulary (docs/permissions.md): `'denied'`,
93
+ * `'restricted'` (MDM or parental controls), `'write-only'` (macOS 14's
94
+ * partial grant — a real grant to a writer, a refusal to a reader), or
95
+ * `'prompt'` where the request was made and nothing came back, which is TCC
96
+ * declining to ask rather than the user declining to allow. `'unknown'` is
97
+ * not among them: where nothing can say, the read is allowed to try and
98
+ * whatever it hits is the answer.
99
+ *
100
+ * Separate from {@link NoCalendarServiceError} on purpose: this one is
101
+ * about a decision, and a decision can be changed —
102
+ * `openPrivacySettings('calendars')` puts the user in front of the switch.
103
+ */
104
+ export class CalendarAccessError extends Error {
105
+ constructor(status, cause) {
106
+ super(
107
+ `react-x11: this app may not read the desktop's calendars (${status}). ` +
108
+ 'On macOS that is the Calendars privacy setting: openPrivacySettings' +
109
+ "('calendars') opens the pane. Read cal.access() to branch before " +
110
+ 'asking.',
111
+ { cause },
112
+ );
113
+ this.name = 'CalendarAccessError';
114
+ this.status = status;
115
+ }
116
+ }
117
+
118
+ // --------------------------------------------------------------------------
119
+ // One shape, whichever rung answered
120
+ // --------------------------------------------------------------------------
121
+
122
+ const pad = (n) => String(n).padStart(2, '0');
123
+
124
+ /**
125
+ * The local calendar day a `Date` falls on, as `'YYYY-MM-DD'`.
126
+ *
127
+ * The **format** is the contract between this and a calendar grid — these
128
+ * keys index straight into `<Calendar dayContent>` in
129
+ * `@react-x11/components` — so it is local time, not UTC: a grid draws the
130
+ * user's days.
131
+ */
132
+ export function dayKey(date) {
133
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
134
+ }
135
+
136
+ /**
137
+ * Occurrences grouped by the local day they appear on — which is what a
138
+ * calendar grid actually renders.
139
+ *
140
+ * ```js
141
+ * const days = byDay(events);
142
+ * days.get(dayKey(new Date())); // today's
143
+ * ```
144
+ *
145
+ * An all-day event spans `[start, end)` across possibly several days and a
146
+ * timed one can cross midnight, so an event lands under **every** day it
147
+ * touches rather than only under its start.
148
+ */
149
+ export function byDay(events) {
150
+ const days = new Map();
151
+ for (const event of events) {
152
+ // `end` is exclusive, so a one-day all-day event ending at the next
153
+ // midnight must not also mark the next day.
154
+ const lastMs = Math.max(event.start.getTime(), event.end.getTime() - 1);
155
+ const last = new Date(lastMs);
156
+ const cursor = new Date(
157
+ event.start.getFullYear(),
158
+ event.start.getMonth(),
159
+ event.start.getDate(),
160
+ );
161
+ while (cursor <= last) {
162
+ const key = dayKey(cursor);
163
+ const list = days.get(key);
164
+ if (list) list.push(event);
165
+ else days.set(key, [event]);
166
+ cursor.setDate(cursor.getDate() + 1);
167
+ }
168
+ }
169
+ return days;
170
+ }
171
+
172
+ /** By start, ascending. Every rung answers sorted, across its calendars. */
173
+ export function sortEvents(events) {
174
+ return events.sort((a, b) => a.start.getTime() - b.start.getTime());
175
+ }
176
+
177
+ /**
178
+ * An all-day end as the exclusive one the rest of the ladder means.
179
+ *
180
+ * EventKit reports an all-day event as ending at 23:59:59 of its last day;
181
+ * iCalendar (so EDS) ends it at the next midnight. `byDay` and every caller
182
+ * that subtracts to get a duration read `end` as exclusive, so the macOS
183
+ * rungs round the reported end up to the next local midnight — and an end
184
+ * that is *already* midnight is already exclusive and must not gain a day.
185
+ */
186
+ export function withExclusiveEnd(end, allDay) {
187
+ if (!allDay || !(end instanceof Date) || Number.isNaN(end.getTime())) {
188
+ return end;
189
+ }
190
+ const atMidnight =
191
+ end.getHours() === 0 &&
192
+ end.getMinutes() === 0 &&
193
+ end.getSeconds() === 0 &&
194
+ end.getMilliseconds() === 0;
195
+ if (atMidnight) return end;
196
+ return new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1);
197
+ }
198
+
199
+ /** `[r, g, b, a]` in 0..1, as EventKit gives a calendar's colour, to the
200
+ * `'#rrggbb'` the EDS rung reads out of a keyfile. Alpha is dropped: a
201
+ * calendar colour is opaque, and a marker drawn in it should be too. */
202
+ export function hexFromComponents(color) {
203
+ if (!Array.isArray(color) || color.length < 3) return undefined;
204
+ const channel = (v) =>
205
+ Math.max(0, Math.min(255, Math.round(Number(v) * 255)))
206
+ .toString(16)
207
+ .padStart(2, '0');
208
+ return `#${channel(color[0])}${channel(color[1])}${channel(color[2])}`;
209
+ }
210
+
211
+ /** EventKit's `EKCalendarType` word as the lower-case one EDS's keyfile
212
+ * uses for the same thing — `caldav`, `local`, `exchange`. */
213
+ export function calendarBackendName(type) {
214
+ return typeof type === 'string' && type ? type.toLowerCase() : undefined;
215
+ }
216
+
217
+ /**
218
+ * EventKit's event predicate spans at most four years, so a longer range is
219
+ * asked for in pieces. 1400 days is comfortably inside that limit and a
220
+ * whole number of days, so a chunk boundary never lands mid-event for an
221
+ * all-day one.
222
+ */
223
+ const MAX_SPAN_MS = 1400 * 24 * 60 * 60 * 1000;
224
+
225
+ /** `[[startMs, endMs], …]`, each at most {@link MAX_SPAN_MS}. */
226
+ export function chunkSpan(from, to) {
227
+ const start = from instanceof Date ? from.getTime() : Number(from);
228
+ const end = to instanceof Date ? to.getTime() : Number(to);
229
+ if (!(end > start)) return [[start, end]];
230
+ const chunks = [];
231
+ for (let at = start; at < end; at += MAX_SPAN_MS) {
232
+ chunks.push([at, Math.min(at + MAX_SPAN_MS, end)]);
233
+ }
234
+ return chunks;
235
+ }
236
+
237
+ // --------------------------------------------------------------------------
238
+ // Rung 3: Evolution Data Server, over the session bus
239
+ // --------------------------------------------------------------------------
240
+
241
+ /**
242
+ * `E_CAL_CLIENT_VIEW_FLAGS_NONE`. A view is created with `NOTIFY_INITIAL`
243
+ * instead, which makes `Start()` replay everything already matching the
244
+ * query as `ObjectsAdded` — the current contents, announced as if they had
245
+ * just changed.
246
+ *
247
+ * That is the wrong signal for a watcher whose answer to a change is to run
248
+ * the query again: the re-query tears the view down, starts a new one, and
249
+ * is told the same thing again, for as long as the app is open. Flags of
250
+ * NONE is the whole of "only tell me what changes from here".
251
+ */
252
+ const VIEW_FLAGS_NONE = 0;
253
+
254
+ /** `20260807T113000Z` — what the S-expression query wants. */
255
+ function icalStamp(d) {
256
+ return (
257
+ `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T` +
258
+ `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
259
+ );
260
+ }
261
+
262
+ /** The query EDS understands for "anything happening between these two". */
263
+ function rangeQuery(from, to) {
264
+ return `(occur-in-time-range? (make-time "${icalStamp(from)}") (make-time "${icalStamp(to)}"))`;
265
+ }
266
+
267
+ /**
268
+ * A source's whole configuration is an ini-style keyfile carried in one
269
+ * D-Bus property, so it has to be parsed to find out whether a source is
270
+ * even a calendar (as opposed to an address book, a mail account or a task
271
+ * list).
272
+ */
273
+ export function parseKeyFile(text) {
274
+ const out = {};
275
+ let section = null;
276
+ for (const raw of text.split('\n')) {
277
+ const line = raw.trim();
278
+ if (!line || line.startsWith('#')) continue;
279
+ if (line.startsWith('[') && line.endsWith(']')) {
280
+ section = line.slice(1, -1);
281
+ out[section] = out[section] ?? {};
282
+ continue;
283
+ }
284
+ if (!section) continue;
285
+ const eq = line.indexOf('=');
286
+ if (eq === -1) continue;
287
+ const key = line.slice(0, eq).trim();
288
+ if (key.includes('[')) continue; // a DisplayName[ru] translation
289
+ out[section][key] = line.slice(eq + 1).trim();
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /**
295
+ * `ical.js`, loaded once and lazily.
296
+ *
297
+ * A **regular dependency** behind a static specifier: 268 KB of the 1.2 MB
298
+ * package is what loads, it has no dependencies of its own and no native
299
+ * code, and a bundler or a single-executable build can follow a static
300
+ * specifier where it cannot follow the run-time-built one an optional
301
+ * dependency needs. The `import()` keeps it off the startup path — an app
302
+ * that never opens a calendar never parses it — and this rung is the only
303
+ * caller: EventKit expands its own recurrences.
304
+ */
305
+ let icalModule = null;
306
+ async function loadIcal() {
307
+ if (!icalModule) {
308
+ icalModule = import('ical.js').then((mod) => {
309
+ // CJS-with-a-default under some resolutions, a namespace under others.
310
+ const resolved = typeof mod.parse === 'function' ? mod : mod.default;
311
+ if (!resolved || typeof resolved.parse !== 'function') {
312
+ icalModule = null;
313
+ throw new TypeError('ical.js did not export what was expected');
314
+ }
315
+ return resolved;
316
+ });
317
+ }
318
+ return icalModule;
319
+ }
320
+
321
+ /**
322
+ * The desktop's calendars, over a bus someone else owns.
323
+ *
324
+ * Two services are involved: `Sources5` is the registry (which calendars
325
+ * exist, their names and colours) and `Calendar8` the store (open a
326
+ * calendar, query a range, watch it). Nothing here closes the connection,
327
+ * because nothing here opened it.
328
+ */
329
+ export class EdsCalendars {
330
+ constructor(bus) {
331
+ this.backend = 'eds';
332
+ this._bus = bus;
333
+ this._open = new Map();
334
+ this._views = [];
335
+ this._timezones = new Set();
336
+ }
337
+
338
+ /** EDS is reachable or it is not; there is no grant to ask for. */
339
+ async access() {
340
+ return 'granted';
341
+ }
342
+
343
+ async requestAccess() {
344
+ return 'granted';
345
+ }
346
+
347
+ /** Every calendar the desktop knows about, colour included. */
348
+ async listCalendars() {
349
+ const om = await this._bus
350
+ .getService(SOURCES_NAME)
351
+ .getInterface(SOURCES_PATH, OBJECT_MANAGER);
352
+ const managed = await om.GetManagedObjects();
353
+
354
+ const calendars = [];
355
+ for (const interfaces of Object.values(managed)) {
356
+ const src = interfaces['org.gnome.evolution.dataserver.Source'];
357
+ if (!src) continue;
358
+ const cfg = parseKeyFile(String(src.Data ?? ''));
359
+ // no [Calendar] section means an address book, a task list or mail
360
+ if (!cfg.Calendar) continue;
361
+ const ds = cfg['Data Source'] ?? {};
362
+ calendars.push({
363
+ uid: String(src.UID ?? ''),
364
+ name: ds.DisplayName ?? '',
365
+ enabled: ds.Enabled !== 'false',
366
+ color: cfg.Calendar.Color,
367
+ backend: cfg.Calendar.BackendName,
368
+ readOnly: !interfaces['org.gnome.evolution.dataserver.Source.Writable'],
369
+ account: cfg['GNOME Online Accounts']?.Account,
370
+ });
371
+ }
372
+ return calendars;
373
+ }
374
+
375
+ async _openCalendar(uid) {
376
+ const existing = this._open.get(uid);
377
+ if (existing) return existing;
378
+
379
+ const factory = await this._bus
380
+ .getService(FACTORY_NAME)
381
+ .getInterface(FACTORY_PATH, FACTORY_IFACE);
382
+ const [path, busName] = await factory.OpenCalendar(uid);
383
+ const cal = await this._bus
384
+ .getService(busName)
385
+ .getInterface(path, CAL_IFACE);
386
+ await cal.Open();
387
+
388
+ const entry = { cal, busName };
389
+ this._open.set(uid, entry);
390
+ return entry;
391
+ }
392
+
393
+ /**
394
+ * Events reference timezones by id, but the payload rarely carries the
395
+ * matching VTIMEZONE — so definitions are fetched on demand and registered
396
+ * with ical.js. A backend that has none leaves the time floating, which is
397
+ * ical.js's own fallback and better than refusing the event.
398
+ */
399
+ async _ensureTimezone(ICAL, cal, tzid) {
400
+ if (!tzid || this._timezones.has(tzid) || ICAL.TimezoneService.has(tzid)) {
401
+ return;
402
+ }
403
+ this._timezones.add(tzid);
404
+ try {
405
+ const vtz = await cal.GetTimezone(tzid);
406
+ const comp = new ICAL.Component(ICAL.parse(vtz));
407
+ const sub =
408
+ comp.name === 'vtimezone'
409
+ ? comp
410
+ : comp.getFirstSubcomponent('vtimezone');
411
+ if (sub) ICAL.TimezoneService.register(tzid, new ICAL.Timezone(sub));
412
+ } catch {
413
+ /* no definition for it; ical.js falls back to floating time */
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Expanded occurrences between two `Date`s, across the calendars given (or
419
+ * every enabled one). Sorted by start.
420
+ *
421
+ * A backend that is offline or broken contributes an entry in `errors`
422
+ * rather than throwing: one unreachable CalDAV server must not blank a
423
+ * whole month of the user's own local calendar.
424
+ */
425
+ async eventsBetween(from, to, options = {}) {
426
+ const ICAL = await loadIcal();
427
+ const list =
428
+ options.calendars ??
429
+ (await this.listCalendars()).filter((c) => c.enabled);
430
+ const query = rangeQuery(from, to);
431
+
432
+ const perCalendar = await Promise.all(
433
+ list.map(async (meta) => {
434
+ try {
435
+ const { cal } = await this._openCalendar(meta.uid);
436
+ const objects = await cal.GetObjectList(query);
437
+ return {
438
+ events: await this._expand(ICAL, cal, objects, from, to, meta),
439
+ error: null,
440
+ };
441
+ } catch (err) {
442
+ return {
443
+ events: [],
444
+ error: {
445
+ calendar: meta,
446
+ message: err instanceof Error ? err.message : String(err),
447
+ },
448
+ };
449
+ }
450
+ }),
451
+ );
452
+
453
+ const events = [];
454
+ const errors = [];
455
+ for (const chunk of perCalendar) {
456
+ events.push(...chunk.events);
457
+ if (chunk.error) errors.push(chunk.error);
458
+ }
459
+ return { events: sortEvents(events), errors };
460
+ }
461
+
462
+ async _expand(ICAL, cal, objects, from, to, meta) {
463
+ const out = [];
464
+ for (const ics of objects) {
465
+ let comp;
466
+ try {
467
+ comp = new ICAL.Component(ICAL.parse(ics));
468
+ } catch {
469
+ continue; // one unparseable object is not the range's problem
470
+ }
471
+
472
+ // Register any VTIMEZONE shipped inline, then any referenced by id.
473
+ for (const vtz of comp.getAllSubcomponents('vtimezone')) {
474
+ const tz = new ICAL.Timezone(vtz);
475
+ if (!ICAL.TimezoneService.has(tz.tzid)) {
476
+ ICAL.TimezoneService.register(tz.tzid, tz);
477
+ }
478
+ }
479
+
480
+ const vevents =
481
+ comp.name === 'vevent' ? [comp] : comp.getAllSubcomponents('vevent');
482
+ for (const ve of vevents) {
483
+ for (const prop of ['dtstart', 'dtend']) {
484
+ const p = ve.getFirstProperty(prop);
485
+ if (p) await this._ensureTimezone(ICAL, cal, p.getParameter('tzid'));
486
+ }
487
+ const event = new ICAL.Event(ve);
488
+ // folded in by the recurrence iterator below
489
+ if (event.isRecurrenceException()) continue;
490
+
491
+ const push = (startTime, endTime) => {
492
+ const start = startTime.toJSDate();
493
+ const end = endTime ? endTime.toJSDate() : start;
494
+ if (end < from || start >= to) return;
495
+ out.push({
496
+ uid: event.uid,
497
+ summary: event.summary || '',
498
+ location: event.location || undefined,
499
+ description: event.description || undefined,
500
+ start,
501
+ end,
502
+ allDay: event.startDate.isDate,
503
+ recurring: event.isRecurring(),
504
+ calendar: { uid: meta.uid, name: meta.name, color: meta.color },
505
+ });
506
+ };
507
+
508
+ if (!event.isRecurring()) {
509
+ push(event.startDate, event.endDate);
510
+ continue;
511
+ }
512
+
513
+ const duration = event.duration;
514
+ const it = event.iterator();
515
+ let next;
516
+ // A malformed RRULE with no UNTIL and a tiny interval can iterate
517
+ // forever; the range check below normally ends it, and this is the
518
+ // backstop for the case where it cannot.
519
+ let guard = 0;
520
+ while ((next = it.next()) && guard++ < 2000) {
521
+ if (next.toJSDate() >= to) break;
522
+ const end = next.clone();
523
+ end.addDuration(duration);
524
+ if (end.toJSDate() < from) continue;
525
+ const details = event.getOccurrenceDetails(next);
526
+ push(details.startDate, details.endDate);
527
+ }
528
+ }
529
+ }
530
+ return out;
531
+ }
532
+
533
+ /**
534
+ * Live updates: `onChange` fires whenever anything in the range moves.
535
+ *
536
+ * **Changes only.** What is in the range already is what `eventsBetween`
537
+ * answered; `onChange` fires for what happens after that. See
538
+ * {@link VIEW_FLAGS_NONE} for the loop that reporting the initial contents
539
+ * as a change costs a caller who re-queries on one.
540
+ */
541
+ async watch(from, to, onChange, options = {}) {
542
+ const list =
543
+ options.calendars ??
544
+ (await this.listCalendars()).filter((c) => c.enabled);
545
+ const query = rangeQuery(from, to);
546
+ const started = [];
547
+
548
+ for (const meta of list) {
549
+ try {
550
+ const { cal, busName } = await this._openCalendar(meta.uid);
551
+ const viewPath = await cal.GetView(query);
552
+ const view = await this._bus
553
+ .getService(busName)
554
+ .getInterface(viewPath, VIEW_IFACE);
555
+
556
+ // Belt to `VIEW_FLAGS_NONE`'s braces, for a backend that would not
557
+ // take the flags: `Complete` closes the initial delivery, so anything
558
+ // before it is the replay rather than a change. Only armed when
559
+ // `SetFlags` failed — a view that never reports `Complete` would
560
+ // otherwise be watched in silence, and losing live updates is the
561
+ // worse half of the trade.
562
+ let replaying = false;
563
+ try {
564
+ await view.SetFlags(VIEW_FLAGS_NONE);
565
+ } catch {
566
+ replaying = true;
567
+ }
568
+ await view.$subscribe('Complete', () => {
569
+ replaying = false;
570
+ });
571
+
572
+ for (const signal of [
573
+ 'ObjectsAdded',
574
+ 'ObjectsModified',
575
+ 'ObjectsRemoved',
576
+ ]) {
577
+ await view.$subscribe(signal, (objects) => {
578
+ if (replaying) return;
579
+ onChange({
580
+ calendar: meta,
581
+ kind: signal,
582
+ count: Array.isArray(objects) ? objects.length : 0,
583
+ });
584
+ });
585
+ }
586
+ await view.Start();
587
+ started.push(view);
588
+ this._views.push(view);
589
+ } catch {
590
+ /* skip a calendar that will not open; the others still update */
591
+ }
592
+ }
593
+
594
+ return async () => {
595
+ for (const view of started) {
596
+ this._views = this._views.filter((v) => v !== view);
597
+ await stopView(view);
598
+ }
599
+ };
600
+ }
601
+
602
+ /** Stop every view this instance started. Does **not** touch the bus. */
603
+ async close() {
604
+ const views = this._views;
605
+ this._views = [];
606
+ for (const view of views) await stopView(view);
607
+ this._open.clear();
608
+ }
609
+ }
610
+
611
+ async function stopView(view) {
612
+ try {
613
+ await view.Stop();
614
+ await view.Dispose();
615
+ } catch {
616
+ /* already gone */
617
+ }
618
+ }
619
+
620
+ // --------------------------------------------------------------------------
621
+ // Rung 2: EventKit through an `osascript` child
622
+ // --------------------------------------------------------------------------
623
+
624
+ /**
625
+ * One JXA program: hold an `EKEventStore`, answer JSON lines on stdin with
626
+ * JSON lines on stdout, and push one more whenever macOS says the store
627
+ * changed.
628
+ *
629
+ * **Why a child at all.** EventKit is a framework, not a service: there is
630
+ * no bus to call and no command-line tool that answers this. `osascript -l
631
+ * JavaScript` is the interpreter every Mac has that can hold framework
632
+ * objects, and JXA passes a JS function where the framework wants a block —
633
+ * which is what makes the completion handler and the change observer
634
+ * possible at all.
635
+ *
636
+ * **Why long-lived.** A process per query would pay ~450 ms of interpreter
637
+ * and framework start-up each time, and — more to the point — could not
638
+ * observe a change, because the observer only fires while a run loop is
639
+ * running. One child per process, ref-counted, is the same bargain
640
+ * `sessionBus()` makes.
641
+ *
642
+ * **What it writes and where.** `console.log` in JXA has gone to stderr in
643
+ * some macOS releases and stdout in others, so replies are written to
644
+ * stdout's file handle directly and stderr is left for the noise AppKit
645
+ * prints into every process that touches it.
646
+ *
647
+ * **It leaves when its parent has.** Eight orphaned appearance watchers were
648
+ * once found on one machine, days old, reparented to launchd by apps that
649
+ * died without running an exit handler; the same 5-second `getppid()` check
650
+ * is here for the same reason.
651
+ *
652
+ * Exported so a test can pin the source; it cannot be executed off a Mac.
653
+ */
654
+ export const MACOS_CALENDAR_PROGRAM = `
655
+ ObjC.import('EventKit');
656
+ ObjC.import('AppKit');
657
+ ObjC.import('stdlib');
658
+ ObjC.import('unistd');
659
+
660
+ var stdout = $.NSFileHandle.fileHandleWithStandardOutput;
661
+ function emit(o) {
662
+ var s = JSON.stringify(o) + '\\n';
663
+ stdout.writeData($.NSString.alloc.initWithUTF8String(s)
664
+ .dataUsingEncoding($.NSUTF8StringEncoding));
665
+ }
666
+ // A nil ObjC object is callable in JXA rather than null, and an unset
667
+ // property on an unsaved object is a plain undefined, so both are checked.
668
+ function str(v) {
669
+ try {
670
+ if (v === undefined || v === null) return null;
671
+ if (typeof v === 'string') return v;
672
+ if (typeof v.isNil === 'function' && v.isNil()) return null;
673
+ var s = ObjC.unwrap(v);
674
+ return typeof s === 'string' ? s : null;
675
+ } catch (e) { return null; }
676
+ }
677
+ function ms(d) {
678
+ try {
679
+ if (!d || (typeof d.isNil === 'function' && d.isNil())) return null;
680
+ return Math.round(Number(d.timeIntervalSince1970) * 1000);
681
+ } catch (e) { return null; }
682
+ }
683
+
684
+ // EKAuthorizationStatus, as react-x11's permission vocabulary. 3 is
685
+ // authorized before macOS 14 and fullAccess after it; 4 is the write-only
686
+ // grant macOS 14 added, which is a refusal to a reader and its own word.
687
+ var STATUS = ['prompt', 'restricted', 'denied', 'granted', 'write-only'];
688
+ function statusWord() {
689
+ var n = Number($.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent));
690
+ return STATUS[n] || 'unknown';
691
+ }
692
+
693
+ var store = null;
694
+ function eventStore() {
695
+ // Creating the store never prompts; only a request does.
696
+ if (store === null) store = $.EKEventStore.alloc.init;
697
+ return store;
698
+ }
699
+
700
+ var TYPES = ['local', 'calDAV', 'exchange', 'subscription', 'birthday'];
701
+ function colorOf(cal) {
702
+ try {
703
+ var c = cal.color;
704
+ if (!c || (typeof c.isNil === 'function' && c.isNil())) return null;
705
+ var s = c.colorUsingColorSpace($.NSColorSpace.sRGBColorSpace);
706
+ if (!s || (typeof s.isNil === 'function' && s.isNil())) return null;
707
+ return [Number(s.redComponent), Number(s.greenComponent),
708
+ Number(s.blueComponent), Number(s.alphaComponent)];
709
+ } catch (e) { return null; }
710
+ }
711
+ function calendarObjects() {
712
+ return eventStore().calendarsForEntityType($.EKEntityTypeEvent);
713
+ }
714
+ function listCalendars() {
715
+ var cals = calendarObjects();
716
+ var out = [];
717
+ for (var i = 0; i < Number(cals.count); i++) {
718
+ var c = cals.objectAtIndex(i);
719
+ var source = null;
720
+ try {
721
+ if (c.source && !(typeof c.source.isNil === 'function' && c.source.isNil())) {
722
+ source = { id: str(c.source.sourceIdentifier), title: str(c.source.title) };
723
+ }
724
+ } catch (e) {}
725
+ out.push({
726
+ id: str(c.calendarIdentifier),
727
+ title: str(c.title),
728
+ color: colorOf(c),
729
+ type: TYPES[Number(c.type)] || null,
730
+ source: source,
731
+ allowsModifications: !!c.allowsContentModifications,
732
+ immutable: !!c.immutable,
733
+ subscribed: !!c.subscribed
734
+ });
735
+ }
736
+ return out;
737
+ }
738
+ function eventsBetween(startMs, endMs, ids) {
739
+ var filter = $();
740
+ if (ids && ids.length) {
741
+ var wanted = {};
742
+ for (var k = 0; k < ids.length; k++) wanted[ids[k]] = true;
743
+ var all = calendarObjects();
744
+ var picked = $.NSMutableArray.alloc.init;
745
+ for (var i = 0; i < Number(all.count); i++) {
746
+ var c = all.objectAtIndex(i);
747
+ if (wanted[str(c.calendarIdentifier)]) picked.addObject(c);
748
+ }
749
+ // An empty selection means "these calendars, of which none are here" —
750
+ // not "every calendar", which is what a nil filter means.
751
+ if (Number(picked.count) === 0) return [];
752
+ filter = picked;
753
+ }
754
+ var pred = eventStore().predicateForEventsWithStartDateEndDateCalendars(
755
+ $.NSDate.dateWithTimeIntervalSince1970(startMs / 1000),
756
+ $.NSDate.dateWithTimeIntervalSince1970(endMs / 1000), filter);
757
+ var evs = eventStore().eventsMatchingPredicate(pred);
758
+ var out = [];
759
+ if (!evs || (typeof evs.isNil === 'function' && evs.isNil())) return out;
760
+ for (var j = 0; j < Number(evs.count); j++) {
761
+ var e = evs.objectAtIndex(j);
762
+ var cal = null;
763
+ try { cal = str(e.calendar.calendarIdentifier); } catch (err) {}
764
+ out.push({
765
+ id: str(e.eventIdentifier),
766
+ calendar: cal,
767
+ title: str(e.title),
768
+ location: str(e.location),
769
+ notes: str(e.notes),
770
+ start: ms(e.startDate),
771
+ end: ms(e.endDate),
772
+ allDay: !!e.allDay,
773
+ recurring: !!e.hasRecurrenceRules
774
+ });
775
+ }
776
+ return out;
777
+ }
778
+
779
+ var watching = false;
780
+ function watch() {
781
+ if (watching) return true;
782
+ watching = true;
783
+ $.NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
784
+ 'EKEventStoreChangedNotification', eventStore(),
785
+ $.NSOperationQueue.mainQueue, function () { emit({ event: 'changed' }); });
786
+ return true;
787
+ }
788
+
789
+ function request(id) {
790
+ var s = eventStore();
791
+ var answered = false;
792
+ var reply = function () {
793
+ if (answered) return;
794
+ answered = true;
795
+ emit({ id: id, ok: statusWord() });
796
+ };
797
+ // The completion is asked for and answered on if it ever comes — but it is
798
+ // never waited on. **Measured on macOS 15.2: an osascript process is never
799
+ // called back**, not even when the grant is already held, so a rung that
800
+ // waited for it would hang forever on its first read. The status is what
801
+ // the caller wanted anyway, and it is the one thing that cannot be lost.
802
+ try {
803
+ // macOS 14 split the one request in two; before it there was only the
804
+ // entity-type form.
805
+ if (typeof s.requestFullAccessToEventsWithCompletion === 'function') {
806
+ s.requestFullAccessToEventsWithCompletion(reply);
807
+ } else {
808
+ s.requestAccessToEntityTypeCompletion($.EKEntityTypeEvent, reply);
809
+ }
810
+ } catch (e) {
811
+ // whatever the framework refused, the poll below still answers
812
+ }
813
+ // So the answer is the status, watched until it stops being undecided.
814
+ // The deadline is for the case that has no dialog at all: TCC declines to
815
+ // ask for a platform binary, and for a responsible app whose hardened
816
+ // runtime lacks com.apple.security.personal-information.calendars — and
817
+ // then nothing will ever change. 'prompt' is the honest answer there, and
818
+ // the JS side does not remember it, so a later read asks again.
819
+ var waited = 0;
820
+ $.NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(0.25, true,
821
+ function (timer) {
822
+ waited += 0.25;
823
+ if (answered) { timer.invalidate; return; }
824
+ if (statusWord() !== 'prompt' || waited >= 30) {
825
+ timer.invalidate;
826
+ reply();
827
+ }
828
+ });
829
+ }
830
+
831
+ function handle(msg) {
832
+ switch (msg.op) {
833
+ case 'status': return statusWord();
834
+ case 'calendars': return listCalendars();
835
+ case 'events': return eventsBetween(msg.start, msg.end, msg.calendars);
836
+ case 'watch': return watch();
837
+ case 'ping': return 'pong';
838
+ default: throw new Error('unknown op ' + msg.op);
839
+ }
840
+ }
841
+
842
+ var stdin = $.NSFileHandle.fileHandleWithStandardInput;
843
+ var buffered = '';
844
+ $.NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
845
+ $.NSFileHandleDataAvailableNotification, stdin, $.NSOperationQueue.mainQueue,
846
+ function () {
847
+ var data = stdin.availableData;
848
+ // Zero bytes is end of file: the parent closed the pipe, or died.
849
+ if (!data || Number(data.length) === 0) $.exit(0);
850
+ buffered += ObjC.unwrap($.NSString.alloc.initWithDataEncoding(
851
+ data, $.NSUTF8StringEncoding));
852
+ var at;
853
+ while ((at = buffered.indexOf('\\n')) !== -1) {
854
+ var line = buffered.slice(0, at);
855
+ buffered = buffered.slice(at + 1);
856
+ if (!line.trim()) continue;
857
+ var msg;
858
+ try { msg = JSON.parse(line); } catch (e) { continue; }
859
+ if (msg.op === 'quit') $.exit(0);
860
+ if (msg.op === 'request') { request(msg.id); continue; }
861
+ try {
862
+ emit({ id: msg.id, ok: handle(msg) });
863
+ } catch (e) {
864
+ emit({ id: msg.id, error: String(e && e.message ? e.message : e) });
865
+ }
866
+ }
867
+ stdin.waitForDataInBackgroundAndNotify;
868
+ });
869
+ stdin.waitForDataInBackgroundAndNotify;
870
+
871
+ emit({ ready: true, status: statusWord() });
872
+
873
+ // An app that dies without its exit handler (a signal, a crash) leaves this
874
+ // process behind, reparented to launchd, for as long as the machine is up.
875
+ $.NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(5, true, function () {
876
+ if ($.getppid() === 1) $.exit(0);
877
+ });
878
+ $.NSRunLoop.currentRunLoop.run();
879
+ `;
880
+
881
+ /** How long to wait for the child's first line before giving up on it. */
882
+ const CHILD_READY_TIMEOUT_MS = 10_000;
883
+
884
+ /**
885
+ * Test seam, not public: what spawns the child. A fake here also lets the
886
+ * rung run off a Mac, where the real one cannot, so the protocol is tested
887
+ * on CI rather than on whoever has a Mac.
888
+ */
889
+ let spawnChild = null;
890
+ export function _setCalendarSpawnForTests(fn) {
891
+ _closeCalendarChild();
892
+ spawnChild = fn;
893
+ }
894
+
895
+ /**
896
+ * The `osascript` child, and the request/reply protocol over its pipes.
897
+ *
898
+ * One per process, ref-counted: several hooks in one app share a store the
899
+ * way they share a bus connection. A reply is matched by id; a line with no
900
+ * id is a push (the store changed).
901
+ */
902
+ class CalendarChild {
903
+ constructor(proc) {
904
+ this.proc = proc;
905
+ this.seq = 0;
906
+ this.refs = 0;
907
+ this.pending = new Map();
908
+ this.watchers = new Set();
909
+ this.exited = null;
910
+ this._buffered = '';
911
+ this._hello = null;
912
+
913
+ // The program's first line is `{ ready: true, status }`, so "it started"
914
+ // and "the framework answered" are the same event: a child that spawns
915
+ // and then fails inside osascript never says hello, and this rung stands
916
+ // down rather than hanging on a pipe.
917
+ this.ready = new Promise((resolve, reject) => {
918
+ this._hello = { resolve, reject };
919
+ });
920
+ // Nothing may await this before `acquireChild` does; an unhandled
921
+ // rejection here would be reported against a promise nobody asked for.
922
+ this.ready.catch(() => {});
923
+
924
+ proc.stdout?.setEncoding?.('utf8');
925
+ proc.stdout?.on('data', (chunk) => this._onData(chunk));
926
+ proc.on('error', (err) => this._die(err));
927
+ proc.on('exit', () => this._die(new Error('the calendar helper exited')));
928
+ }
929
+
930
+ _onData(chunk) {
931
+ this._buffered += chunk;
932
+ let at;
933
+ while ((at = this._buffered.indexOf('\n')) !== -1) {
934
+ const line = this._buffered.slice(0, at).trim();
935
+ this._buffered = this._buffered.slice(at + 1);
936
+ if (!line) continue;
937
+ let msg;
938
+ try {
939
+ msg = JSON.parse(line);
940
+ } catch {
941
+ continue; // noise from a framework that logs into every process
942
+ }
943
+ if (msg.ready) {
944
+ this._hello.resolve(msg);
945
+ continue;
946
+ }
947
+ if (msg.event === 'changed') {
948
+ for (const fn of [...this.watchers]) {
949
+ try {
950
+ fn();
951
+ } catch {
952
+ /* one watcher's failure is not another's */
953
+ }
954
+ }
955
+ continue;
956
+ }
957
+ const entry = this.pending.get(msg.id);
958
+ if (!entry) continue;
959
+ this.pending.delete(msg.id);
960
+ if (msg.error) entry.reject(new Error(msg.error));
961
+ else entry.resolve(msg.ok);
962
+ }
963
+ }
964
+
965
+ _die(err) {
966
+ if (this.exited) return;
967
+ this.exited = err;
968
+ this._hello.reject(err);
969
+ for (const entry of this.pending.values()) entry.reject(err);
970
+ this.pending.clear();
971
+ this.watchers.clear();
972
+ if (shared === this) shared = null;
973
+ // A child that never said hello is still running; a child that exited
974
+ // does not mind being killed again.
975
+ this.proc.kill?.();
976
+ }
977
+
978
+ send(message) {
979
+ if (this.exited) return Promise.reject(this.exited);
980
+ const id = ++this.seq;
981
+ return new Promise((resolve, reject) => {
982
+ this.pending.set(id, { resolve, reject });
983
+ try {
984
+ this.proc.stdin.write(`${JSON.stringify({ ...message, id })}\n`);
985
+ } catch (err) {
986
+ this.pending.delete(id);
987
+ reject(err);
988
+ }
989
+ });
990
+ }
991
+
992
+ release() {
993
+ if (--this.refs > 0) return;
994
+ if (shared === this) shared = null;
995
+ try {
996
+ this.proc.stdin?.end();
997
+ } catch {
998
+ /* already gone */
999
+ }
1000
+ this.proc.kill?.();
1001
+ }
1002
+ }
1003
+
1004
+ let shared = null;
1005
+
1006
+ /** The shared child, started if it is not running. Rejects if it will not
1007
+ * start or does not say hello, which is this Mac saying it cannot answer. */
1008
+ async function acquireChild() {
1009
+ if (shared && !shared.exited) {
1010
+ shared.refs++;
1011
+ return shared;
1012
+ }
1013
+ let proc;
1014
+ if (spawnChild) {
1015
+ proc = await spawnChild();
1016
+ } else {
1017
+ const { spawn } = await import('node:child_process');
1018
+ // stderr is ignored on purpose: AppKit logs into every process that
1019
+ // touches it, and none of it is an answer to anything asked here.
1020
+ proc = spawn(
1021
+ 'osascript',
1022
+ ['-l', 'JavaScript', '-e', MACOS_CALENDAR_PROGRAM],
1023
+ { stdio: ['pipe', 'pipe', 'ignore'] },
1024
+ );
1025
+ }
1026
+ // Deliberately **not** `unref`'d, unlike the appearance watcher: this one
1027
+ // is asked questions and answers them, so a process that let the loop
1028
+ // drain while a read was in flight would exit in the middle of it. It is
1029
+ // an open resource, like the bus connection — `close()` ends it, and the
1030
+ // exit handler below is the backstop for an app that forgot to.
1031
+ const child = new CalendarChild(proc);
1032
+ const timer = setTimeout(() => {
1033
+ child._die(new Error('the calendar helper did not start'));
1034
+ }, CHILD_READY_TIMEOUT_MS);
1035
+ timer.unref?.();
1036
+ try {
1037
+ await child.ready;
1038
+ } finally {
1039
+ clearTimeout(timer);
1040
+ }
1041
+
1042
+ child.refs = 1;
1043
+ shared = child;
1044
+ return child;
1045
+ }
1046
+
1047
+ /** Kill the shared child, if any. Called on exit, and by the tests. */
1048
+ export function _closeCalendarChild() {
1049
+ const child = shared;
1050
+ shared = null;
1051
+ if (!child) return;
1052
+ child.refs = 0;
1053
+ try {
1054
+ child.proc.stdin?.end();
1055
+ } catch {
1056
+ /* already gone */
1057
+ }
1058
+ child.proc.kill?.();
1059
+ }
1060
+
1061
+ process.on('exit', () => {
1062
+ _closeCalendarChild();
1063
+ });
1064
+
1065
+ /** EventKit over the child: the same answers as the bridge, a slower way. */
1066
+ export class OsascriptCalendars {
1067
+ constructor(child) {
1068
+ this.backend = 'osascript';
1069
+ this._child = child;
1070
+ }
1071
+
1072
+ access() {
1073
+ return this._child.send({ op: 'status' });
1074
+ }
1075
+
1076
+ requestAccess() {
1077
+ return this._child.send({ op: 'request' });
1078
+ }
1079
+
1080
+ async listCalendars() {
1081
+ const list = await this._child.send({ op: 'calendars' });
1082
+ return (list ?? []).map((cal) => ({
1083
+ uid: String(cal.id),
1084
+ name: cal.title ?? '',
1085
+ enabled: true,
1086
+ color: hexFromComponents(cal.color),
1087
+ backend: calendarBackendName(cal.type),
1088
+ readOnly: cal.allowsModifications === false || cal.immutable === true,
1089
+ account: cal.source?.title ?? undefined,
1090
+ }));
1091
+ }
1092
+
1093
+ async eventsBetween(from, to, options = {}) {
1094
+ const metas = options.calendars ?? (await this.listCalendars());
1095
+ // The same rule as the bridge rung: a filter that matched nothing means
1096
+ // nothing, not everything.
1097
+ if (options.calendars && metas.length === 0) {
1098
+ return { events: [], errors: [] };
1099
+ }
1100
+ const byId = new Map(metas.map((meta) => [meta.uid, meta]));
1101
+ const ids = options.calendars ? metas.map((meta) => meta.uid) : undefined;
1102
+
1103
+ const events = [];
1104
+ const seen = new Set();
1105
+ for (const [start, end] of chunkSpan(from, to)) {
1106
+ const raw = await this._child.send({
1107
+ op: 'events',
1108
+ start,
1109
+ end,
1110
+ calendars: ids,
1111
+ });
1112
+ for (const one of raw ?? []) {
1113
+ const key = `${one.id} ${one.start}`;
1114
+ if (seen.has(key)) continue;
1115
+ seen.add(key);
1116
+ const at = new Date(one.start);
1117
+ const meta = byId.get(String(one.calendar));
1118
+ events.push({
1119
+ uid: String(one.id ?? `${one.calendar}:${one.start}`),
1120
+ summary: one.title ?? '',
1121
+ location: one.location ?? undefined,
1122
+ description: one.notes ?? undefined,
1123
+ start: at,
1124
+ end: withExclusiveEnd(new Date(one.end), Boolean(one.allDay)),
1125
+ allDay: Boolean(one.allDay),
1126
+ recurring: Boolean(one.recurring),
1127
+ calendar: meta
1128
+ ? { uid: meta.uid, name: meta.name, color: meta.color }
1129
+ : { uid: String(one.calendar ?? ''), name: '' },
1130
+ });
1131
+ }
1132
+ }
1133
+ return { events: sortEvents(events), errors: [] };
1134
+ }
1135
+
1136
+ /** The store's own notification, which names nothing: see
1137
+ * `CocoaCalendars.watch` for why a change carries no calendar. */
1138
+ async watch(from, to, onChange) {
1139
+ await this._child.send({ op: 'watch' });
1140
+ const fn = () => onChange({ calendar: null, kind: 'changed', count: null });
1141
+ this._child.watchers.add(fn);
1142
+ return async () => {
1143
+ this._child.watchers.delete(fn);
1144
+ };
1145
+ }
1146
+
1147
+ /** Nothing: the child is shared and ref-counted, and the handle's own
1148
+ * `close()` is what releases this share of it. Releasing here too would
1149
+ * count one handle twice and kill a child another is still using. */
1150
+ async close() {}
1151
+ }
1152
+
1153
+ // --------------------------------------------------------------------------
1154
+ // Choosing a rung
1155
+ // --------------------------------------------------------------------------
1156
+
1157
+ /**
1158
+ * The app whose EventKit bridge to read, or null.
1159
+ *
1160
+ * Never a backend check: an app that can read calendars says so by carrying
1161
+ * `calendars` (src/cocoa/app.js), and this asks the connections the renderer
1162
+ * is drawing through — the rule `permissions` and `filePanels` follow.
1163
+ */
1164
+ function calendarsApp(app) {
1165
+ if (app) return app.calendars ? app : null;
1166
+ const apps = liveApps().filter((one) => one.calendars);
1167
+ if (apps.length <= 1) return apps[0] ?? null;
1168
+ const showing = apps.filter((one) => (one._rootChildren ?? []).length > 0);
1169
+ return showing.length === 1 ? showing[0] : null;
1170
+ }
1171
+
1172
+ /** `osascript` on this machine's PATH, without running it. */
1173
+ async function haveOsascript() {
1174
+ if (spawnChild) return true;
1175
+ if (process.platform !== 'darwin') return false;
1176
+ const { access } = await import('node:fs/promises');
1177
+ const dirs = (process.env.PATH ?? '/usr/bin:/bin').split(':');
1178
+ for (const dir of dirs) {
1179
+ if (!dir) continue;
1180
+ try {
1181
+ await access(`${dir}/osascript`);
1182
+ return true;
1183
+ } catch {
1184
+ /* not in this one */
1185
+ }
1186
+ }
1187
+ return false;
1188
+ }
1189
+
1190
+ /**
1191
+ * Which rung this machine lands on, without reading anything.
1192
+ *
1193
+ * ```js
1194
+ * switch (await calendarBackend()) {
1195
+ * case null: return <NoCalendarHere />;
1196
+ * }
1197
+ * ```
1198
+ *
1199
+ * Useful for a settings screen that wants to say where the events come from,
1200
+ * and for the tests. It acquires a bus ref and releases it, so it is cheap
1201
+ * but not free — and it never spawns the `osascript` child or raises a
1202
+ * permission prompt.
1203
+ *
1204
+ * @returns {Promise<'cocoa'|'osascript'|'eds'|null>}
1205
+ */
1206
+ export async function calendarBackend(options = {}) {
1207
+ if (calendarsApp(options.app)) return 'cocoa';
1208
+ if (await haveOsascript()) return 'osascript';
1209
+ const ref = await sessionBus();
1210
+ if (ref) {
1211
+ try {
1212
+ if (await hasService(SOURCES_NAME, ref)) return 'eds';
1213
+ } finally {
1214
+ await ref.release();
1215
+ }
1216
+ }
1217
+ return null;
1218
+ }
1219
+
1220
+ /**
1221
+ * The desktop's calendars, on the best rung this machine has.
1222
+ *
1223
+ * ```js
1224
+ * const cal = await desktopCalendar();
1225
+ * if (!cal) return; // no calendars here
1226
+ * try {
1227
+ * const { events } = await cal.eventsBetween(from, to);
1228
+ * } finally {
1229
+ * await cal.close();
1230
+ * }
1231
+ * ```
1232
+ *
1233
+ * **Never rejects for anything about the machine** — `null` is the answer
1234
+ * where nothing can read a calendar, the way `permissionStatus()` answers
1235
+ * `'unknown'`. `{ required: true }` turns that into a
1236
+ * {@link NoCalendarServiceError} for a caller who would rather branch on a
1237
+ * `catch`, and `{ backend }` pins one rung and fails rather than falling
1238
+ * through it, which is what a test and a "why is it slow" investigation
1239
+ * both want.
1240
+ *
1241
+ * The handle owns something on every rung — a bus reference, a watcher, a
1242
+ * share of the `osascript` child — so `close()` it. `useDesktopCalendarEvents`
1243
+ * does that for you.
1244
+ *
1245
+ * @returns {Promise<DesktopCalendar | null>}
1246
+ */
1247
+ export async function desktopCalendar(options = {}) {
1248
+ const { app, backend, required = false } = options;
1249
+
1250
+ if (!backend || backend === 'cocoa') {
1251
+ const found = calendarsApp(app);
1252
+ if (found) return new DesktopCalendar(found.calendars, async () => {});
1253
+ }
1254
+
1255
+ if (!backend || backend === 'osascript') {
1256
+ if (await haveOsascript()) {
1257
+ try {
1258
+ const child = await acquireChild();
1259
+ return new DesktopCalendar(new OsascriptCalendars(child), async () => {
1260
+ child.release();
1261
+ });
1262
+ } catch (err) {
1263
+ // osascript is there but would not run the program: a machine
1264
+ // answering "no", not a reason to crash the app.
1265
+ if (backend === 'osascript') {
1266
+ if (required) throw new NoCalendarServiceError(err);
1267
+ return null;
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ if (!backend || backend === 'eds') {
1274
+ const ref = await sessionBus();
1275
+ if (ref) {
1276
+ let found = false;
1277
+ try {
1278
+ found = await hasService(SOURCES_NAME, ref);
1279
+ } catch {
1280
+ found = false;
1281
+ }
1282
+ if (found) {
1283
+ return new DesktopCalendar(new EdsCalendars(ref.bus), () =>
1284
+ ref.release(),
1285
+ );
1286
+ }
1287
+ await ref.release();
1288
+ }
1289
+ }
1290
+
1291
+ if (required) throw new NoCalendarServiceError();
1292
+ return null;
1293
+ }
1294
+
1295
+ // --------------------------------------------------------------------------
1296
+ // The handle: one shape, and the grant asked for once
1297
+ // --------------------------------------------------------------------------
1298
+
1299
+ /**
1300
+ * What `desktopCalendar()` hands back: one API over whichever rung answered.
1301
+ *
1302
+ * The rungs are mechanism — a bridge call, a JSON line, a D-Bus view — and
1303
+ * the policy is here, in one place, because it is the same policy on all of
1304
+ * them: **the first read asks for the grant** (the notification centre's
1305
+ * rule: the first post asks), a refusal is a typed rejection rather than an
1306
+ * empty list, and the handle's own watches are the ones `close()` stops.
1307
+ */
1308
+ export class DesktopCalendar {
1309
+ constructor(rung, release) {
1310
+ this._rung = rung;
1311
+ this._release = release;
1312
+ this._stops = new Set();
1313
+ this._closed = false;
1314
+ this._asked = null;
1315
+ }
1316
+
1317
+ /** `'cocoa'`, `'osascript'` or `'eds'` — which rung answered. */
1318
+ get backend() {
1319
+ return this._rung.backend;
1320
+ }
1321
+
1322
+ /**
1323
+ * May this app read the calendars, as far as anything can say without
1324
+ * asking the user: the permission vocabulary, `'granted'` on a rung with
1325
+ * no such gate (EDS).
1326
+ */
1327
+ access() {
1328
+ return this._rung.access();
1329
+ }
1330
+
1331
+ /** Raise the system's prompt where there is one; the status after the
1332
+ * user answered. Reads do this for you on the first one. */
1333
+ requestAccess() {
1334
+ this._asked = null;
1335
+ return this._rung.requestAccess();
1336
+ }
1337
+
1338
+ /** System Settings > Privacy & Security > Calendars, for a refusal the
1339
+ * user can still change their mind about. `false` off macOS. */
1340
+ openSettings() {
1341
+ return openPrivacySettings('calendars');
1342
+ }
1343
+
1344
+ async _ensureAccess() {
1345
+ if (this._closed) throw new Error('react-x11: this calendar is closed');
1346
+ if (!this._asked) {
1347
+ this._asked = (async () => {
1348
+ let status = await this._rung.access();
1349
+ // "Not decided yet" is the one state where reading means asking.
1350
+ if (status === 'prompt') status = await this._rung.requestAccess();
1351
+ return status;
1352
+ })().catch((err) => {
1353
+ this._asked = null;
1354
+ throw err;
1355
+ });
1356
+ }
1357
+ const status = await this._asked;
1358
+ // An undecided answer is not an answer: it means the request was made
1359
+ // and nothing came back — TCC declining to ask (see the JXA program's
1360
+ // `request`). Remembering it would keep a whole session behind a
1361
+ // decision the user may make in Settings a moment later.
1362
+ if (status === 'prompt') this._asked = null;
1363
+ if (status !== 'granted' && status !== 'unknown') {
1364
+ throw new CalendarAccessError(status);
1365
+ }
1366
+ }
1367
+
1368
+ /** Every calendar the desktop knows about. */
1369
+ async listCalendars() {
1370
+ await this._ensureAccess();
1371
+ return this._rung.listCalendars();
1372
+ }
1373
+
1374
+ /**
1375
+ * The occurrences between two `Date`s, expanded, sorted by start, each
1376
+ * tagged with the calendar it came from.
1377
+ *
1378
+ * `end` is **exclusive** on every rung. `errors` names the calendars that
1379
+ * would not answer — one unreachable CalDAV server is not a failure of
1380
+ * the month — and is empty on the macOS rungs, where there is one store.
1381
+ */
1382
+ async eventsBetween(from, to, options = {}) {
1383
+ await this._ensureAccess();
1384
+ return this._rung.eventsBetween(from, to, options);
1385
+ }
1386
+
1387
+ /**
1388
+ * Call `onChange` when something in the store moves, and return the
1389
+ * function that stops watching.
1390
+ *
1391
+ * Deliberately thin: **re-query rather than patch**. EDS names what
1392
+ * changed and EventKit does not, and neither can say what a recurrence
1393
+ * master edited months away did to this range.
1394
+ */
1395
+ async watch(from, to, onChange, options = {}) {
1396
+ await this._ensureAccess();
1397
+ const stop = await this._rung.watch(from, to, onChange, options);
1398
+ const once = async () => {
1399
+ if (!this._stops.delete(once)) return;
1400
+ await stop();
1401
+ };
1402
+ this._stops.add(once);
1403
+ return once;
1404
+ }
1405
+
1406
+ /** Stop this handle's watches and release what it holds. Idempotent. */
1407
+ async close() {
1408
+ if (this._closed) return;
1409
+ this._closed = true;
1410
+ for (const stop of [...this._stops]) await stop();
1411
+ this._stops.clear();
1412
+ await this._rung.close?.();
1413
+ await this._release();
1414
+ }
1415
+ }