feat(calendar): Apple Calendar (EventKit) integration for macOS#1237
feat(calendar): Apple Calendar (EventKit) integration for macOS#1237gabrielste1n wants to merge 6 commits into
Conversation
Add a second calendar provider that reads the local EventKit store — covering iCloud, Google, Exchange, and Outlook accounts already in Calendar.app — with one native permission prompt and push-based updates instead of OAuth + polling. - New macos-calendar-listener Swift helper (mic-listener pattern): emits permission + calendars/events snapshots as line-delimited JSON, re-emits on EKEventStoreChanged, stdin "sync", and a 5-min window roll; --request triggers the TCC prompt attributed to OpenWhispr - Build script patches dev Electron's Info.plist with calendar usage strings; packaged builds get them via electron-builder extendInfo, plus the calendars entitlement for hardened runtime - calendar_events gains a provider column; new apple_calendars table; Google deletes are provider-scoped; shared read queries suppress Apple copies of Google-mirrored meetings (datetime-normalized, Google rows never collapsed) so reminders fire once per meeting - Meeting reminder scheduling extracted from GoogleCalendarManager into a provider-agnostic CalendarReminderScheduler shared by both managers - AppleCalendarManager owns the helper lifecycle; meeting join links are extracted from url/location/notes into hangout_link at sync time so Join buttons and note linking work unchanged - Integrations UI gets an Apple Calendar row (macOS only) with connect, denied → System Settings flow, source list, and disconnect; i18n keys added to all 10 locales Downstream consumers (Upcoming Meetings, overlay reminders, agent calendar tool, note linking) read the shared table and work for both providers with no changes.
There was a problem hiding this comment.
Review fixes — Apple Calendar (EventKit) integration
Correctness
[P1] Apple meeting links were deleted after meetings ended. Snapshots only cover Date() forward, and the old replace logic wiped every stored Apple event first — so after the next 5-minute refresh, a finished meeting's calendar_event_id pointed at nothing and linked notes lost their calendar metadata. replaceAppleCalendarEvents now retains events still referenced by non-deleted notes (database.js:2386), with a regression test covering retention vs. pruning (calendarDatabase.test.js).
[P2] Disconnecting one calendar provider repeated the other's reminders. Disconnect used to reset the shared scheduler wholesale, clearing notification history for both providers and re-prompting an already-dismissed active meeting (reproduced both directions). The scheduler's reset(provider) is now provider-scoped (calendarReminderScheduler.js:118): it clears only that provider's delivered-reminder keys and active meeting, so Google reminders survive an Apple disconnect and vice versa. Tested in calendarReminderScheduler.test.js.
[P2] Apple-only users saw "Sign in required." The Upcoming Meetings empty state checked app sign-in status even when the events source was local Apple Calendar. It now treats a connected Apple Calendar as sufficient and shows the normal "no more meetings" state (UpcomingMeetings.tsx:113).
[P2] A helper crash silently left Calendar permanently stale. An unexpected helper exit previously just cleared the process reference — the UI kept saying "Connected" while sync became a no-op until app restart. The manager now schedules an automatic restart with exponential backoff (1s → 30s cap) whenever the helper dies while a connection exists, and resets the backoff on each successful snapshot (appleCalendarManager.js:184). Tests cover crash-restart and deliberate-stop paths (appleCalendarManager.test.js).
Code quality (follow-up review pass)
Accurate connect-failure UX: connect failures are routed by reason — only a real permission denial opens the Privacy-settings dialog; helper-missing/snapshot errors show a generic failure dialog instead of a misleading permissions prompt (IntegrationsView.tsx:91), with new connectFailed strings in all 10 locales.
Removed speculative/dead code: the is_selected column and getSelectedAppleCalendars() (no UI ever deselects a calendar), the unused AppleCalendar interface, and an unused windowManager constructor param.
Deduplicated the macOS Swift build scripts: calendar, mic, globe, and text-monitor scripts are now thin wrappers over a shared build lib (scripts/lib/build-macos-swift-binary.js) — ~740 duplicated lines removed; the calendar script keeps its dev-Electron plist patch as a hook.
Extracted shared main-process helpers: bundled-binary resolution (binaryResolver.js) and window broadcasting (windowBroadcast.js), replacing four identical broadcastToWindows implementations across ~40 call sites.
Observability: acal-get-connection-status failures are now logged instead of silently swallowed.
Summary
Adds Apple Calendar as a second calendar provider on macOS, reading the local EventKit store that Calendar.app already aggregates (iCloud, Google, Exchange, Outlook). Connecting is a single native permission prompt — no OAuth, tokens, or API quotas — and updates arrive via
EKEventStoreChangedpush instead of 2-minute polling.Everything downstream of sync (meeting reminders, the notification overlay, Upcoming Meetings, the agent's calendar tool, note linking) reads the provider-neutral
calendar_eventstable, so both providers flow through unchanged consumers.How it works
Native helper —
resources/macos-calendar-listener.swift, built byscripts/build-macos-calendar-listener.jsfollowing the existing mic-listener pattern. It emits line-delimited JSON: apermissionmessage on launch, then fullsnapshotmessages (calendars + 7-day event window) on store changes, on"sync"via stdin (focus/wake), and on a 5-minute window-roll timer.--requesttriggers the TCC prompt. The build script also patches dev Electron's Info.plist with the calendar usage strings (packaged builds get them viaextendInfo+ the calendars entitlement).Database —
calendar_eventsgains aprovidercolumn ('google'default); newapple_calendarstable mirrorsgoogle_calendars(source_nameinstead of account email). Google-side deletes are now provider-scoped so the two providers can't clobber each other. Apple syncs are full snapshot replaces, which also handles deletions.Cross-provider dedupe — when a user connects Google and Apple (with Calendar.app mirroring the same Google account), every meeting would exist twice. The shared read queries suppress Apple rows whose time slot + title match a Google row, with
datetime()normalization since the providers store different timestamp formats. Google rows are never collapsed, so Google-only behavior is byte-identical to main.Reminder scheduling — extracted from
GoogleCalendarManagerinto a provider-agnosticCalendarReminderSchedulershared by both managers (it only reads the deduped DB queries), so one scheduler fires each reminder exactly once. Pure refactor for Google-only users.Join links — EventKit has no structured conference data; a new unit-tested
extractMeetingUrl()pulls Zoom/Meet/Teams/Webex/Chime links out ofurl/location/notesat sync time into the existinghangout_linkcolumn, so every Join button and note-creation flow works unchanged.UI — Apple Calendar row in Settings → Integrations (macOS only), mirroring the Google row: connect with the same system-audio pre-gate, permission-denied dialog that deep-links to Privacy & Security → Calendars, connected sources sub-row, and disconnect confirmation. i18n keys added to all 10 locales.
Testing
npm run lint,cd src && tsc --noEmit,node --test(561 tests),npm run i18n:checkall greenis_selectedsurviving snapshot prunesNotes for reviewers
apple_calendars.is_selectedexists in the schema and is honored by the sync filter for future parity.