Read and write the widget snapshot through CalendarrCore

The snapshot format was an internal detail shared between the app and its own
widget extension. A second Mac app needs the same data, and the way to give it
that is a documented contract rather than a format it reverse-engineers and then
drifts from. Both targets now link CalendarrCore and go through SnapshotStore.

Shared/WidgetData.swift becomes a thin facade. The typealiases and the flat
colour accessors exist so the ~70 existing call sites across the app and the
widget views compile unchanged; they are a migration convenience, not a design.

The snapshot now carries what a reader outside this app actually needs:

coverageStart / coverageEnd, because the published window is ~7 days back and
~42 ahead. Outside it the snapshot holds no information, which is not the same
as holding no events — and only the writer knows where that edge is. Without it
a consumer renders a convincingly empty March and is simply wrong.

isLoggedIn plus a session record, so a reader can say "sign in to Calendarr"
rather than "open Calendarr once". The events are deleted on sign-out, so the
absence of a cache alone cannot tell those two apart.

writerVersion, purely so a mismatch between the two apps is diagnosable.

The coverage constants move to SnapshotCoverage, so the writer and the code that
reconstructs the window for older files can no longer disagree about it.

CalendarStore needs an explicit `import CalendarrCore` because the app target
builds with SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, which requires the
defining module to be imported directly rather than picked up transitively.

Verified: builds for iOS and Mac Catalyst; CalendarrKit's 11 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-10 17:42:00 +02:00
parent 84f5c00516
commit d1da8de06a
6 changed files with 162 additions and 207 deletions

View File

@@ -76,6 +76,17 @@ class AppState {
try? KeychainStore.set(token, for: "authToken") // secret Keychain, not UserDefaults
UserDefaults.standard.set(user, forKey: "username")
UserDefaults.standard.set(admin, forKey: "isAdmin")
publishSession()
}
/// Mirror the non-secret session facts into the shared container. Apps in
/// another sandbox cannot read our UserDefaults, so this is how they learn
/// which server we point at and whether anyone is signed in. The token
/// itself stays in the shared keychain group, never in a plain file.
private func publishSession() {
WidgetStore.writeSession(baseURL: serverURL,
username: username,
isLoggedIn: isLoggedIn)
}
func logout() {
@@ -89,12 +100,18 @@ class AppState {
UserDefaults.standard.removeObject(forKey: "isAdmin")
// The shared container outlives the session, so it has to be cleared
// explicitly otherwise widgets keep showing the signed-out user's data.
// The session record stays behind, flagged signed-out, so a reader can
// say "sign in to Calendarr" rather than "open Calendarr once".
WidgetStore.clear()
publishSession()
}
func resetServer() {
logout()
serverURL = ""
UserDefaults.standard.removeObject(forKey: "serverURL")
// Back to unconfigured: there is no server left to name, so drop the
// session record rather than leaving a stale URL in the container.
WidgetStore.clearSession()
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import CalendarrCore
extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from
@@ -579,8 +580,12 @@ class CalendarStore {
let now = Date()
// Include the week before today so widgets that show the current week
// (e.g. "This Week", "Up Next + Calendar") have data for Mondaytoday.
let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now
let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from
// The window is published in the snapshot as coverageStart/coverageEnd:
// outside it a reader has no information, which is not the same as
// having no events, and only the writer knows where the edge is.
let dayStart = cal.startOfDay(for: now)
let from = cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? now
let to = cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? from
// Build the calendar list from all cached events (not just the window)
// so every calendar appears in the widget configuration picker.
@@ -604,7 +609,7 @@ class CalendarStore {
&& !banishedCalendarKeys.contains(key)
}
.sorted { $0.startDate < $1.startDate }
.prefix(500)
.prefix(SnapshotCoverage.maxEvents)
.map { ev in
WidgetEvent(id: ev.id,
title: ev.title,
@@ -615,19 +620,9 @@ class CalendarStore {
location: ev.location,
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
}
let defaults = UserDefaults.standard
let snap = WidgetSnapshot(
writtenAt: now,
events: Array(visible),
todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52",
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
language: defaults.string(forKey: "appLanguage") ?? "system"
)
WidgetStore.write(snap)
WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible),
coverageStart: from,
coverageEnd: to))
WidgetTimelineNotifier.reload()
}