Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'
git-subtree-dir: CalendarrKit git-subtree-mainline:8bc32faa7fgit-subtree-split:9ea1a8705d
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
@@ -0,0 +1,37 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CalendarrKit",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
// String form, not `.v26`: that enum case needs PackageDescription 6.2,
|
||||
// and raising swift-tools-version for one constant would change
|
||||
// defaults across the whole manifest.
|
||||
.watchOS("26.0"),
|
||||
.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)]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,61 @@
|
||||
import Foundation
|
||||
|
||||
/// When a complication needs to be redrawn.
|
||||
///
|
||||
/// A watch-face complication has a small refresh budget, so the timeline is
|
||||
/// pre-computed from data the watch already holds instead of being refreshed on
|
||||
/// a schedule: an entry on every event boundary means the complication changes
|
||||
/// in the exact minute the next appointment does, without a network call.
|
||||
public enum ComplicationTimeline {
|
||||
/// Dates at which a complication should re-render.
|
||||
///
|
||||
/// Always begins with `now` so the first entry renders immediately. Event
|
||||
/// starts and ends within `horizon` become entries.
|
||||
///
|
||||
/// The first `fineGrainedWindow` then gets a mark every minute, and the rest
|
||||
/// of the horizon one every hour. The fine-grained part is what lets a
|
||||
/// complication show a *compact* countdown — "23m" rather than "1:23:45" —
|
||||
/// which is the only kind that fits an accessoryCorner or a circular gauge.
|
||||
/// A live `Text(style: .timer)` needs no entries at all but is eight
|
||||
/// characters wide, and overlaps the curved label it sits next to.
|
||||
///
|
||||
/// The result is sorted, deduplicated to the second, and capped at
|
||||
/// `maxEntries`, keeping the earliest.
|
||||
public static func entryDates(from now: Date,
|
||||
in snapshot: CalendarrSnapshot,
|
||||
horizon: TimeInterval = 24 * 3600,
|
||||
fineGrainedWindow: TimeInterval = 3600,
|
||||
maxEntries: Int = 150) -> [Date] {
|
||||
let end = now.addingTimeInterval(horizon)
|
||||
var seen: Set<Int> = []
|
||||
var dates: [Date] = []
|
||||
|
||||
func add(_ date: Date) {
|
||||
guard date >= now, date <= end else { return }
|
||||
// Deduplicate to whole seconds: two events that meet exactly would
|
||||
// otherwise produce two entries a float apart.
|
||||
let key = Int(date.timeIntervalSince1970.rounded())
|
||||
guard seen.insert(key).inserted else { return }
|
||||
dates.append(date)
|
||||
}
|
||||
|
||||
add(now)
|
||||
for event in snapshot.events {
|
||||
add(event.start)
|
||||
add(event.end)
|
||||
}
|
||||
let fineEnd = min(end, now.addingTimeInterval(fineGrainedWindow))
|
||||
var minute = now.addingTimeInterval(60)
|
||||
while minute <= fineEnd {
|
||||
add(minute)
|
||||
minute = minute.addingTimeInterval(60)
|
||||
}
|
||||
var hourly = now.addingTimeInterval(3600)
|
||||
while hourly <= end {
|
||||
add(hourly)
|
||||
hourly = hourly.addingTimeInterval(3600)
|
||||
}
|
||||
|
||||
return Array(dates.sorted().prefix(maxEntries))
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Identifies the appointment a complication is currently showing.
|
||||
///
|
||||
/// The start date is part of the identity on purpose: a moved event keeps its
|
||||
/// id, and the complication is wrong until it is told.
|
||||
public struct NextEventIdentity: Equatable, Sendable, Codable {
|
||||
public let id: String
|
||||
public let start: Date
|
||||
|
||||
public init(id: String, start: Date) {
|
||||
self.id = id
|
||||
self.start = start
|
||||
}
|
||||
}
|
||||
|
||||
public enum WatchPushPolicy {
|
||||
/// The first event that has not ended yet — which is what every "next
|
||||
/// appointment" complication renders, including one that is running now.
|
||||
public static func nextEventIdentity(in snapshot: CalendarrSnapshot,
|
||||
at now: Date) -> NextEventIdentity? {
|
||||
snapshot.events
|
||||
.filter { $0.end > now }
|
||||
.min { $0.start < $1.start }
|
||||
.map { NextEventIdentity(id: $0.id, start: $0.start) }
|
||||
}
|
||||
|
||||
/// Whether this change is worth one of the day's limited complication
|
||||
/// pushes. Only a changed next appointment is — everything else reaches the
|
||||
/// watch on the next application-context delivery.
|
||||
public static func shouldPushComplicationUpdate(previous: NextEventIdentity?,
|
||||
current: NextEventIdentity?) -> Bool {
|
||||
previous != current
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
/// Dictionary keys used on the WatchConnectivity wire. Stable within a major
|
||||
/// version, like the snapshot filenames.
|
||||
public enum WatchTransportKey {
|
||||
public static let schema = "schema"
|
||||
public static let snapshot = "snapshot"
|
||||
public static let calendars = "calendars"
|
||||
public static let session = "session"
|
||||
}
|
||||
|
||||
public enum WatchTransportError: Error, Equatable, LocalizedError {
|
||||
case missingSnapshot
|
||||
case unsupportedSchema(found: Int, maxSupported: Int)
|
||||
case malformed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingSnapshot:
|
||||
return "Watch payload carried no snapshot."
|
||||
case .unsupportedSchema(let found, let max):
|
||||
return "Watch payload schema \(found) is newer than this build understands (\(max))."
|
||||
case .malformed(let why):
|
||||
return "Watch payload could not be decoded: \(why)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the phone sends to the watch: a trimmed snapshot, the calendar list for
|
||||
/// a filter UI, and the non-secret session facts so the watch can tell "signed
|
||||
/// out" from "never received anything".
|
||||
///
|
||||
/// The auth token is deliberately absent. The watch never talks to the server,
|
||||
/// so there is nothing for it to authenticate with and nothing to leak.
|
||||
public struct WatchTransportPayload: Sendable {
|
||||
/// Bump only for a breaking change to the dictionary shape.
|
||||
public static let currentSchema = 1
|
||||
/// Payloads claiming a higher schema are refused rather than mis-decoded.
|
||||
public static let maxSupportedSchema = 1
|
||||
|
||||
public let snapshot: CalendarrSnapshot
|
||||
public let calendars: [SnapshotCalendar]
|
||||
public let session: SharedSession?
|
||||
|
||||
public init(snapshot: CalendarrSnapshot,
|
||||
calendars: [SnapshotCalendar],
|
||||
session: SharedSession?) {
|
||||
self.snapshot = snapshot
|
||||
self.calendars = calendars
|
||||
self.session = session
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Encode for `WCSession`. Values are `Data` and `Int` only, because
|
||||
/// WatchConnectivity accepts property-list types and rejects everything
|
||||
/// else at runtime rather than at compile time.
|
||||
public func encoded() throws -> [String: Any] {
|
||||
let e = Self.encoder()
|
||||
var dict: [String: Any] = [
|
||||
WatchTransportKey.schema: Self.currentSchema,
|
||||
WatchTransportKey.snapshot: try e.encode(snapshot),
|
||||
WatchTransportKey.calendars: try e.encode(calendars),
|
||||
]
|
||||
if let session { dict[WatchTransportKey.session] = try e.encode(session) }
|
||||
return dict
|
||||
}
|
||||
|
||||
public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload {
|
||||
let found = dict[WatchTransportKey.schema] as? Int ?? 1
|
||||
guard found <= maxSupportedSchema else {
|
||||
throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema)
|
||||
}
|
||||
guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else {
|
||||
throw WatchTransportError.missingSnapshot
|
||||
}
|
||||
|
||||
let d = decoder()
|
||||
let snapshot: CalendarrSnapshot
|
||||
do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) }
|
||||
catch { throw WatchTransportError.malformed("snapshot: \(error)") }
|
||||
|
||||
var calendars: [SnapshotCalendar] = []
|
||||
if let data = dict[WatchTransportKey.calendars] as? Data {
|
||||
calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
var session: SharedSession?
|
||||
if let data = dict[WatchTransportKey.session] as? Data {
|
||||
session = try? d.decode(SharedSession.self, from: data)
|
||||
}
|
||||
|
||||
return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
/// The server reports `calendar_id` two different ways, and normalising them
|
||||
/// inconsistently is what made per-calendar filtering silently do nothing for
|
||||
/// some sources. These pin the rule down.
|
||||
struct CalendarKeyTests {
|
||||
|
||||
@Test("CalDAV ids arrive bare and are used as-is")
|
||||
func bareNumericId() {
|
||||
#expect(CalendarKey.make(source: "caldav", calendarId: "12") == "caldav:12")
|
||||
}
|
||||
|
||||
@Test("Prefixed ids from the other sources get the prefix stripped")
|
||||
func strippedPrefix() {
|
||||
#expect(CalendarKey.make(source: "local", calendarId: "local-3") == "local:3")
|
||||
#expect(CalendarKey.make(source: "google", calendarId: "google-5") == "google:5")
|
||||
#expect(CalendarKey.make(source: "ical", calendarId: "ical-7") == "ical:7")
|
||||
#expect(CalendarKey.make(source: "homeassistant",
|
||||
calendarId: "homeassistant-9") == "homeassistant:9")
|
||||
}
|
||||
|
||||
@Test("Both spellings of the same calendar produce the same key")
|
||||
func bothFormsAgree() {
|
||||
#expect(CalendarKey.make(source: "local", calendarId: "local-3")
|
||||
== CalendarKey.make(source: "local", calendarId: "3"))
|
||||
}
|
||||
|
||||
@Test("Only a leading prefix is stripped, and only once")
|
||||
func stripsOnlyLeadingPrefix() {
|
||||
// A prefix appearing later in the id must survive untouched.
|
||||
#expect(CalendarKey.make(source: "local", calendarId: "3-local") == "local:3-local")
|
||||
// Two prefixes: only the first goes.
|
||||
#expect(CalendarKey.make(source: "local", calendarId: "local-local-3") == "local:local-3")
|
||||
}
|
||||
|
||||
@Test("parse round-trips make")
|
||||
func roundTrip() {
|
||||
let key = CalendarKey.make(source: "local", calendarId: "local-42")
|
||||
let parsed = CalendarKey.parse(key)
|
||||
#expect(parsed?.source == "local")
|
||||
#expect(parsed?.id == 42)
|
||||
}
|
||||
|
||||
@Test("parse rejects what it cannot represent")
|
||||
func rejectsMalformed() {
|
||||
#expect(CalendarKey.parse("nocolon") == nil)
|
||||
#expect(CalendarKey.parse("local:abc") == nil) // non-numeric id
|
||||
#expect(CalendarKey.parse("local:") == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
struct ComplicationTimelineTests {
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||
|
||||
private static func event(_ id: String, startOffset: TimeInterval,
|
||||
duration: TimeInterval) -> SnapshotEvent {
|
||||
SnapshotEvent(id: id, title: id,
|
||||
start: now.addingTimeInterval(startOffset),
|
||||
end: now.addingTimeInterval(startOffset + duration),
|
||||
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
|
||||
}
|
||||
|
||||
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||
CalendarrSnapshot(writtenAt: now,
|
||||
coverageStart: now.addingTimeInterval(-86400),
|
||||
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
}
|
||||
|
||||
@Test("The first entry is always now, so the complication renders immediately")
|
||||
func startsAtNow() {
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||
#expect(dates.first == Self.now)
|
||||
}
|
||||
|
||||
@Test("Entries are sorted and free of duplicates")
|
||||
func sortedAndDeduplicated() {
|
||||
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800),
|
||||
Self.event("b", startOffset: 5400, duration: 1800)])
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||
|
||||
#expect(dates == dates.sorted())
|
||||
#expect(Set(dates).count == dates.count)
|
||||
}
|
||||
|
||||
@Test("Event starts and ends inside the horizon become entries")
|
||||
func includesEventBoundaries() {
|
||||
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)])
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||
|
||||
#expect(dates.contains(Self.now.addingTimeInterval(3600)))
|
||||
#expect(dates.contains(Self.now.addingTimeInterval(5400)))
|
||||
}
|
||||
|
||||
@Test("Boundaries outside the horizon are ignored")
|
||||
func ignoresBoundariesBeyondHorizon() {
|
||||
let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)])
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600)
|
||||
|
||||
#expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false)
|
||||
#expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) })
|
||||
}
|
||||
|
||||
@Test("Boundaries already in the past are ignored")
|
||||
func ignoresPastBoundaries() {
|
||||
let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)])
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||
|
||||
#expect(dates.allSatisfy { $0 >= Self.now })
|
||||
}
|
||||
|
||||
@Test("An empty calendar still gets hourly entries across the horizon")
|
||||
func fillsHourlyWhenEmpty() {
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]),
|
||||
horizon: 24 * 3600)
|
||||
#expect(dates.count >= 24)
|
||||
}
|
||||
|
||||
@Test("The first hour gets minute-granular entries, so a compact countdown stays honest")
|
||||
func minuteGranularFirstHour() {
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||
let firstHour = dates.filter { $0 <= Self.now.addingTimeInterval(3600) }
|
||||
|
||||
// 60 minute marks plus `now` itself.
|
||||
#expect(firstHour.count >= 60)
|
||||
#expect(firstHour.contains(Self.now.addingTimeInterval(60)))
|
||||
#expect(firstHour.contains(Self.now.addingTimeInterval(1800)))
|
||||
}
|
||||
|
||||
@Test("Past the first hour the entries thin out to hourly")
|
||||
func hourlyAfterFirstHour() {
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||
let later = dates.filter { $0 > Self.now.addingTimeInterval(3600) }
|
||||
|
||||
// 23 further hours, not 23 * 60 minutes.
|
||||
#expect(later.count < 40)
|
||||
#expect(later.contains(Self.now.addingTimeInterval(2 * 3600)))
|
||||
}
|
||||
|
||||
@Test("The entry count is capped, keeping the earliest entries")
|
||||
func capsEntries() {
|
||||
var events: [SnapshotEvent] = []
|
||||
for i in 0..<300 {
|
||||
events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30))
|
||||
}
|
||||
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events),
|
||||
maxEntries: 50)
|
||||
|
||||
#expect(dates.count == 50)
|
||||
#expect(dates.first == Self.now)
|
||||
#expect(dates == dates.sorted())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
struct SnapshotFilterTests {
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||
|
||||
private static func event(_ id: String, key: String) -> SnapshotEvent {
|
||||
SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600),
|
||||
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key)
|
||||
}
|
||||
|
||||
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||
CalendarrSnapshot(writtenAt: now,
|
||||
coverageStart: now.addingTimeInterval(-86400),
|
||||
coverageEnd: now.addingTimeInterval(86400),
|
||||
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
}
|
||||
|
||||
@Test("An empty allow-list means every calendar, not no calendar")
|
||||
func emptyAllowListKeepsEverything() {
|
||||
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||
#expect(snap.filtered(toCalendarKeys: []).events.count == 2)
|
||||
}
|
||||
|
||||
@Test("An allow-list keeps only the listed calendars")
|
||||
func allowListNarrows() {
|
||||
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||
#expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"])
|
||||
}
|
||||
|
||||
@Test("A deny-list removes only the listed calendars")
|
||||
func denyListNarrows() {
|
||||
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||
#expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"])
|
||||
}
|
||||
|
||||
@Test("Filtering narrows the events but never the window")
|
||||
func windowIsUntouched() {
|
||||
let snap = Self.snapshot([Self.event("a", key: "local:1")])
|
||||
let filtered = snap.filtered(toCalendarKeys: ["nothing:0"])
|
||||
|
||||
#expect(filtered.events.isEmpty)
|
||||
#expect(filtered.coverageStart == snap.coverageStart)
|
||||
#expect(filtered.coverageEnd == snap.coverageEnd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
/// The wire format is a contract between apps that ship independently, so both
|
||||
/// directions of compatibility need a test: a new reader must understand an old
|
||||
/// file, and an old reader must survive a new one.
|
||||
struct SnapshotSchemaTests {
|
||||
|
||||
private static func decoder() -> JSONDecoder {
|
||||
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
|
||||
}
|
||||
private static func encoder() -> JSONEncoder {
|
||||
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
|
||||
}
|
||||
|
||||
/// A real v1 file: no schemaVersion, no coverage window, no isLoggedIn,
|
||||
/// colours flat at the top level.
|
||||
private static let v1Fixture = """
|
||||
{
|
||||
"writtenAt": "2026-08-10T12:00:00Z",
|
||||
"events": [
|
||||
{
|
||||
"id": "abc",
|
||||
"title": "Zahnarzt",
|
||||
"start": "2026-08-11T09:00:00Z",
|
||||
"end": "2026-08-11T10:00:00Z",
|
||||
"isAllDay": false,
|
||||
"colorHex": "#58B900",
|
||||
"location": "Bern",
|
||||
"calendarKey": "local:3"
|
||||
}
|
||||
],
|
||||
"todayColorHex": "#6FB669",
|
||||
"textColorHex": "#FFFFFF",
|
||||
"backgroundColorHex": "#000000",
|
||||
"lineColorHex": "#3B3B3D",
|
||||
"primaryColorHex": "#58B900",
|
||||
"accentColorHex": "#45A148",
|
||||
"language": "de"
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("A v1 file decodes, and its missing fields get sane defaults")
|
||||
func decodesV1() throws {
|
||||
let snap = try Self.decoder().decode(
|
||||
CalendarrSnapshot.self, from: Data(Self.v1Fixture.utf8))
|
||||
|
||||
#expect(snap.schemaVersion == 1)
|
||||
#expect(snap.events.count == 1)
|
||||
#expect(snap.events[0].calendarKey == "local:3")
|
||||
#expect(snap.language == "de")
|
||||
#expect(snap.theme.today == "#6FB669")
|
||||
#expect(snap.theme.accent == "#45A148")
|
||||
|
||||
// v1 never said whether anyone was signed in; assuming yes preserves the
|
||||
// old behaviour of simply rendering whatever was cached.
|
||||
#expect(snap.isLoggedIn)
|
||||
#expect(snap.writerVersion == "unknown")
|
||||
|
||||
// The window is reconstructed from the policy v1 always used.
|
||||
let cal = Calendar(identifier: .gregorian)
|
||||
let dayStart = cal.startOfDay(for: snap.writtenAt)
|
||||
#expect(snap.coverageStart == cal.date(byAdding: .day, value: -7, to: dayStart))
|
||||
#expect(snap.coverageEnd == cal.date(byAdding: .day, value: 42, to: dayStart))
|
||||
}
|
||||
|
||||
/// Older still: written before per-calendar filtering existed, so the events
|
||||
/// carry no calendarKey at all.
|
||||
private static let v1FixtureNoCalendarKey = """
|
||||
{
|
||||
"writtenAt": "2026-08-10T12:00:00Z",
|
||||
"events": [
|
||||
{
|
||||
"id": "abc",
|
||||
"title": "Zahnarzt",
|
||||
"start": "2026-08-11T09:00:00Z",
|
||||
"end": "2026-08-11T10:00:00Z",
|
||||
"isAllDay": false,
|
||||
"colorHex": "#58B900",
|
||||
"location": "Bern"
|
||||
}
|
||||
],
|
||||
"todayColorHex": "#6FB669",
|
||||
"textColorHex": "#FFFFFF",
|
||||
"backgroundColorHex": "#000000",
|
||||
"lineColorHex": "#3B3B3D",
|
||||
"language": "de"
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("A v1 file that predates calendarKey and the newer colours still decodes")
|
||||
func decodesV1WithoutCalendarKey() throws {
|
||||
let snap = try Self.decoder().decode(
|
||||
CalendarrSnapshot.self, from: Data(Self.v1FixtureNoCalendarKey.utf8))
|
||||
#expect(snap.events[0].calendarKey == "")
|
||||
// primary/accent were added later too, so they fall back to defaults.
|
||||
#expect(snap.theme.primary == "#4285f4")
|
||||
#expect(snap.theme.accent == "#ea4335")
|
||||
}
|
||||
|
||||
@Test("v2 round-trips without losing anything")
|
||||
func roundTripsV2() throws {
|
||||
let start = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
let original = CalendarrSnapshot(
|
||||
writtenAt: start,
|
||||
coverageStart: start.addingTimeInterval(-7 * 86_400),
|
||||
coverageEnd: start.addingTimeInterval(42 * 86_400),
|
||||
isLoggedIn: true,
|
||||
writerVersion: "3.5",
|
||||
events: [SnapshotEvent(id: "x", title: "Termin", start: start,
|
||||
end: start.addingTimeInterval(3600),
|
||||
isAllDay: false, colorHex: "#58B900",
|
||||
location: "", calendarKey: "local:1")],
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "ch")
|
||||
|
||||
let decoded = try Self.decoder().decode(
|
||||
CalendarrSnapshot.self, from: Self.encoder().encode(original))
|
||||
|
||||
#expect(decoded.schemaVersion == CalendarrSnapshot.currentSchema)
|
||||
#expect(decoded.writerVersion == "3.5")
|
||||
#expect(decoded.language == "ch")
|
||||
#expect(decoded.coverageStart == original.coverageStart)
|
||||
#expect(decoded.coverageEnd == original.coverageEnd)
|
||||
#expect(decoded.theme == original.theme)
|
||||
#expect(decoded.events == original.events)
|
||||
}
|
||||
|
||||
/// The reason the colours are stored flat rather than nested under `theme`:
|
||||
/// a v1 reader requires them at the top level, so nesting them would have
|
||||
/// broken every older build on the first v2 write.
|
||||
@Test("v2 keeps the colour keys where a v1 reader expects them")
|
||||
func v2StaysReadableByV1() throws {
|
||||
let start = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
let snap = CalendarrSnapshot(
|
||||
writtenAt: start, coverageStart: start, coverageEnd: start,
|
||||
isLoggedIn: true, writerVersion: "3.5", events: [],
|
||||
theme: SnapshotTheme(today: "#a", text: "#b", background: "#c",
|
||||
line: "#d", primary: "#e", accent: "#f"),
|
||||
language: "de")
|
||||
|
||||
let object = try JSONSerialization.jsonObject(
|
||||
with: Self.encoder().encode(snap)) as? [String: Any]
|
||||
|
||||
for key in ["todayColorHex", "textColorHex", "backgroundColorHex",
|
||||
"lineColorHex", "primaryColorHex", "accentColorHex",
|
||||
"writtenAt", "events", "language"] {
|
||||
#expect(object?[key] != nil, "v1 readers require \(key) at the top level")
|
||||
}
|
||||
#expect(object?["theme"] == nil, "theme must stay a computed view, not a stored key")
|
||||
}
|
||||
|
||||
@Test("covers() separates 'no events' from 'no information'")
|
||||
func coverageWindow() {
|
||||
let now = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
let snap = CalendarrSnapshot(
|
||||
writtenAt: now,
|
||||
coverageStart: now.addingTimeInterval(-86_400),
|
||||
coverageEnd: now.addingTimeInterval(86_400),
|
||||
isLoggedIn: true, writerVersion: "3.5", events: [],
|
||||
theme: SnapshotTheme(today: "", text: "", background: "",
|
||||
line: "", primary: "", accent: ""),
|
||||
language: "de")
|
||||
|
||||
#expect(snap.covers(now))
|
||||
#expect(snap.covers(now.addingTimeInterval(-86_399)))
|
||||
#expect(!snap.covers(now.addingTimeInterval(-90_000)))
|
||||
#expect(!snap.covers(snap.coverageEnd)) // half-open: end is excluded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
struct WatchPushPolicyTests {
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||
|
||||
private static func event(_ id: String, startOffset: TimeInterval,
|
||||
duration: TimeInterval = 3600) -> SnapshotEvent {
|
||||
SnapshotEvent(id: id, title: id,
|
||||
start: now.addingTimeInterval(startOffset),
|
||||
end: now.addingTimeInterval(startOffset + duration),
|
||||
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
|
||||
}
|
||||
|
||||
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||
CalendarrSnapshot(writtenAt: now,
|
||||
coverageStart: now.addingTimeInterval(-86400),
|
||||
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
}
|
||||
|
||||
@Test("The next event is the first one that has not ended yet")
|
||||
func picksFirstUnfinished() {
|
||||
let snap = Self.snapshot([Self.event("past", startOffset: -7200),
|
||||
Self.event("running", startOffset: -600),
|
||||
Self.event("later", startOffset: 3600)])
|
||||
|
||||
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running")
|
||||
}
|
||||
|
||||
@Test("An empty calendar has no next event")
|
||||
func emptyHasNoIdentity() {
|
||||
#expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil)
|
||||
}
|
||||
|
||||
@Test("Events are considered in start order regardless of input order")
|
||||
func sortsBeforePicking() {
|
||||
let snap = Self.snapshot([Self.event("late", startOffset: 7200),
|
||||
Self.event("soon", startOffset: 1800)])
|
||||
|
||||
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon")
|
||||
}
|
||||
|
||||
@Test("An unchanged next event does not spend a push")
|
||||
func unchangedDoesNotPush() {
|
||||
let identity = NextEventIdentity(id: "a", start: Self.now)
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false)
|
||||
}
|
||||
|
||||
@Test("A different event, a moved event, or a first event all spend a push")
|
||||
func changesPush() {
|
||||
let a = NextEventIdentity(id: "a", start: Self.now)
|
||||
let b = NextEventIdentity(id: "b", start: Self.now)
|
||||
let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600))
|
||||
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b))
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved))
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a))
|
||||
}
|
||||
|
||||
@Test("Losing the last event spends a push, so the watch stops showing it")
|
||||
func clearingPushes() {
|
||||
let a = NextEventIdentity(id: "a", start: Self.now)
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil))
|
||||
}
|
||||
|
||||
@Test("Two empty calendars in a row do not push")
|
||||
func stayingEmptyDoesNotPush() {
|
||||
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
/// The phone and the watch ship on independent schedules, so the transport gets
|
||||
/// the same treatment as the file format: refuse a newer schema rather than
|
||||
/// mis-decode it.
|
||||
struct WatchTransportTests {
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||
|
||||
private static func snapshot() -> CalendarrSnapshot {
|
||||
CalendarrSnapshot(writtenAt: now,
|
||||
coverageStart: now.addingTimeInterval(-86400),
|
||||
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||
isLoggedIn: true, writerVersion: "1.2",
|
||||
events: [SnapshotEvent(id: "a", title: "Zahnarzt",
|
||||
start: now.addingTimeInterval(3600),
|
||||
end: now.addingTimeInterval(7200),
|
||||
isAllDay: false, colorHex: "#4285f4",
|
||||
location: "Bern", calendarKey: "local:3")],
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
}
|
||||
|
||||
@Test("A payload survives a round trip")
|
||||
func roundTrips() throws {
|
||||
let payload = WatchTransportPayload(
|
||||
snapshot: Self.snapshot(),
|
||||
calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")],
|
||||
session: SharedSession(baseURL: "https://cal.example.com", username: "guido",
|
||||
isLoggedIn: true, writtenAt: Self.now))
|
||||
|
||||
let decoded = try WatchTransportPayload.decode(try payload.encoded())
|
||||
|
||||
#expect(decoded.snapshot.events.map(\.id) == ["a"])
|
||||
#expect(decoded.snapshot.language == "de")
|
||||
#expect(decoded.calendars.map(\.name) == ["Privat"])
|
||||
#expect(decoded.session?.username == "guido")
|
||||
}
|
||||
|
||||
@Test("A payload with no session record still decodes")
|
||||
func sessionIsOptional() throws {
|
||||
let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil)
|
||||
let decoded = try WatchTransportPayload.decode(try payload.encoded())
|
||||
|
||||
#expect(decoded.session == nil)
|
||||
#expect(decoded.calendars.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The encoded form only contains property-list types")
|
||||
func encodesToPlistTypes() throws {
|
||||
let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
|
||||
|
||||
#expect(dict[WatchTransportKey.schema] is Int)
|
||||
#expect(dict[WatchTransportKey.snapshot] is Data)
|
||||
#expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary))
|
||||
}
|
||||
|
||||
@Test("A newer schema is refused rather than guessed at")
|
||||
func refusesNewerSchema() throws {
|
||||
var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
|
||||
dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1
|
||||
|
||||
#expect(throws: WatchTransportError.unsupportedSchema(
|
||||
found: WatchTransportPayload.maxSupportedSchema + 1,
|
||||
maxSupported: WatchTransportPayload.maxSupportedSchema)) {
|
||||
try WatchTransportPayload.decode(dict)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A payload without a snapshot is rejected")
|
||||
func rejectsMissingSnapshot() {
|
||||
let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema]
|
||||
|
||||
#expect(throws: WatchTransportError.missingSnapshot) {
|
||||
try WatchTransportPayload.decode(dict)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CalendarrCore
|
||||
|
||||
/// The watch payload is a narrower snapshot, not a different one. These tests
|
||||
/// pin down the part that is easy to get wrong: the coverage window has to
|
||||
/// shrink with the events, or the watch renders an empty week it knows nothing
|
||||
/// about.
|
||||
struct WatchTrimmingTests {
|
||||
|
||||
private static let cal = Calendar(identifier: .gregorian)
|
||||
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||
|
||||
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
|
||||
let dayStart = cal.startOfDay(for: now)
|
||||
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
|
||||
let start = cal.date(byAdding: .hour, value: hour, to: day)!
|
||||
return SnapshotEvent(id: id, title: "Event \(id)",
|
||||
start: start, end: start.addingTimeInterval(3600),
|
||||
isAllDay: false, colorHex: "#4285f4",
|
||||
location: "", calendarKey: "local:1")
|
||||
}
|
||||
|
||||
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||
let dayStart = cal.startOfDay(for: now)
|
||||
return CalendarrSnapshot(
|
||||
writtenAt: now,
|
||||
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
|
||||
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
|
||||
isLoggedIn: true,
|
||||
writerVersion: "1.0",
|
||||
events: events,
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
}
|
||||
|
||||
@Test("The window narrows to the watch policy, not just the events")
|
||||
func narrowsWindow() {
|
||||
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||
|
||||
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
|
||||
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
|
||||
}
|
||||
|
||||
@Test("Events outside the watch window are dropped")
|
||||
func dropsEventsOutsideWindow() {
|
||||
let inside = Self.event("in", dayOffset: 3)
|
||||
let outside = Self.event("out", dayOffset: 30)
|
||||
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
|
||||
#expect(trimmed.events.map(\.id) == ["in"])
|
||||
}
|
||||
|
||||
@Test("An event still running at the window start is kept")
|
||||
func keepsRunningEvent() {
|
||||
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
|
||||
let running = SnapshotEvent(id: "running", title: "Urlaub",
|
||||
start: start,
|
||||
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
|
||||
isAllDay: true, colorHex: "#4285f4",
|
||||
location: "", calendarKey: "local:1")
|
||||
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
|
||||
#expect(trimmed.events.map(\.id) == ["running"])
|
||||
}
|
||||
|
||||
@Test("Metadata survives trimming unchanged")
|
||||
func preservesMetadata() {
|
||||
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
|
||||
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
|
||||
#expect(trimmed.language == "de")
|
||||
#expect(trimmed.isLoggedIn == true)
|
||||
#expect(trimmed.writerVersion == "1.0")
|
||||
#expect(trimmed.theme.accent == "#6")
|
||||
#expect(trimmed.writtenAt == original.writtenAt)
|
||||
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
|
||||
}
|
||||
|
||||
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
|
||||
func capPullsBackCoverage() {
|
||||
var many: [SnapshotEvent] = []
|
||||
for day in 0..<14 {
|
||||
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
|
||||
}
|
||||
#expect(many.count > WatchCoverage.maxEvents)
|
||||
|
||||
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
|
||||
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
|
||||
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
|
||||
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
|
||||
#expect(trimmed.coverageEnd < fullEnd)
|
||||
#expect(trimmed.coverageEnd > trimmed.coverageStart)
|
||||
}
|
||||
|
||||
@Test("Trimming never widens a window the writer already narrowed")
|
||||
func neverWidens() {
|
||||
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||
let narrow = CalendarrSnapshot(
|
||||
writtenAt: Self.now,
|
||||
coverageStart: dayStart,
|
||||
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
|
||||
isLoggedIn: true, writerVersion: "1.0", events: [],
|
||||
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||
line: "#4", primary: "#5", accent: "#6"),
|
||||
language: "de")
|
||||
|
||||
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||
|
||||
#expect(trimmed.coverageStart == dayStart)
|
||||
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user