Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'

git-subtree-dir: CalendarrKit
git-subtree-mainline: 8bc32faa7f
git-subtree-split: 9ea1a8705d
This commit is contained in:
Scarriffle
2026-10-19 05:34:15 +02:00
21 changed files with 1672 additions and 0 deletions
@@ -0,0 +1,62 @@
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)
}
}
}
@@ -0,0 +1,32 @@
import Foundation
extension CalendarrSnapshot {
/// Keep only the listed calendars. An empty set means "every calendar",
/// which is what an unconfigured widget or complication asks for.
///
/// Filtering narrows the events but deliberately not the window: the
/// snapshot still speaks for the same range, it just has fewer calendars
/// in it.
public func filtered(toCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { keys.contains($0.calendarKey) })
}
/// Remove the listed calendars, keeping everything else. An empty set is a
/// no-op.
public func filtered(excludingCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { !keys.contains($0.calendarKey) })
}
private func replacingEvents(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: writtenAt,
coverageStart: coverageStart,
coverageEnd: coverageEnd,
isLoggedIn: isLoggedIn,
writerVersion: writerVersion,
events: events,
theme: theme,
language: language)
}
}
@@ -0,0 +1,223 @@
import Foundation
// MARK: - Coverage policy
/// How much of the calendar the writer publishes. Shared so the writer and the
/// v1-file reconstruction below can never disagree about it.
public enum SnapshotCoverage {
/// Days before today. The current week must stay complete for widgets that
/// render Monday–today.
public static let daysBehind = 7
/// Days after today.
public static let daysAhead = 42
/// Hard cap on events written, applied after sorting by start date.
public static let maxEvents = 500
}
// MARK: - Event
/// One event as published to the shared container.
///
/// Deliberately lossy: notes, URL, source, creator, privacy, read-only status,
/// reminders and recurrence rules are all dropped. This is enough to *display* a
/// calendar and deliberately not enough to edit one or reason about permissions.
public struct SnapshotEvent: Codable, Sendable, Identifiable, Hashable {
public let id: String
public let title: String
public let start: Date
public let end: Date
public let isAllDay: Bool
public let colorHex: String
public let location: String
/// Ties the event to a calendar. See `CalendarKey`.
public let calendarKey: String
public init(id: String, title: String, start: Date, end: Date,
isAllDay: Bool, colorHex: String, location: String, calendarKey: String) {
self.id = id; self.title = title; self.start = start; self.end = end
self.isAllDay = isAllDay; self.colorHex = colorHex
self.location = location; self.calendarKey = calendarKey
}
// Lenient: caches written before calendarKey existed simply lack the field.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
title = try c.decode(String.self, forKey: .title)
start = try c.decode(Date.self, forKey: .start)
end = try c.decode(Date.self, forKey: .end)
isAllDay = try c.decode(Bool.self, forKey: .isAllDay)
colorHex = try c.decode(String.self, forKey: .colorHex)
location = try c.decode(String.self, forKey: .location)
calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? ""
}
}
// MARK: - Calendar
/// A calendar available for filtering, published alongside the events so a
/// consumer can build a picker without a network call.
public struct SnapshotCalendar: Codable, Sendable, Identifiable, Hashable {
/// A `CalendarKey`, matching `SnapshotEvent.calendarKey`.
public let id: String
public let name: String
public let colorHex: String
public init(id: String, name: String, colorHex: String) {
self.id = id; self.name = name; self.colorHex = colorHex
}
}
// MARK: - Theme
/// The colours the app is currently rendering with, so a consumer can match it
/// without reading the app's own preferences.
public struct SnapshotTheme: Sendable, Hashable {
public let today: String
public let text: String
public let background: String
public let line: String
public let primary: String
public let accent: String
public init(today: String, text: String, background: String,
line: String, primary: String, accent: String) {
self.today = today; self.text = text; self.background = background
self.line = line; self.primary = primary; self.accent = accent
}
}
// MARK: - Snapshot
/// The published calendar snapshot.
///
/// ## Wire compatibility
///
/// The six colour values are stored **flat** at the top level rather than nested
/// under `theme`, even though `theme` is the nicer thing to read in code. That is
/// deliberate: v1 files carry them flat and a v1 reader requires them flat, so
/// nesting would break older readers on the very first v2 write. Within a major
/// version this format only ever gains optional fields — old readers ignore what
/// they do not know, new readers default what is missing. `theme` is a computed
/// view over the stored values, so callers get the clean shape anyway.
public struct CalendarrSnapshot: Codable, Sendable {
/// Bump only for a breaking change, and see the rules in the README.
public static let currentSchema = 2
/// Files claiming a higher schema are refused rather than mis-decoded.
public static let maxSupportedSchema = 2
public let schemaVersion: Int
public let writtenAt: Date
/// Start of the published window. Outside `coverageStart..<coverageEnd` the
/// snapshot carries *no information* — which is not the same as "no events".
public let coverageStart: Date
public let coverageEnd: Date
/// False once the user signs out. Distinguishes "signed out" from "never ran".
public let isLoggedIn: Bool
/// CFBundleShortVersionString of whoever wrote the file. Diagnostics only.
public let writerVersion: String
public let events: [SnapshotEvent]
public let language: String
// Stored flat for wire compatibility — read them via `theme`.
let todayColorHex: String
let textColorHex: String
let backgroundColorHex: String
let lineColorHex: String
let primaryColorHex: String
let accentColorHex: String
public var theme: SnapshotTheme {
SnapshotTheme(today: todayColorHex, text: textColorHex,
background: backgroundColorHex, line: lineColorHex,
primary: primaryColorHex, accent: accentColorHex)
}
/// True when `date` falls inside the published window, i.e. when an absence
/// of events for that date genuinely means "nothing scheduled".
public func covers(_ date: Date) -> Bool {
date >= coverageStart && date < coverageEnd
}
public init(writtenAt: Date,
coverageStart: Date,
coverageEnd: Date,
isLoggedIn: Bool,
writerVersion: String,
events: [SnapshotEvent],
theme: SnapshotTheme,
language: String) {
self.schemaVersion = Self.currentSchema
self.writtenAt = writtenAt
self.coverageStart = coverageStart
self.coverageEnd = coverageEnd
self.isLoggedIn = isLoggedIn
self.writerVersion = writerVersion
self.events = events
self.language = language
self.todayColorHex = theme.today
self.textColorHex = theme.text
self.backgroundColorHex = theme.background
self.lineColorHex = theme.line
self.primaryColorHex = theme.primary
self.accentColorHex = theme.accent
}
/// Lenient decoder. Everything added after v1 is optional here, and a v1
/// file's coverage window is reconstructed from the policy that produced it.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
events = try c.decode([SnapshotEvent].self, forKey: .events)
language = try c.decode(String.self, forKey: .language)
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
textColorHex = try c.decode(String.self, forKey: .textColorHex)
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
writerVersion = try c.decodeIfPresent(String.self, forKey: .writerVersion) ?? "unknown"
// v1 had no explicit window; it always wrote the same one, measured from
// the start of the day it was written.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: writtenAt)
coverageStart = try c.decodeIfPresent(Date.self, forKey: .coverageStart)
?? cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? dayStart
coverageEnd = try c.decodeIfPresent(Date.self, forKey: .coverageEnd)
?? cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? dayStart
}
enum CodingKeys: String, CodingKey {
case schemaVersion, writtenAt, coverageStart, coverageEnd, isLoggedIn, writerVersion
case events, language
case todayColorHex, textColorHex, backgroundColorHex, lineColorHex
case primaryColorHex, accentColorHex
}
}
// MARK: - Read result
/// Why a read produced no usable snapshot. The distinction matters for the UI:
/// "never ran", "signed out" and "genuinely empty" call for different messages.
public enum SnapshotReadResult: Sendable {
case ok(CalendarrSnapshot)
/// The user signed out of Calendarr. Show a sign-in prompt, not stale events.
case loggedOut(writtenAt: Date)
/// Calendarr has never published on this machine. Ask the user to open it once.
case neverWritten
/// Written by a newer Calendarr than this reader understands. Refuse rather
/// than guess; ask the user to update.
case incompatible(foundSchema: Int, maxSupported: Int)
/// Corrupt file, or — much more likely — a misconfigured App Group.
case unreadable(String)
public var snapshot: CalendarrSnapshot? {
if case .ok(let s) = self { return s }
return nil
}
}
@@ -0,0 +1,139 @@
import Foundation
public enum SnapshotStoreError: Error, LocalizedError {
case containerUnavailable(appGroupID: String)
public var errorDescription: String? {
switch self {
case .containerUnavailable(let id):
return "App Group container unavailable for \(id). Check that "
+ "com.apple.security.application-groups in this target's "
+ "entitlements matches exactly, and that macOS builds use the "
+ "Team ID prefix."
}
}
}
/// Reads — and, for Calendarr itself, writes — the shared calendar snapshot.
///
/// Writing is gated behind `@_spi(Writer)` so a read-only consumer cannot
/// corrupt the shared cache even by accident. Calendarr imports the package as
/// `@_spi(Writer) import CalendarrCore`; everyone else gets read access only.
public struct SnapshotStore: Sendable {
private static let cacheFilename = "widget-cache.json"
private static let calendarsFilename = "widget-calendars.json"
public let appGroupID: String
public init(appGroupID: String = CalendarrAppGroup.current) {
self.appGroupID = appGroupID
}
private var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}
private static func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}
// MARK: - Reading
/// Load the published snapshot.
///
/// Never returns a bare nil: every failure mode is named, because they call
/// for different things in the UI and because a silent nil is precisely how
/// a misconfigured App Group used to hide itself.
public func read() -> SnapshotReadResult {
guard let container = containerURL else {
return .unreadable(SnapshotStoreError
.containerUnavailable(appGroupID: appGroupID).localizedDescription)
}
let url = container.appendingPathComponent(Self.cacheFilename)
guard FileManager.default.fileExists(atPath: url.path) else {
// No cache. Signing out deletes the events — keeping them would leak
// the previous user's calendar — so the session file is what tells
// "signed out" apart from "never ran here". Answering that in one
// call keeps every consumer from reimplementing the distinction.
if let session = SharedSessionStore(appGroupID: appGroupID).read(),
!session.isLoggedIn {
return .loggedOut(writtenAt: session.writtenAt)
}
return .neverWritten
}
let data: Data
do { data = try Data(contentsOf: url) }
catch { return .unreadable("Could not read \(Self.cacheFilename): \(error.localizedDescription)") }
// Check the version before decoding: a newer writer may have changed the
// meaning of a field we think we understand, so refusing beats guessing.
if let probe = try? Self.decoder().decode(SchemaProbe.self, from: data) {
let found = probe.schemaVersion ?? 1
if found > CalendarrSnapshot.maxSupportedSchema {
return .incompatible(foundSchema: found,
maxSupported: CalendarrSnapshot.maxSupportedSchema)
}
}
do {
let snapshot = try Self.decoder().decode(CalendarrSnapshot.self, from: data)
guard snapshot.isLoggedIn else { return .loggedOut(writtenAt: snapshot.writtenAt) }
return .ok(snapshot)
} catch {
return .unreadable("Could not decode \(Self.cacheFilename): \(error)")
}
}
/// The calendars available for filtering. Empty when nothing is published.
public func readCalendars() -> [SnapshotCalendar] {
guard let url = containerURL?.appendingPathComponent(Self.calendarsFilename),
let data = try? Data(contentsOf: url) else { return [] }
return (try? Self.decoder().decode([SnapshotCalendar].self, from: data)) ?? []
}
/// Reads only the version field, so an unsupported file is never fully decoded.
private struct SchemaProbe: Decodable { let schemaVersion: Int? }
// MARK: - Writing (Calendarr only)
@_spi(Writer)
public func write(_ snapshot: CalendarrSnapshot) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(snapshot)
try data.write(to: container.appendingPathComponent(Self.cacheFilename), options: .atomic)
SnapshotChangeNotifier.post()
}
@_spi(Writer)
public func writeCalendars(_ calendars: [SnapshotCalendar]) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(calendars)
try data.write(to: container.appendingPathComponent(Self.calendarsFilename), options: .atomic)
}
/// Remove everything published. Call this on sign-out and on server reset —
/// the container outlives the session, so without it the next reader keeps
/// rendering the previous user's calendar.
@_spi(Writer)
public func clear() {
guard let container = containerURL else { return }
for name in [Self.cacheFilename, Self.calendarsFilename] {
try? FileManager.default.removeItem(at: container.appendingPathComponent(name))
}
SnapshotChangeNotifier.post()
}
}
@@ -0,0 +1,67 @@
import Foundation
/// How much of the calendar travels to the watch.
///
/// Deliberately narrower than `SnapshotCoverage`: the payload crosses
/// WatchConnectivity on every publish, and a watch complication never needs six
/// weeks of history to answer "what is next".
public enum WatchCoverage {
/// Days before today. One is enough for "still running" all-day events.
public static let daysBehind = 1
/// Days after today.
public static let daysAhead = 14
/// Hard cap on events sent, applied after sorting by start date.
public static let maxEvents = 120
}
extension CalendarrSnapshot {
/// A narrower copy of this snapshot, suitable for sending to the watch.
///
/// The coverage window shrinks together with the events, and never widens
/// past what the writer already claimed. If `WatchCoverage.maxEvents`
/// truncates the list, `coverageEnd` is pulled back to the first event we
/// had to drop: everything before that point is provably complete, and
/// beyond it the watch must not pretend to know anything.
public func trimmedForWatch(now: Date = Date(),
calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot {
let dayStart = calendar.startOfDay(for: now)
let windowStart = max(coverageStart,
calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart)
let windowEnd = min(coverageEnd,
calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart)
guard windowEnd > windowStart else {
return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: [])
}
let inWindow = events
.filter { $0.start < windowEnd && $0.end > windowStart }
.sorted { $0.start < $1.start }
guard inWindow.count > WatchCoverage.maxEvents else {
return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow)
}
let kept = Array(inWindow.prefix(WatchCoverage.maxEvents))
// The first dropped event marks the edge of what we can prove. Its start
// becomes the new end of coverage; the last kept event is dropped too
// when it starts at that same instant, which keeps the invariant
// "every event we carry starts before coverageEnd" exactly true.
let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start))
return Self.rebuilt(from: self, start: windowStart, end: provenEnd,
events: kept.filter { $0.start < provenEnd && $0.end > windowStart })
}
private static func rebuilt(from source: CalendarrSnapshot,
start: Date, end: Date,
events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: source.writtenAt,
coverageStart: start,
coverageEnd: end,
isLoggedIn: source.isLoggedIn,
writerVersion: source.writerVersion,
events: events,
theme: source.theme,
language: source.language)
}
}