From ab2328969e1d1062bf4cadc45238c1785e8c35c4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 17:30:23 +0200 Subject: [PATCH 1/9] Add CalendarrCore: the shared read contract for the calendar snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + Package.swift | 33 +++ README.md | 105 +++++++++ Sources/CalendarrCore/AppGroup.swift | 31 +++ Sources/CalendarrCore/CalendarKey.swift | 30 +++ .../CalendarrCore/Session/SharedSession.swift | 85 +++++++ .../Snapshot/SnapshotChangeNotifier.swift | 62 +++++ .../Snapshot/SnapshotModels.swift | 217 ++++++++++++++++++ .../Snapshot/SnapshotStore.swift | 129 +++++++++++ .../CalendarrCoreTests/CalendarKeyTests.swift | 51 ++++ .../SnapshotSchemaTests.swift | 172 ++++++++++++++ 11 files changed, 920 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/CalendarrCore/AppGroup.swift create mode 100644 Sources/CalendarrCore/CalendarKey.swift create mode 100644 Sources/CalendarrCore/Session/SharedSession.swift create mode 100644 Sources/CalendarrCore/Snapshot/SnapshotChangeNotifier.swift create mode 100644 Sources/CalendarrCore/Snapshot/SnapshotModels.swift create mode 100644 Sources/CalendarrCore/Snapshot/SnapshotStore.swift create mode 100644 Tests/CalendarrCoreTests/CalendarKeyTests.swift create mode 100644 Tests/CalendarrCoreTests/SnapshotSchemaTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..000dfed --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fa49347 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CalendarrKit", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .macCatalyst(.v17), + ], + products: [ + // Foundation only — deliberately no SwiftUI, no UIKit, no AppKit, so it + // compiles for iOS, Mac Catalyst and native macOS from one source. + .library(name: "CalendarrCore", targets: ["CalendarrCore"]), + ], + targets: [ + .target( + name: "CalendarrCore", + // Matches the apps, which build in Swift 5 language mode. Note that + // this package deliberately does NOT set defaultIsolation(MainActor), + // even though the Calendarr iOS app target does: the widget extension + // and the read-only consumer app have no MainActor default, and making + // the package MainActor-by-default would force `await` at every + // SnapshotStore.read() call site. + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "CalendarrCoreTests", + dependencies: ["CalendarrCore"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e206dd2 --- /dev/null +++ b/README.md @@ -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..