Add CalendarrCore: the shared read contract for the calendar snapshot

Calendarr already publishes an event snapshot into its App Group container for
the widgets to render. That channel is what a second Mac app needs too, so this
turns an internal detail into a documented contract rather than something a
second app reverse-engineers and then drifts from.

Foundation only — no SwiftUI, no UIKit, no AppKit — so one source compiles for
iOS, Mac Catalyst and native macOS. Building against the native macOS SDK is the
point: it fails loudly on a UIKit leak that a Catalyst build would accept.

Three things earn their place here rather than being copied per app:

CalendarrAppGroup resolves the container identifier per platform. macOS requires
the Team ID prefix and iOS forbids it, and a mismatch fails silently — nil
container, every read and write a no-op, widgets blank forever.

CalendarKey normalises the two ways the server reports calendar_id (bare for
CalDAV, "local-3" style for everything else). Normalising them differently is
what made per-calendar filtering quietly do nothing for some sources.

The snapshot schema is versioned, with the coverage window now explicit. That
window is the easiest thing to get wrong downstream: outside it the snapshot
carries no information, which is not the same as no events, and rendering an
empty week for an uncovered date produces a screen that looks right and is
false. covers() exists so consumers can tell the two apart.

Reads return a named result rather than an optional. "Never ran", "signed out",
"too new to read" and "unreadable" need different messages, and a bare nil is
how the App Group misconfiguration used to hide.

Writing is behind @_spi(Writer) so a read-only consumer cannot corrupt the
shared cache. Writes post a Darwin notification, the one cross-process signal
that works between two sandboxed apps without an extra entitlement.

The six colour values stay flat on the wire instead of nesting under `theme`,
which would read better. A v1 reader requires them flat, so nesting would have
broken every older build on the first v2 write; `theme` is a computed view.
Tests pin both directions of compatibility, since the two apps ship
independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-10 17:30:23 +02:00
co-authored by Claude Opus 5
commit ab2328969e
11 changed files with 920 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
# CalendarrKit
Shared code between Calendarr (iOS / Mac Catalyst) and other apps from the same
developer team that want to **read** the user's calendar.
`CalendarrCore` depends on Foundation only — no SwiftUI, no UIKit, no AppKit — so
it compiles for iOS, Mac Catalyst and native macOS from one source. That
constraint is load-bearing: `swift build` against the native macOS SDK is what
catches a UIKit import before it reaches a consumer, because a Mac Catalyst build
would compile it happily.
## What a reading app gets
```swift
import CalendarrCore
let store = SnapshotStore() // uses CalendarrAppGroup.current
switch store.read() {
case .ok(let snap):
snap.events // [SnapshotEvent]
snap.theme // the colours Calendarr is currently rendering with
snap.writtenAt // "as of 14:32"
snap.coverageStart // see "Coverage" below
snap.coverageEnd
case .neverWritten: // Calendarr has never run here → "open Calendarr once"
case .loggedOut: // signed out → "sign in to Calendarr", discard old events
case .incompatible: // Calendarr is newer than this package → "update the app"
case .unreadable(let why): // almost always a wrong App Group; log `why`
}
store.readCalendars() // [SnapshotCalendar] for a filter UI
```
React to changes without polling:
```swift
let token = SnapshotChangeNotifier.observe { /* hop to your actor, read again */ }
// keep `token` alive; releasing it removes the observer
```
Also reload on window activation — the notification is a best-effort signal, and
missing one should leave you slightly stale, never wrong.
## Coverage — the part that bites
The snapshot is not the whole calendar. Outside `coverageStart..<coverageEnd` it
carries **no information**, which is not the same as "no events". Rendering an
empty March because it is outside the window produces a screen that looks correct
and is false. Use `snapshot.covers(date)` and grey out what you cannot speak for.
| | |
|---|---|
| Window | ~7 days back, ~42 days ahead, frozen at write time |
| Cap | 500 events, sorted by start |
| Filters | hidden and "banished" calendars are already excluded — you see the user's filtered view |
| Scope | personal only; group overlays are never published, deliberately |
| Dropped | notes, url, source, creator, privacy, read-only, reminders, recurrence |
What remains is enough to *display* a calendar and deliberately not enough to
edit one or reason about permissions.
## Setup for a consuming app
Same developer team (`PP34X97WS3`) is required — that is what makes the container
and the keychain group shared.
```xml
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>PP34X97WS3.group.com.scarriffleservices.calendarr</string></array>
```
The Team ID prefix is mandatory on macOS and forbidden on iOS. `CalendarrAppGroup.current`
resolves the right one; it must match the entitlement byte for byte, or
`containerURL(…)` returns nil and every read silently yields nothing.
Never sign in from a consuming app. Credentials, 2FA and token storage belong to
Calendarr alone.
## Wire format rules
`widget-cache.json`, `widget-calendars.json` and `session.json` live in the group
container and are read by apps that ship on independent schedules.
1. Filenames do not change within a major version, so an older build keeps reading
after the writer updates.
2. Readers refuse `schemaVersion > maxSupportedSchema` instead of guessing.
3. Writers only ever *add* optional fields. Never rename, remove or retype one.
4. A breaking change means a new major version *and* a new filename, with the old
file still written for one full release cycle.
5. The package version is the contract: `CalendarrKit 1.x` ⇔ snapshot schema 2.
Depend on `.upToNextMajor(from: "1.0.0")`.
Rule 3 is why the six colour values sit flat at the top level instead of nested
under `theme`, which would read better: a v1 reader requires them flat, so nesting
would have broken every older build on the first v2 write. `theme` is a computed
view over them. `SnapshotSchemaTests` pins this down in both directions.
## Verify
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # native macOS — catches UIKit leaks
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
```