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:
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// The App Group the Calendarr apps share.
|
||||
///
|
||||
/// The identifier registered in the Apple Developer portal never changes. What
|
||||
/// changes is the string the *runtime* expects: macOS — including Mac Catalyst —
|
||||
/// requires the Team ID prefix, and iOS forbids it.
|
||||
///
|
||||
/// Getting this wrong fails silently. `containerURL(forSecurityApplication‑
|
||||
/// GroupIdentifier:)` returns nil, every read and write no-ops, and widgets show
|
||||
/// placeholder content forever with no error raised anywhere. `current` must
|
||||
/// therefore stay byte-identical to `com.apple.security.application-groups` in
|
||||
/// the entitlements file used for the platform being built.
|
||||
public enum CalendarrAppGroup {
|
||||
/// As registered in the Apple Developer portal.
|
||||
public static let unprefixed = "group.com.scarriffleservices.calendarr"
|
||||
|
||||
/// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time.
|
||||
public static let teamID = "PP34X97WS3"
|
||||
|
||||
/// The value to hand to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`.
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
public static let current = "\(teamID).\(unprefixed)"
|
||||
#else
|
||||
public static let current = unprefixed
|
||||
#endif
|
||||
|
||||
/// Keychain access group shared across apps from this team. Requires
|
||||
/// `keychain-access-groups` in the entitlements of every app that uses it.
|
||||
public static let sharedKeychainGroup = "\(teamID).com.scarriffleservices.calendarr.shared"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Stable identifier for one calendar, used to tie a snapshot event back to the
|
||||
/// calendar it came from.
|
||||
///
|
||||
/// This exists because the server reports `calendar_id` inconsistently: a raw
|
||||
/// numeric for CalDAV sources, but `"<source>-<id>"` for local, iCal, Google and
|
||||
/// Home Assistant (e.g. `"local-3"`). Any consumer that filters by calendar has
|
||||
/// to normalise the two forms identically, or filtering silently does nothing
|
||||
/// for the prefixed sources — which is exactly the bug this rule was written to
|
||||
/// fix. Keeping the rule in one shared place is why it lives here rather than
|
||||
/// being reimplemented per app.
|
||||
public enum CalendarKey {
|
||||
/// Build a key, stripping a redundant `"<source>-"` prefix from the id.
|
||||
public static func make(source: String, calendarId: String) -> String {
|
||||
var id = calendarId
|
||||
let prefix = "\(source)-"
|
||||
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||
return "\(source):\(id)"
|
||||
}
|
||||
|
||||
/// Split a key back into its source and numeric id.
|
||||
/// Returns nil for malformed keys or non-numeric ids.
|
||||
public static func parse(_ key: String) -> (source: String, id: Int)? {
|
||||
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||
let source = String(key[..<colon])
|
||||
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||
return (source, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Non-secret session facts published alongside the snapshot.
|
||||
///
|
||||
/// This exists so a consumer can answer "which server, and is anyone signed in?"
|
||||
/// without reading Calendarr's own `UserDefaults`, which lives in a different
|
||||
/// sandbox. The auth token is deliberately *not* here — it belongs in the shared
|
||||
/// keychain group, not in a plain file in the container.
|
||||
public struct SharedSession: Codable, Sendable {
|
||||
public static let currentSchema = 1
|
||||
public static let maxSupportedSchema = 1
|
||||
|
||||
public let schemaVersion: Int
|
||||
public let baseURL: String
|
||||
public let username: String
|
||||
public let isLoggedIn: Bool
|
||||
public let writtenAt: Date
|
||||
|
||||
public init(baseURL: String, username: String, isLoggedIn: Bool, writtenAt: Date) {
|
||||
self.schemaVersion = Self.currentSchema
|
||||
self.baseURL = baseURL
|
||||
self.username = username
|
||||
self.isLoggedIn = isLoggedIn
|
||||
self.writtenAt = writtenAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
baseURL = try c.decode(String.self, forKey: .baseURL)
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
|
||||
writtenAt = try c.decodeIfPresent(Date.self, forKey: .writtenAt) ?? Date(timeIntervalSince1970: 0)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion, baseURL, username, isLoggedIn, writtenAt
|
||||
}
|
||||
|
||||
/// The server URL as a `URL`, or nil when unset or malformed.
|
||||
public var url: URL? { baseURL.isEmpty ? nil : URL(string: baseURL) }
|
||||
}
|
||||
|
||||
public struct SharedSessionStore: Sendable {
|
||||
private static let filename = "session.json"
|
||||
|
||||
public let appGroupID: String
|
||||
|
||||
public init(appGroupID: String = CalendarrAppGroup.current) {
|
||||
self.appGroupID = appGroupID
|
||||
}
|
||||
|
||||
private var fileURL: URL? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)?
|
||||
.appendingPathComponent(Self.filename)
|
||||
}
|
||||
|
||||
/// Nil when Calendarr has never published, or the file is unreadable or newer
|
||||
/// than this reader understands.
|
||||
public func read() -> SharedSession? {
|
||||
guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let session = try? decoder.decode(SharedSession.self, from: data),
|
||||
session.schemaVersion <= SharedSession.maxSupportedSchema else { return nil }
|
||||
return session
|
||||
}
|
||||
|
||||
@_spi(Writer)
|
||||
public func write(_ session: SharedSession) throws {
|
||||
guard let url = fileURL else {
|
||||
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
try encoder.encode(session).write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
@_spi(Writer)
|
||||
public func clear() {
|
||||
guard let url = fileURL else { return }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
@@ -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,217 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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,129 @@
|
||||
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 { 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user