react-x11 2.9.0 → 2.9.2
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/components/Button.js +68 -57
- package/src/components/Select.js +20 -3
- package/src/components/native.js +41 -0
- 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,259 @@
|
|
|
1
|
+
// `useDesktopCalendarEvents()` — the desktop's calendar as rendering state:
|
|
2
|
+
// the occurrences in a range, grouped the way a calendar grid asks for them,
|
|
3
|
+
// re-read when the store moves.
|
|
4
|
+
//
|
|
5
|
+
// The imperative half in `desktopcalendar.js` is complete; what a component
|
|
6
|
+
// wants on top is binding — the tree's connection, a handle that is closed
|
|
7
|
+
// when the view goes away, the grant asked for on the first read rather than
|
|
8
|
+
// at start-up — not another rung.
|
|
9
|
+
//
|
|
10
|
+
// ```jsx
|
|
11
|
+
// const { byDay } = useDesktopCalendarEvents({ from, to, watch: true });
|
|
12
|
+
//
|
|
13
|
+
// <Calendar
|
|
14
|
+
// dayContent={(day) =>
|
|
15
|
+
// byDay.get(day)?.slice(0, 3).map((ev) => (
|
|
16
|
+
// <box key={ev.uid} style={{ width: 4, height: 4, borderRadius: 2,
|
|
17
|
+
// backgroundColor: ev.calendar.color ?? '$accent' }} />
|
|
18
|
+
// ))
|
|
19
|
+
// }
|
|
20
|
+
// />
|
|
21
|
+
// ```
|
|
22
|
+
//
|
|
23
|
+
// `<Calendar>` is `@react-x11/components`; the `'YYYY-MM-DD'` keys are the
|
|
24
|
+
// one contract between the two packages, which is why `byDay` is here and
|
|
25
|
+
// the grid is there.
|
|
26
|
+
|
|
27
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
28
|
+
|
|
29
|
+
import { useAppOrNull } from './appcontext.js';
|
|
30
|
+
import {
|
|
31
|
+
CalendarAccessError,
|
|
32
|
+
byDay as groupByDay,
|
|
33
|
+
desktopCalendar,
|
|
34
|
+
} from './desktopcalendar.js';
|
|
35
|
+
import { openPrivacySettings } from './permissions.js';
|
|
36
|
+
|
|
37
|
+
const NO_EVENTS = [];
|
|
38
|
+
const NO_CALENDARS = [];
|
|
39
|
+
const NO_ERRORS = [];
|
|
40
|
+
|
|
41
|
+
/** The calendars a caller asked for by uid, or every enabled one. `onlyKey`
|
|
42
|
+
* is the comma-joined list, because an array prop is a new identity every
|
|
43
|
+
* render and these effects are keyed on it. */
|
|
44
|
+
function pickCalendars(all, onlyKey) {
|
|
45
|
+
if (!onlyKey) return all.filter((c) => c.enabled);
|
|
46
|
+
const wanted = new Set(onlyKey.split(','));
|
|
47
|
+
return all.filter((c) => wanted.has(c.uid));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The user's desktop calendar events, as rendering state.
|
|
52
|
+
*
|
|
53
|
+
* **`from` and `to` are read by their timestamps, not their identity**, so
|
|
54
|
+
* `new Date(...)` inline in the render body is fine and will not re-query on
|
|
55
|
+
* every paint. That is the mistake this hook would otherwise invite, and it
|
|
56
|
+
* costs a round trip per frame.
|
|
57
|
+
*
|
|
58
|
+
* `status` is the whole answer:
|
|
59
|
+
*
|
|
60
|
+
* - `'idle'` — `enabled: false`, nothing asked yet.
|
|
61
|
+
* - `'loading'` — a read is in flight. The first one may be showing the
|
|
62
|
+
* system's permission prompt: the first read is what asks, so that a
|
|
63
|
+
* picker the user never opens never prompts.
|
|
64
|
+
* - `'ready'` — `events` is this range.
|
|
65
|
+
* - `'denied'` — the **user's** answer. `openSettings()` puts them in front
|
|
66
|
+
* of the switch; do not show that button on any other status.
|
|
67
|
+
* - `'unavailable'` — the **machine's** answer: no bridge, not a Mac, no
|
|
68
|
+
* Evolution Data Server on the bus. An ordinary state, not a failure —
|
|
69
|
+
* hide the feature rather than reporting it.
|
|
70
|
+
*
|
|
71
|
+
* `errors` is different from all of them: calendars that would not answer
|
|
72
|
+
* while others did, which is one unreachable CalDAV server rather than a
|
|
73
|
+
* failed read.
|
|
74
|
+
*/
|
|
75
|
+
export function useDesktopCalendarEvents(options) {
|
|
76
|
+
const { from, to, calendars: only, watch = false, enabled = true } = options;
|
|
77
|
+
const app = useAppOrNull();
|
|
78
|
+
|
|
79
|
+
const [cal, setCal] = useState(null);
|
|
80
|
+
const [backend, setBackend] = useState(null);
|
|
81
|
+
const [events, setEvents] = useState(NO_EVENTS);
|
|
82
|
+
const [found, setFound] = useState(NO_CALENDARS);
|
|
83
|
+
const [errors, setErrors] = useState(NO_ERRORS);
|
|
84
|
+
const [status, setStatus] = useState('idle');
|
|
85
|
+
const [error, setError] = useState(null);
|
|
86
|
+
const [nonce, setNonce] = useState(0);
|
|
87
|
+
const live = useRef(true);
|
|
88
|
+
|
|
89
|
+
const refresh = useCallback(() => setNonce((n) => n + 1), []);
|
|
90
|
+
const openSettings = useCallback(() => openPrivacySettings('calendars'), []);
|
|
91
|
+
|
|
92
|
+
// Timestamps, not the `Date` objects: a caller writing
|
|
93
|
+
// `from={new Date(y, m, 1)}` in the render body hands us a new identity
|
|
94
|
+
// every paint, and an effect keyed on that would re-query every frame.
|
|
95
|
+
const fromMs = from.getTime();
|
|
96
|
+
const toMs = to.getTime();
|
|
97
|
+
const onlyKey = only ? only.join(',') : '';
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
live.current = true;
|
|
101
|
+
return () => {
|
|
102
|
+
live.current = false;
|
|
103
|
+
};
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
// The handle, opened once per connection: the `osascript` child and the
|
|
107
|
+
// bus reference behind it are shared, and one hook must not open a second
|
|
108
|
+
// of either every time the month changes.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!enabled) {
|
|
111
|
+
setCal(null);
|
|
112
|
+
setBackend(null);
|
|
113
|
+
setStatus('idle');
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let alive = true;
|
|
118
|
+
let handle = null;
|
|
119
|
+
setStatus('loading');
|
|
120
|
+
desktopCalendar({ app }).then(
|
|
121
|
+
(opened) => {
|
|
122
|
+
if (!alive) {
|
|
123
|
+
void opened?.close();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
handle = opened;
|
|
127
|
+
setCal(opened);
|
|
128
|
+
setBackend(opened ? opened.backend : null);
|
|
129
|
+
if (!opened) {
|
|
130
|
+
setEvents(NO_EVENTS);
|
|
131
|
+
setStatus('unavailable');
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
(err) => {
|
|
135
|
+
if (!alive) return;
|
|
136
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
137
|
+
setStatus('unavailable');
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
alive = false;
|
|
143
|
+
setCal(null);
|
|
144
|
+
void handle?.close();
|
|
145
|
+
};
|
|
146
|
+
}, [app, enabled]);
|
|
147
|
+
|
|
148
|
+
// The query.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!cal) return undefined;
|
|
151
|
+
|
|
152
|
+
let alive = true;
|
|
153
|
+
void (async () => {
|
|
154
|
+
setStatus('loading');
|
|
155
|
+
setError(null);
|
|
156
|
+
try {
|
|
157
|
+
const all = await cal.listCalendars();
|
|
158
|
+
if (!alive) return;
|
|
159
|
+
setFound(all);
|
|
160
|
+
|
|
161
|
+
const result = await cal.eventsBetween(
|
|
162
|
+
new Date(fromMs),
|
|
163
|
+
new Date(toMs),
|
|
164
|
+
{
|
|
165
|
+
calendars: pickCalendars(all, onlyKey),
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
if (!alive) return;
|
|
169
|
+
setEvents(result.events);
|
|
170
|
+
setErrors(result.errors);
|
|
171
|
+
setStatus('ready');
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (!alive) return;
|
|
174
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
175
|
+
setEvents(NO_EVENTS);
|
|
176
|
+
// The user's refusal and the machine's silence are different words:
|
|
177
|
+
// one has a Settings switch behind it and the other does not. A
|
|
178
|
+
// request that came back still undecided is the machine's — TCC
|
|
179
|
+
// would not even ask — so it is 'unavailable', not a refusal the
|
|
180
|
+
// user could take back.
|
|
181
|
+
const refused =
|
|
182
|
+
err instanceof CalendarAccessError && err.status !== 'prompt';
|
|
183
|
+
setStatus(refused ? 'denied' : 'unavailable');
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
alive = false;
|
|
189
|
+
};
|
|
190
|
+
// `refresh` is stable; `nonce` is what a manual refresh moves.
|
|
191
|
+
}, [cal, fromMs, toMs, onlyKey, nonce]);
|
|
192
|
+
|
|
193
|
+
// The subscription is a **separate** effect, and deliberately not keyed on
|
|
194
|
+
// `nonce`: a change has to re-run the query, and only the query. Watching
|
|
195
|
+
// from inside the effect the change re-runs means every notification tears
|
|
196
|
+
// the views down and starts new ones — which is a loop as soon as anything
|
|
197
|
+
// is delivered while a view is starting, and was one for as long as EDS's
|
|
198
|
+
// `Start()` reported the range's existing contents as a change.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!cal || !watch) return undefined;
|
|
201
|
+
|
|
202
|
+
let alive = true;
|
|
203
|
+
let stopWatching = null;
|
|
204
|
+
void (async () => {
|
|
205
|
+
try {
|
|
206
|
+
const all = await cal.listCalendars();
|
|
207
|
+
if (!alive) return;
|
|
208
|
+
stopWatching = await cal.watch(
|
|
209
|
+
new Date(fromMs),
|
|
210
|
+
new Date(toMs),
|
|
211
|
+
() => {
|
|
212
|
+
// Re-query rather than patch: a recurrence master edited months
|
|
213
|
+
// away changes what this range looks like.
|
|
214
|
+
if (alive && live.current) refresh();
|
|
215
|
+
},
|
|
216
|
+
{ calendars: pickCalendars(all, onlyKey) },
|
|
217
|
+
);
|
|
218
|
+
if (!alive) await stopWatching();
|
|
219
|
+
} catch {
|
|
220
|
+
// No grant, no service, nothing to watch. The query effect above is
|
|
221
|
+
// what reports that to the caller; a second copy would only race.
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
alive = false;
|
|
227
|
+
void (async () => {
|
|
228
|
+
if (stopWatching) await stopWatching();
|
|
229
|
+
})();
|
|
230
|
+
};
|
|
231
|
+
}, [cal, watch, fromMs, toMs, onlyKey, refresh]);
|
|
232
|
+
|
|
233
|
+
const grouped = useMemo(() => groupByDay(events), [events]);
|
|
234
|
+
|
|
235
|
+
return useMemo(
|
|
236
|
+
() => ({
|
|
237
|
+
events,
|
|
238
|
+
byDay: grouped,
|
|
239
|
+
calendars: found,
|
|
240
|
+
errors,
|
|
241
|
+
status,
|
|
242
|
+
backend,
|
|
243
|
+
error,
|
|
244
|
+
refresh,
|
|
245
|
+
openSettings,
|
|
246
|
+
}),
|
|
247
|
+
[
|
|
248
|
+
events,
|
|
249
|
+
grouped,
|
|
250
|
+
found,
|
|
251
|
+
errors,
|
|
252
|
+
status,
|
|
253
|
+
backend,
|
|
254
|
+
error,
|
|
255
|
+
refresh,
|
|
256
|
+
openSettings,
|
|
257
|
+
],
|
|
258
|
+
);
|
|
259
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from './types/launcher.js';
|
|
|
29
29
|
export * from './types/tray.js';
|
|
30
30
|
export * from './types/permissions.js';
|
|
31
31
|
export * from './types/notifications.js';
|
|
32
|
+
export * from './types/desktopcalendar.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* The XID of the X11 window a ref points at, or `null` if there is not one
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,15 @@ export {
|
|
|
27
27
|
notify,
|
|
28
28
|
} from './notifications.js';
|
|
29
29
|
export { useNotifier } from './notificationhooks.js';
|
|
30
|
+
export {
|
|
31
|
+
CalendarAccessError,
|
|
32
|
+
NoCalendarServiceError,
|
|
33
|
+
byDay,
|
|
34
|
+
calendarBackend,
|
|
35
|
+
dayKey,
|
|
36
|
+
desktopCalendar,
|
|
37
|
+
} from './desktopcalendar.js';
|
|
38
|
+
export { useDesktopCalendarEvents } from './desktopcalendarhooks.js';
|
|
30
39
|
export { parseUriList } from './transfer.js';
|
|
31
40
|
export { useApp, useClipboard, useSupports } from './appcontext.js';
|
|
32
41
|
export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
|
package/src/permissionhooks.js
CHANGED
|
@@ -42,30 +42,33 @@ import {
|
|
|
42
42
|
export function usePermission(kind, options = {}) {
|
|
43
43
|
const app = useAppOrNull();
|
|
44
44
|
const target = options.target;
|
|
45
|
+
// `calendars` only: which level to ask for. Part of the effect keys
|
|
46
|
+
// because asking for a narrower grant is asking a different question.
|
|
47
|
+
const access = options.access;
|
|
45
48
|
const available = permissionBackend({ app }) !== null;
|
|
46
49
|
const [status, setStatus] = useState('unknown');
|
|
47
50
|
const inflight = useRef(null);
|
|
48
51
|
|
|
49
52
|
const refresh = useCallback(async () => {
|
|
50
|
-
const next = await permissionStatus(kind, { app, target });
|
|
53
|
+
const next = await permissionStatus(kind, { app, target, access });
|
|
51
54
|
setStatus(next);
|
|
52
55
|
return next;
|
|
53
|
-
}, [kind, app, target]);
|
|
56
|
+
}, [kind, app, target, access]);
|
|
54
57
|
|
|
55
58
|
useEffect(() => {
|
|
56
59
|
let alive = true;
|
|
57
|
-
permissionStatus(kind, { app, target }).then(
|
|
60
|
+
permissionStatus(kind, { app, target, access }).then(
|
|
58
61
|
(next) => alive && setStatus(next),
|
|
59
62
|
() => alive && setStatus('unknown'),
|
|
60
63
|
);
|
|
61
64
|
return () => {
|
|
62
65
|
alive = false;
|
|
63
66
|
};
|
|
64
|
-
}, [kind, app, target]);
|
|
67
|
+
}, [kind, app, target, access]);
|
|
65
68
|
|
|
66
69
|
const request = useCallback(() => {
|
|
67
70
|
if (inflight.current) return inflight.current;
|
|
68
|
-
const run = requestPermission(kind, { app, target })
|
|
71
|
+
const run = requestPermission(kind, { app, target, access })
|
|
69
72
|
.then((next) => {
|
|
70
73
|
setStatus(next);
|
|
71
74
|
return next;
|
|
@@ -75,7 +78,7 @@ export function usePermission(kind, options = {}) {
|
|
|
75
78
|
});
|
|
76
79
|
inflight.current = run;
|
|
77
80
|
return run;
|
|
78
|
-
}, [kind, app, target]);
|
|
81
|
+
}, [kind, app, target, access]);
|
|
79
82
|
|
|
80
83
|
const openSettings = useCallback(
|
|
81
84
|
() => openPrivacySettings(kind, { app }),
|
package/src/permissions.js
CHANGED
|
@@ -23,18 +23,24 @@
|
|
|
23
23
|
//
|
|
24
24
|
// ## Two things the vocabulary decides
|
|
25
25
|
//
|
|
26
|
-
// - A status is one of
|
|
26
|
+
// - A status is one of six words. `'granted'`, `'denied'` and
|
|
27
27
|
// `'restricted'` (MDM or parental controls: the user cannot grant it) are
|
|
28
28
|
// the platform's; `'prompt'` is "not decided yet — a request would ask";
|
|
29
29
|
// `'unknown'` is "nothing here can say", which is a fact about the machine
|
|
30
30
|
// rather than about the permission, and the reason a query never throws.
|
|
31
|
+
// `'write-only'` is the sixth and the odd one: macOS 14's partial grant
|
|
32
|
+
// for `calendars` and `reminders`, where the app may save an item it
|
|
33
|
+
// cannot read. It crosses as its own word rather than being flattened
|
|
34
|
+
// into one of the other two, because it is a grant to a writer and a
|
|
35
|
+
// refusal to a reader and only the caller knows which it is.
|
|
31
36
|
// - A request answers with the status **after** the user has, never with a
|
|
32
37
|
// bare boolean, because `'restricted'` and `'denied'` want different UI —
|
|
33
38
|
// one is a Settings switch the user can flip, the other is not.
|
|
34
39
|
|
|
35
40
|
import { liveApps } from './trace-registry.js';
|
|
36
41
|
|
|
37
|
-
/** The kinds a status can be asked for. `automation` wants `{ target }
|
|
42
|
+
/** The kinds a status can be asked for. `automation` wants `{ target }`;
|
|
43
|
+
* `calendars` takes `{ access: 'write-only' }` for the narrower grant. */
|
|
38
44
|
export const PERMISSION_KINDS = Object.freeze([
|
|
39
45
|
'camera',
|
|
40
46
|
'microphone',
|
|
@@ -43,6 +49,8 @@ export const PERMISSION_KINDS = Object.freeze([
|
|
|
43
49
|
'input-monitoring',
|
|
44
50
|
'automation',
|
|
45
51
|
'location',
|
|
52
|
+
'calendars',
|
|
53
|
+
'reminders',
|
|
46
54
|
]);
|
|
47
55
|
|
|
48
56
|
/** The Settings panes, the kinds above plus the two that have no API at all
|
|
@@ -55,6 +63,8 @@ const SETTINGS_PANES = Object.freeze({
|
|
|
55
63
|
'input-monitoring': 'Privacy_ListenEvent',
|
|
56
64
|
automation: 'Privacy_Automation',
|
|
57
65
|
location: 'Privacy_LocationServices',
|
|
66
|
+
calendars: 'Privacy_Calendars',
|
|
67
|
+
reminders: 'Privacy_Reminders',
|
|
58
68
|
'files-and-folders': 'Privacy_FilesAndFolders',
|
|
59
69
|
'full-disk-access': 'Privacy_AllFiles',
|
|
60
70
|
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendars the user's desktop already has — EventKit on macOS,
|
|
3
|
+
* Evolution Data Server on a freedesktop session. See
|
|
4
|
+
* docs/desktop-calendar.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { NtkApp } from './nodes.js';
|
|
8
|
+
import type { PermissionStatus } from './permissions.js';
|
|
9
|
+
|
|
10
|
+
/** Which rung answered: the EventKit bridge, an `osascript` child holding
|
|
11
|
+
* an `EKEventStore`, or Evolution Data Server over the session bus. */
|
|
12
|
+
export type CalendarBackend = 'cocoa' | 'osascript' | 'eds';
|
|
13
|
+
|
|
14
|
+
/** One calendar the desktop knows about. */
|
|
15
|
+
export interface DesktopCalendarInfo {
|
|
16
|
+
uid: string;
|
|
17
|
+
/** The name shown in the desktop's own calendar UI. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Whether the desktop has it switched on. Always true on macOS, which
|
|
20
|
+
* has no such state to report. */
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
/** `'#3584e4'` — the colour that UI draws it in, worth carrying into a
|
|
23
|
+
* day marker. */
|
|
24
|
+
color?: string;
|
|
25
|
+
/** `caldav`, `google`, `local`, `exchange`, `subscription`, `birthday`…
|
|
26
|
+
* the store's own word, lower-cased. */
|
|
27
|
+
backend?: string;
|
|
28
|
+
readOnly: boolean;
|
|
29
|
+
/** The account it came from, where one is named — a GNOME Online Accounts
|
|
30
|
+
* entry, or an `EKSource` (`'iCloud'`, `'Google'`). */
|
|
31
|
+
account?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One occurrence, with any recurrence already expanded. */
|
|
35
|
+
export interface DesktopEvent {
|
|
36
|
+
uid: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
location?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
start: Date;
|
|
41
|
+
/** **Exclusive**, on every rung: an all-day event on the 10th ends at
|
|
42
|
+
* midnight on the 11th. The macOS rungs normalise what EventKit reports
|
|
43
|
+
* (the last second of the last day) so a caller never has to ask. */
|
|
44
|
+
end: Date;
|
|
45
|
+
allDay: boolean;
|
|
46
|
+
recurring: boolean;
|
|
47
|
+
calendar: { uid: string; name: string; color?: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A calendar that would not answer. One broken backend is not a failure. */
|
|
51
|
+
export interface DesktopCalendarError {
|
|
52
|
+
calendar: DesktopCalendarInfo;
|
|
53
|
+
message: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EventsResult {
|
|
57
|
+
events: DesktopEvent[];
|
|
58
|
+
/** Empty on the macOS rungs, where there is one store. */
|
|
59
|
+
errors: DesktopCalendarError[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What `watch` reports. Deliberately thin: re-query rather than patch.
|
|
64
|
+
*
|
|
65
|
+
* `'changed'` with a null calendar and no count is EventKit's — its
|
|
66
|
+
* notification names nothing, and a rung must not invent a count.
|
|
67
|
+
*/
|
|
68
|
+
export interface CalendarChange {
|
|
69
|
+
calendar: DesktopCalendarInfo | null;
|
|
70
|
+
kind: 'ObjectsAdded' | 'ObjectsModified' | 'ObjectsRemoved' | 'changed';
|
|
71
|
+
count: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EventsOptions {
|
|
75
|
+
/** Restrict to these calendars. Default: every enabled one. */
|
|
76
|
+
calendars?: DesktopCalendarInfo[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Nothing on this machine can read a calendar. Raised only by
|
|
81
|
+
* `desktopCalendar({ required: true })`; the default answer is `null`,
|
|
82
|
+
* because a machine with no calendar service is an ordinary machine.
|
|
83
|
+
*/
|
|
84
|
+
export declare class NoCalendarServiceError extends Error {
|
|
85
|
+
readonly name: 'NoCalendarServiceError';
|
|
86
|
+
readonly cause?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The app may not read the calendars. `status` says why: `'denied'` and
|
|
91
|
+
* `'restricted'` and `'write-only'` are decisions, and `'prompt'` is a
|
|
92
|
+
* request that came back with nothing — TCC declining to ask, which the
|
|
93
|
+
* hook reports as `'unavailable'` rather than as a refusal.
|
|
94
|
+
*/
|
|
95
|
+
export declare class CalendarAccessError extends Error {
|
|
96
|
+
readonly name: 'CalendarAccessError';
|
|
97
|
+
readonly status: PermissionStatus;
|
|
98
|
+
readonly cause?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A handle on the desktop's calendars. `close()` it. */
|
|
102
|
+
export interface DesktopCalendar {
|
|
103
|
+
readonly backend: CalendarBackend;
|
|
104
|
+
/** The grant, without prompting; `'granted'` on a rung with no such gate. */
|
|
105
|
+
access(): Promise<PermissionStatus>;
|
|
106
|
+
/** Raise the system's prompt where there is one. Reads do this on the
|
|
107
|
+
* first one, so a picker the user never opens never prompts. */
|
|
108
|
+
requestAccess(): Promise<PermissionStatus>;
|
|
109
|
+
/** System Settings › Privacy & Security › Calendars. `false` off macOS. */
|
|
110
|
+
openSettings(): Promise<boolean>;
|
|
111
|
+
listCalendars(): Promise<DesktopCalendarInfo[]>;
|
|
112
|
+
/** Occurrences in `[from, to)`, sorted by start. Rejects with
|
|
113
|
+
* {@link CalendarAccessError} where the user said no. */
|
|
114
|
+
eventsBetween(
|
|
115
|
+
from: Date,
|
|
116
|
+
to: Date,
|
|
117
|
+
options?: EventsOptions,
|
|
118
|
+
): Promise<EventsResult>;
|
|
119
|
+
/** Call `onChange` when something moves; the returned function stops. */
|
|
120
|
+
watch(
|
|
121
|
+
from: Date,
|
|
122
|
+
to: Date,
|
|
123
|
+
onChange: (change: CalendarChange) => void,
|
|
124
|
+
options?: EventsOptions,
|
|
125
|
+
): Promise<() => Promise<void>>;
|
|
126
|
+
close(): Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DesktopCalendarOptions {
|
|
130
|
+
/** The connection whose backend to ask, when there are several. */
|
|
131
|
+
app?: NtkApp;
|
|
132
|
+
/** Pin one rung and fail rather than fall through it. */
|
|
133
|
+
backend?: CalendarBackend;
|
|
134
|
+
/** Reject with {@link NoCalendarServiceError} instead of answering null. */
|
|
135
|
+
required?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The desktop's calendars, on the best rung this machine has — or `null`,
|
|
140
|
+
* which is an ordinary answer about a machine.
|
|
141
|
+
*/
|
|
142
|
+
export declare function desktopCalendar(
|
|
143
|
+
options?: DesktopCalendarOptions,
|
|
144
|
+
): Promise<DesktopCalendar | null>;
|
|
145
|
+
|
|
146
|
+
/** Which rung this machine lands on, without reading anything, spawning
|
|
147
|
+
* anything or prompting. */
|
|
148
|
+
export declare function calendarBackend(
|
|
149
|
+
options?: Pick<DesktopCalendarOptions, 'app'>,
|
|
150
|
+
): Promise<CalendarBackend | null>;
|
|
151
|
+
|
|
152
|
+
/** The local calendar day a `Date` falls on, as `'YYYY-MM-DD'` — the key
|
|
153
|
+
* `byDay` uses and `<Calendar dayContent>` is handed. */
|
|
154
|
+
export declare function dayKey(date: Date): string;
|
|
155
|
+
|
|
156
|
+
/** Occurrences grouped by the local day they appear on. An event lands
|
|
157
|
+
* under every day it touches, not only under its start. */
|
|
158
|
+
export declare function byDay(
|
|
159
|
+
events: DesktopEvent[],
|
|
160
|
+
): Map<string, DesktopEvent[]>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `'idle'` before anything is asked, `'denied'` for the user's refusal and
|
|
164
|
+
* `'unavailable'` for the machine's silence — separate words, because only
|
|
165
|
+
* one of them has a Settings switch behind it.
|
|
166
|
+
*/
|
|
167
|
+
export type DesktopCalendarStatus =
|
|
168
|
+
'idle' | 'loading' | 'ready' | 'denied' | 'unavailable';
|
|
169
|
+
|
|
170
|
+
export interface UseDesktopCalendarEventsOptions {
|
|
171
|
+
/** Start of the window to read, inclusive. */
|
|
172
|
+
from: Date;
|
|
173
|
+
/** End of the window, exclusive. */
|
|
174
|
+
to: Date;
|
|
175
|
+
/** Restrict to these calendar uids. Default: every enabled calendar. */
|
|
176
|
+
calendars?: string[];
|
|
177
|
+
/** Re-query when the desktop says something changed. */
|
|
178
|
+
watch?: boolean;
|
|
179
|
+
/** Set false to hold off entirely — and to not prompt: a picker that is
|
|
180
|
+
* not open yet has no business asking for the user's calendar. */
|
|
181
|
+
enabled?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface UseDesktopCalendarEventsResult {
|
|
185
|
+
events: DesktopEvent[];
|
|
186
|
+
/** The same events, keyed by `'YYYY-MM-DD'`, ready for `dayContent`. */
|
|
187
|
+
byDay: Map<string, DesktopEvent[]>;
|
|
188
|
+
/** Every calendar found, for a legend or a filter. */
|
|
189
|
+
calendars: DesktopCalendarInfo[];
|
|
190
|
+
/** Calendars that would not answer while others did. Not fatal. */
|
|
191
|
+
errors: DesktopCalendarError[];
|
|
192
|
+
status: DesktopCalendarStatus;
|
|
193
|
+
/** Which rung answered, once one has. */
|
|
194
|
+
backend: CalendarBackend | null;
|
|
195
|
+
/** Why there are no events, when there is a reason worth showing. */
|
|
196
|
+
error: Error | null;
|
|
197
|
+
/** Re-query now. */
|
|
198
|
+
refresh: () => void;
|
|
199
|
+
/** System Settings › Privacy & Security › Calendars — for `'denied'`, and
|
|
200
|
+
* for no other status. */
|
|
201
|
+
openSettings: () => Promise<boolean>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The user's desktop calendar events, as rendering state. */
|
|
205
|
+
export declare function useDesktopCalendarEvents(
|
|
206
|
+
options: UseDesktopCalendarEventsOptions,
|
|
207
|
+
): UseDesktopCalendarEventsResult;
|
|
@@ -12,7 +12,9 @@ export type PermissionKind =
|
|
|
12
12
|
| 'accessibility'
|
|
13
13
|
| 'input-monitoring'
|
|
14
14
|
| 'automation'
|
|
15
|
-
| 'location'
|
|
15
|
+
| 'location'
|
|
16
|
+
| 'calendars'
|
|
17
|
+
| 'reminders';
|
|
16
18
|
|
|
17
19
|
/** The panes `openPrivacySettings` reaches: every kind, plus the two with no
|
|
18
20
|
* API because reading the folder is the prompt. */
|
|
@@ -23,10 +25,12 @@ export type PrivacyPane =
|
|
|
23
25
|
* `'granted'`, `'denied'` and `'restricted'` (MDM or parental controls — the
|
|
24
26
|
* user cannot grant it) are the platform's; `'prompt'` is not decided yet, a
|
|
25
27
|
* request would ask; `'unknown'` is "nothing here can say", a fact about the
|
|
26
|
-
* machine rather than the permission.
|
|
28
|
+
* machine rather than the permission. `'write-only'` is macOS 14's partial
|
|
29
|
+
* grant for `calendars`/`reminders`: a grant to a writer, a refusal to a
|
|
30
|
+
* reader, and only the caller knows which it is.
|
|
27
31
|
*/
|
|
28
32
|
export type PermissionStatus =
|
|
29
|
-
'granted' | 'denied' | 'restricted' | 'prompt' | 'unknown';
|
|
33
|
+
'granted' | 'denied' | 'restricted' | 'prompt' | 'write-only' | 'unknown';
|
|
30
34
|
|
|
31
35
|
export interface PermissionOptions {
|
|
32
36
|
/** The connection whose backend to ask, when there are several. */
|
|
@@ -34,6 +38,10 @@ export interface PermissionOptions {
|
|
|
34
38
|
/** `automation` only: the bundle id of the app to send Apple Events to.
|
|
35
39
|
* Only a running target has an answer. */
|
|
36
40
|
target?: string;
|
|
41
|
+
/** `calendars` only: which grant to ask for. `'write-only'` is macOS 14's
|
|
42
|
+
* narrower prompt — the app may save events it cannot read. `reminders`
|
|
43
|
+
* has no such grant and refuses one. */
|
|
44
|
+
access?: 'full' | 'write-only';
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/**
|
|
@@ -96,5 +104,5 @@ export interface Permission {
|
|
|
96
104
|
/** A permission for a component: the status as render state. */
|
|
97
105
|
export declare function usePermission(
|
|
98
106
|
kind: PermissionKind,
|
|
99
|
-
options?: Pick<PermissionOptions, 'target'>,
|
|
107
|
+
options?: Pick<PermissionOptions, 'target' | 'access'>,
|
|
100
108
|
): Permission;
|