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>
63 lines
2.6 KiB
Swift
63 lines
2.6 KiB
Swift
import Foundation
|
|
|
|
/// Cross-process "the snapshot changed" broadcast.
|
|
///
|
|
/// Two sandboxed apps cannot see each other's `NotificationCenter`, and neither
|
|
/// can poll the container cheaply. Darwin notifications are the one signal that
|
|
/// crosses the process boundary without an extra entitlement, a running
|
|
/// connection, or a shared framework.
|
|
///
|
|
/// The payload is always empty — Darwin notifications carry no data. The signal
|
|
/// means only "read again". If it is ever missed, a consumer that also reloads
|
|
/// on window activation degrades to a slightly stale view rather than a wrong one.
|
|
public enum SnapshotChangeNotifier {
|
|
public static let name = "com.scarriffleservices.calendarr.snapshot-changed"
|
|
|
|
/// Announce that the snapshot was rewritten. Called by `SnapshotStore`.
|
|
public static func post() {
|
|
CFNotificationCenterPostNotification(
|
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
|
CFNotificationName(name as CFString),
|
|
nil, nil, true)
|
|
}
|
|
|
|
/// Start listening. The handler runs on an arbitrary thread, so hop to
|
|
/// whatever actor your UI needs before touching state.
|
|
///
|
|
/// Returns a token you must keep alive; deinit removes the observer.
|
|
public static func observe(_ handler: @escaping @Sendable () -> Void) -> ObservationToken {
|
|
ObservationToken(handler: handler)
|
|
}
|
|
|
|
public final class ObservationToken: @unchecked Sendable {
|
|
private let handler: @Sendable () -> Void
|
|
|
|
fileprivate init(handler: @escaping @Sendable () -> Void) {
|
|
self.handler = handler
|
|
// Darwin notifications hand back no context pointer we can rely on
|
|
// across process boundaries, so route through an unretained pointer
|
|
// to this token and look it up in the callback.
|
|
let observer = Unmanaged.passUnretained(self).toOpaque()
|
|
CFNotificationCenterAddObserver(
|
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
|
observer,
|
|
{ _, observer, _, _, _ in
|
|
guard let observer else { return }
|
|
Unmanaged<ObservationToken>.fromOpaque(observer)
|
|
.takeUnretainedValue().handler()
|
|
},
|
|
SnapshotChangeNotifier.name as CFString,
|
|
nil,
|
|
.deliverImmediately)
|
|
}
|
|
|
|
deinit {
|
|
CFNotificationCenterRemoveObserver(
|
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
|
Unmanaged.passUnretained(self).toOpaque(),
|
|
CFNotificationName(SnapshotChangeNotifier.name as CFString),
|
|
nil)
|
|
}
|
|
}
|
|
}
|