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.
@@ -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';
@@ -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 }),
@@ -23,18 +23,24 @@
23
23
  //
24
24
  // ## Two things the vocabulary decides
25
25
  //
26
- // - A status is one of five words. `'granted'`, `'denied'` and
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;