Compare commits

..

2 Commits

Author SHA1 Message Date
Scarriffle
d1da8de06a 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>
2026-08-10 17:42:00 +02:00
Scarriffle
84f5c00516 Keep iOS iPhone-only; give the iPad family to Mac Catalyst alone
Enabling Mac Catalyst pulled in the iPad device family, which made App Store
Connect reject the iOS build with error 90474: an iPad-capable bundle must
declare all four interface orientations to support iPad multitasking, and this
app declared two.

Catalyst genuinely needs the iPad idiom, but only for its own build. A per-SDK
condition gives macOS family 2 while iOS keeps family 1 — verified to resolve
correctly for both destinations, and Catalyst still builds with family 2 alone.
The shipped iOS bundle is back to UIDeviceFamily [1], exactly as before the
port started.

The iPad orientation key is removed rather than extended. With an iPhone-only
iOS build it is ignored, and macOS ignores orientation keys entirely, so it was
dead configuration that only served to trip the validator.

Supporting iPad properly is a deliberate piece of work — the layout is
portrait-only with no size-class handling — and is better done on purpose than
inherited as a side effect of the Mac port.

Verified: builds for both Mac Catalyst and iOS Simulator; built bundle reports
UIDeviceFamily [1] and portrait-only with no iPad orientation key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:19:55 +02:00
6 changed files with 170 additions and 213 deletions

View File

@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -95,6 +97,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -102,6 +105,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -175,6 +179,7 @@
);
name = "Calendarr iOS";
packageProductDependencies = (
CA1E4D0A0001000000000001 /* CalendarrCore */,
);
productName = "Calendarr iOS";
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
@@ -198,6 +203,7 @@
);
name = CalendarrWidgets;
packageProductDependencies = (
CA1E4D0A0001000000000002 /* CalendarrCore */,
);
productName = CalendarrWidgets;
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
@@ -235,6 +241,9 @@
);
mainGroup = C0000201FB4E10100AB5001;
minimizedProjectReferenceProxies = 1;
packageReferences = (
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
projectDirPath = "";
@@ -493,9 +502,9 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -507,7 +516,6 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -525,7 +533,8 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Debug;
};
@@ -538,9 +547,9 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -552,7 +561,6 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -570,7 +578,8 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Release;
};
@@ -604,7 +613,8 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Debug;
};
@@ -637,7 +647,8 @@
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Release;
};
@@ -681,6 +692,24 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
CA1E4D0A0001000000000001 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../CalendarrKit;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = C0000301FB4E10100AB5001 /* Project object */;
}

View File

@@ -12,7 +12,7 @@
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

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()
}

View File

@@ -41,16 +41,17 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider {
let ids = (config.selectedCalendars ?? []).map { $0.id }
guard !ids.isEmpty else { return snapshot } // nothing selected = show all
let keep = Set(ids)
// Filtering narrows the events but not the window: the snapshot still
// speaks for the same range, it just has fewer calendars in it.
return WidgetSnapshot(
writtenAt: snapshot.writtenAt,
events: snapshot.events.filter { keep.contains($0.calendarKey) },
todayColorHex: snapshot.todayColorHex,
textColorHex: snapshot.textColorHex,
backgroundColorHex: snapshot.backgroundColorHex,
lineColorHex: snapshot.lineColorHex,
primaryColorHex: snapshot.primaryColorHex,
accentColorHex: snapshot.accentColorHex,
language: snapshot.language
writtenAt: snapshot.writtenAt,
coverageStart: snapshot.coverageStart,
coverageEnd: snapshot.coverageEnd,
isLoggedIn: snapshot.isLoggedIn,
writerVersion: snapshot.writerVersion,
events: snapshot.events.filter { keep.contains($0.calendarKey) },
theme: snapshot.theme,
language: snapshot.language
)
}
}

View File

@@ -1,221 +1,136 @@
import Foundation
@_spi(Writer) import CalendarrCore
#if canImport(WidgetKit)
import WidgetKit
#endif
/// App-Group identifier shared between the main app and the widget extension.
///
/// IMPORTANT: this must stay byte-identical to `com.apple.security.application-groups`
/// in the matching .entitlements file for the platform being built, and match the
/// App Group registered in the Apple Developer portal.
///
/// The identifier registered in the portal never changes only the string the
/// *runtime* expects does. macOS (including Mac Catalyst) requires the Team ID
/// prefix; iOS forbids it. Get this wrong and `containerURL(forSecurityApplication
/// GroupIdentifier:)` returns nil, every read and write below quietly no-ops, and
/// the widgets show placeholder content forever with no error anywhere.
enum CalendarrAppGroup {
/// As registered in the Apple Developer portal.
static let unprefixed = "group.com.scarriffleservices.calendarr"
/// Team ID the value `$(AppIdentifierPrefix)` expands to at build time.
static let teamID = "PP34X97WS3"
#if os(macOS) || targetEnvironment(macCatalyst)
static let current = "\(teamID).\(unprefixed)"
#else
static let current = unprefixed
#endif
}
// The snapshot types and the App Group plumbing now live in CalendarrCore, so
// that a second app from this team reads the exact same format instead of
// reimplementing it and drifting.
//
// These aliases keep the ~70 existing call sites in the app and the widget
// compiling unchanged. They are a migration convenience, not a design: new code
// should use the CalendarrCore names directly.
typealias WidgetEvent = SnapshotEvent
typealias WidgetCalendar = SnapshotCalendar
typealias WidgetSnapshot = CalendarrSnapshot
/// The App Group identifier for this platform. See `CalendarrAppGroup`.
let widgetAppGroupID = CalendarrAppGroup.current
/// Lightweight calendar descriptor stored alongside the event cache so the
/// widget configuration intent can offer calendar options without a network call.
struct WidgetCalendar: Codable, Identifiable, Hashable {
let id: String // calendarKey ("source-calendarId")
let name: String
let colorHex: String
}
/// Lightweight event representation that lives inside the widget cache.
/// We strip everything the widget doesn't need (notes, calendar IDs, URLs).
struct WidgetEvent: Codable, Hashable, Identifiable {
let id: String
let title: String
let start: Date
let end: Date
let isAllDay: Bool
let colorHex: String
let location: String
let calendarKey: String // used for per-calendar filtering in widget config
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
}
// Backward-compatible decoder: old caches have no calendarKey field.
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) ?? ""
}
}
/// Snapshot blob the app writes to the App-Group container and the widget reads.
struct WidgetSnapshot: Codable {
let writtenAt: Date
let events: [WidgetEvent]
/// Mirrors the user's chosen visual settings so the widget looks the same
/// as the app even when its own AppStorage in the extension is empty.
let todayColorHex: String
let textColorHex: String
let backgroundColorHex: String
let lineColorHex: String
let primaryColorHex: String
let accentColorHex: String
let language: String
init(writtenAt: Date,
events: [WidgetEvent],
todayColorHex: String,
textColorHex: String,
backgroundColorHex: String,
lineColorHex: String,
primaryColorHex: String,
accentColorHex: String,
language: String) {
self.writtenAt = writtenAt
self.events = events
self.todayColorHex = todayColorHex
self.textColorHex = textColorHex
self.backgroundColorHex = backgroundColorHex
self.lineColorHex = lineColorHex
self.primaryColorHex = primaryColorHex
self.accentColorHex = accentColorHex
self.language = language
}
/// Custom decoder so older caches without the new colour fields still load.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
events = try c.decode([WidgetEvent].self, forKey: .events)
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)
language = try c.decode(String.self, forKey: .language)
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
}
private enum CodingKeys: String, CodingKey {
case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
case lineColorHex, primaryColorHex, accentColorHex, language
}
extension CalendarrSnapshot {
// The six colours are stored flat on the wire for backwards compatibility
// but exposed as `theme` in the package. These keep the widget views, which
// read them individually, from having to change.
var todayColorHex: String { theme.today }
var textColorHex: String { theme.text }
var backgroundColorHex: String { theme.background }
var lineColorHex: String { theme.line }
var primaryColorHex: String { theme.primary }
var accentColorHex: String { theme.accent }
}
/// Thin facade over `CalendarrCore.SnapshotStore`, preserving the static API the
/// app and widget already use. Errors are swallowed here exactly as before
/// a failed widget cache write must never interrupt the user but the store now
/// reports *why* a read failed, which is what `read()` discards and callers that
/// need the distinction should use `SnapshotStore` directly for.
enum WidgetStore {
private static let cacheFilename = "widget-cache.json"
private static let calendarsFilename = "widget-calendars.json"
private static let store = SnapshotStore()
private static let sessions = SharedSessionStore()
private static var containerURL: URL? {
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
#if DEBUG
if url == nil {
// A nil container is always a build-configuration bug: the entitlement
// is missing, or its value does not match widgetAppGroupID. It is
// otherwise completely silent, so make it loud while developing.
assertionFailure("App Group container unavailable for \(widgetAppGroupID)"
+ "check com.apple.security.application-groups in the "
+ "entitlements for this platform.")
}
#endif
return url
/// Version of the app writing the cache, for diagnostics in the consumer.
private static var writerVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
}
private static var cacheURL: URL? {
containerURL?.appendingPathComponent(cacheFilename)
}
private static var calendarsURL: URL? {
containerURL?.appendingPathComponent(calendarsFilename)
}
/// Called by the app whenever the event cache changes.
static func write(_ snapshot: WidgetSnapshot) {
guard let url = cacheURL else { return }
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(snapshot) {
try? data.write(to: url, options: .atomic)
}
try? store.write(snapshot)
}
/// Called by the widget timeline provider to load the latest snapshot.
static func read() -> WidgetSnapshot? {
guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(WidgetSnapshot.self, from: data)
store.read().snapshot
}
/// Write the list of available calendars so the widget intent can offer
/// calendar options for filtering without a network call.
static func writeCalendars(_ calendars: [WidgetCalendar]) {
guard let url = calendarsURL else { return }
if let data = try? JSONEncoder().encode(calendars) {
try? data.write(to: url, options: .atomic)
}
try? store.writeCalendars(calendars)
}
/// Read the calendar list written by the main app.
static func readCalendars() -> [WidgetCalendar] {
guard let url = calendarsURL, let data = try? Data(contentsOf: url) else { return [] }
return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? []
store.readCalendars()
}
/// Drop the cached snapshot and calendar list. Called on logout and on
/// server reset: without this the files survive, and widgets plus any
/// other app reading the group container keep rendering the previous
/// user's events indefinitely.
/// Record which server we are pointed at and whether anyone is signed in, so
/// a reading app can tell "signed out" from "never ran" without reaching into
/// this app's UserDefaults, which lives in another sandbox.
static func writeSession(baseURL: String, username: String, isLoggedIn: Bool) {
try? sessions.write(SharedSession(baseURL: baseURL,
username: username,
isLoggedIn: isLoggedIn,
writtenAt: Date()))
}
/// Forget the session entirely. Used on server reset, where the app returns
/// to unconfigured and there is no longer a server worth naming.
static func clearSession() {
sessions.clear()
}
/// Drop the cached calendar. Called on sign-out and server reset: the
/// container outlives the session, so without this every reader keeps
/// rendering the previous user's events.
static func clear() {
for url in [cacheURL, calendarsURL].compactMap({ $0 }) {
try? FileManager.default.removeItem(at: url)
}
store.clear()
WidgetTimelineNotifier.reload()
}
/// Rewrite the existing snapshot with the latest colour / language values
/// from UserDefaults. Used when the user tweaks an appearance setting and
/// we want the widgets to refresh immediately, without needing a new event
/// sync. No-op if there's no cached snapshot yet.
/// Rewrite the existing snapshot with the latest colour / language values so
/// widgets pick up an appearance change immediately, without waiting for the
/// next event sync. No-op when nothing is cached yet.
static func republishAppearanceOnly() {
guard let existing = read() else { return }
let defaults = UserDefaults.standard
let updated = WidgetSnapshot(
writtenAt: Date(),
coverageStart: existing.coverageStart,
coverageEnd: existing.coverageEnd,
isLoggedIn: existing.isLoggedIn,
writerVersion: writerVersion,
events: existing.events,
todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
language: defaults.string(forKey: "appLanguage") ?? existing.language
)
theme: SnapshotTheme(
today: defaults.string(forKey: "todayColor") ?? existing.theme.today,
text: defaults.string(forKey: "textColor") ?? existing.theme.text,
background: defaults.string(forKey: "backgroundColor") ?? existing.theme.background,
line: defaults.string(forKey: "lineColor") ?? existing.theme.line,
primary: defaults.string(forKey: "primaryColor") ?? existing.theme.primary,
accent: defaults.string(forKey: "accentColor") ?? existing.theme.accent),
language: defaults.string(forKey: "appLanguage") ?? existing.language)
write(updated)
WidgetTimelineNotifier.reload()
}
/// Build a snapshot from the app's current state. Kept here so the coverage
/// window and the writer version are stamped in exactly one place.
static func makeSnapshot(events: [WidgetEvent],
coverageStart: Date,
coverageEnd: Date) -> WidgetSnapshot {
let defaults = UserDefaults.standard
return WidgetSnapshot(
writtenAt: Date(),
coverageStart: coverageStart,
coverageEnd: coverageEnd,
isLoggedIn: true, // only ever written while signed in
writerVersion: writerVersion,
events: events,
theme: SnapshotTheme(
today: defaults.string(forKey: "todayColor") ?? "#4285f4",
text: defaults.string(forKey: "textColor") ?? "#FFFFFF",
background: defaults.string(forKey: "backgroundColor") ?? "#000000",
line: defaults.string(forKey: "lineColor") ?? "#3A3A52",
primary: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accent: defaults.string(forKey: "accentColor") ?? "#ea4335"),
language: defaults.string(forKey: "appLanguage") ?? "system")
}
}
enum WidgetTimelineNotifier {