The desktop's calendar
The user's real events — iCloud, Google, Microsoft, CalDAV, a subscribed feed, the local one — without asking for a single credential and without an OAuth flow, because the desktop already did all of that. Every account in System Settings › Internet Accounts, every account in GNOME's Online Accounts, is in a store this can read.
import { useDesktopCalendarEvents } from 'react-x11';
function Month({ from, to }) {
const { byDay, status, openSettings } = useDesktopCalendarEvents({
from,
to,
watch: true,
});
if (status === 'unavailable') return null; // no calendar on this machine
if (status === 'denied')
return <Button label="Open Settings" onPress={openSettings} />;
return (
<Calendar
dayContent={(day) =>
byDay
.get(day)
?.slice(0, 3)
.map((ev) => (
<box
key={ev.uid}
style={{
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: ev.calendar.color ?? '$accent',
}}
/>
))
}
/>
);
}
<Calendar> is the grid from @react-x11/components; the
'YYYY-MM-DD' keys of byDay are the contract between the two. Nothing here
draws anything.
The ladder
Chosen in order, like the file dialog's, never by naming a backend:
| 1 | EventKit through the bridge | The cocoa backend with @windowkit/appkit >= 0.8. One EKEventStore for every account, recurrences expanded by the framework, and one notification for any change. |
| 2 | EventKit through osascript | A Mac with no bridge — every XQuartz install, and a cocoa app over an older bridge. A long-lived osascript -l JavaScript child holds the same store. Same framework, slower transport. |
| 3 | EDS over the session bus | org.gnome.evolution.dataserver.Sources5 / Calendar8 — the store every GNOME calendar UI reads, and the one GNOME Online Accounts feeds. Recurrences are expanded here, with ical.js. |
| 4 | nothing | desktopCalendar() answers null and the hook says 'unavailable'. An ordinary answer about a machine, not a failure. |
await calendarBackend(); // 'cocoa' | 'osascript' | 'eds' | null
calendarBackend() reads nothing, spawns nothing and prompts nothing, so it
is safe on a settings screen that wants to say where the events come from.
Rung 2 outranks rung 3 on a Mac: EventKit is that machine's real store, and an Evolution Data Server installed beside it would be a second, emptier one.
The imperative half
import { desktopCalendar } from 'react-x11';
const cal = await desktopCalendar(); // DesktopCalendar | null, never rejects
if (!cal) return; // nothing on this machine
try {
const calendars = await cal.listCalendars();
const { events, errors } = await cal.eventsBetween(from, to, { calendars });
const stop = await cal.watch(from, to, () => reload());
} finally {
await cal.close();
}
Call close() on every rung; it releases what the rung holds (EDS views, this
handle's share of the osascript child, the bus reference). useDesktopCalendarEvents does it for
you.
{ required: true } turns the null into a typed NoCalendarServiceError
for a caller who would rather branch in a catch, and { backend } pins one
rung and fails rather than falling past it — for a test, or a "why is this
slow" investigation.
One shape, whichever rung answered
interface DesktopCalendarInfo {
uid: string;
name: string;
enabled: boolean; // always true on macOS: no such state to report
color?: string; // '#3584e4'
backend?: string; // 'caldav' | 'local' | 'exchange' | 'subscription' | …
readOnly: boolean;
account?: string; // the Online Accounts / EKSource entry it came from
}
interface DesktopEvent {
uid: string;
summary: string;
location?: string;
description?: string;
start: Date;
end: Date; // exclusive
allDay: boolean;
recurring: boolean;
calendar: { uid: string; name: string; color?: string };
}
endis exclusive on every rung — an all-day event ends at the next midnight (the macOS rungs normalise EventKit's 23:59:59), sobyDaymay assume it.- A change is "something moved", not a patch.
watchreports that the range changed; the only correct answer is to query again. - "Not allowed to look" is not "no events". Without the grant a read rejects; it never returns an empty list.
byDay
const days = byDay(events); // Map<'YYYY-MM-DD', DesktopEvent[]>
days.get(dayKey(new Date())); // today's
Keyed by local day, because a grid draws the user's days. An event lands
under every day it touches, not only under its start — an all-day span
crosses several and a timed one can cross midnight. The hook returns the same
map as byDay on its result, already memoised.
The grant
Only the macOS rungs have one; EDS is reachable or it is not.
await cal.access(); // the permission vocabulary; never prompts
await cal.requestAccess(); // the system's prompt, once
await cal.openSettings(); // System Settings › Privacy & Security › Calendars
The first read asks. listCalendars() and eventsBetween() raise the
prompt when the status is 'prompt' — so a picker the user never opens never
prompts, and enabled: false on the hook is how a button defers it. A refusal
is a typed CalendarAccessError carrying the status, never an empty list.
'denied' is the user's answer and 'unavailable' is the machine's.
Show an Open Settings button on 'denied' and on nothing else
(permissions.md has the same rule for 'unknown').
'write-only' — macOS 14's partial grant — is a refusal to a reader, so it
rejects like a denial, but by its own name: an app that also saves events can
tell the two apart.
Attribution, and what raises the prompt
macOS keys the grant on the process that owns the process tree, and the dialog names that process:
- The
osascriptrung's grant is shared with everything that terminal or IDE runs — not with this app alone. Two react-x11 apps started from the same terminal share one answer; the same app launched from Finder asks again. - A bare
nodeon the bridge rung lands in the same place: attributed to its responsible process, or tonodeitself where it has none. - A bundled app is its own responsible process, which is the only way to
get a prompt that names the app and a grant that is the app's — and it must
carry
NSCalendarsFullAccessUsageDescription(plusNSCalendarsWriteOnlyAccessUsageDescriptionwhere it asks for the narrower grant) or the request never prompts. See packaging.md.
Reading the status and opening the store never prompt; only a request (or the first read) does.
useDesktopCalendarEvents()
const {
events,
byDay,
calendars,
errors,
status,
backend,
error,
refresh,
openSettings,
} = useDesktopCalendarEvents({ from, to, calendars?, watch?, enabled? });
status | |
|---|---|
'idle' | enabled: false — nothing has been asked, including the grant |
'loading' | a read is in flight; the first one may be showing the system's prompt |
'ready' | events is this range |
'denied' | the user's answer. openSettings() is the way back |
'unavailable' | the machine's answer: no rung — or a request TCC would not even ask (above). Hide the feature rather than reporting it |
errors is none of those: calendars that would not answer while others did
(one unreachable CalDAV server does not blank the user's local calendar). It
is always empty on the macOS rungs, where there is one store.
from and to are read by their timestamps, not their identity, so
from={new Date(y, m, 1)} inline in the render body is fine and does not
re-query every paint.
What differs between the rungs
- Recurrences. EventKit expands them; EDS answers with unexpanded masters
and
ical.jsexpands them here. A malformedRRULEwith noUNTILis guarded at 2000 occurrences on that rung. enabled. EDS reports whether the user switched a calendar off; EventKit has no such state (a calendar unchecked in Calendar.app is still in the store), so it is alwaystruethere.- The change signal. EDS names the calendar, the kind (
ObjectsAdded,ObjectsModified,ObjectsRemoved) and a count; EventKit's is one'changed'with a null calendar and no count. - The range. EventKit's predicate spans at most four years, so a longer one is asked for in pieces and merged (an occurrence on a seam is reported once). EDS takes any range.
- What the app is on the bus. The EDS rung borrows react-x11's shared session bus (dbus.md), so an app is one name on the bus however many features it turns on.
ical.js
A regular dependency (MPL-2.0, no native code, no install script), loaded
lazily and only by the EDS rung, through a static import('ical.js') that a
bundler or single-executable build can follow.
Writing
Not supported yet: this module reads.
Design notes: design/desktop-calendar.md.