react-x11 2.9.0 → 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.
- package/package.json +4 -2
- package/src/cocoa/app.js +18 -0
- package/src/cocoa/calendar.js +186 -0
- package/src/cocoa/native.js +20 -3
- package/src/cocoa/permissions.js +14 -5
- package/src/desktopcalendar.js +1415 -0
- package/src/desktopcalendarhooks.js +259 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +9 -0
- package/src/permissionhooks.js +9 -6
- package/src/permissions.js +12 -2
- package/src/types/desktopcalendar.d.ts +207 -0
- package/src/types/permissions.d.ts +12 -4
|
@@ -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
|
+
}
|