Compare commits

...

6 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
Scarriffle
0d2ad6e021 Give Mac Catalyst its own App Group prefix and sandbox entitlements
The App Group identifier registered in the portal does not change, but the
string the runtime expects does: macOS and Mac Catalyst require the Team ID
prefix, iOS forbids it. CalendarrAppGroup now resolves the right one per
platform. Getting this wrong is the worst failure mode in the whole port —
containerURL() returns nil, every snapshot read and write quietly no-ops, and
the widgets show placeholder content forever with no error anywhere. A DEBUG
assertion now makes that loud during development.

Because the two platforms need different values, they need different
entitlements files — listing both strings in one file breaks iOS provisioning
on the unregistered prefixed value. Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*],
verified to resolve correctly for both destinations.

The Catalyst entitlements are written App Store grade from the start, so one
configuration serves both the Mac App Store and a notarized DMG: sandbox,
network client, Contacts (birthday import), user-selected files (.ics import
and export), the prefixed App Group, and keychain sharing. Deliberately absent:
files.downloads, network.server, device.*, temporary-exception.* — nothing needs
them and each is App Review friction. ENABLE_HARDENED_RUNTIME is required for
notarization and ignored by the App Store, so it is safe to set unconditionally.

Verified: builds for both Mac Catalyst and iOS Simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:03:33 +02:00
Scarriffle
7fa8dbdbdf Enable Mac Catalyst on both targets; make the widget intent parameter optional
Supported Destinations now includes Mac (Mac Catalyst) on the app and the
widget extension. Catalyst requires the iPad device family, so both targets
move to TARGETED_DEVICE_FAMILY "1,2".

The first Catalyst build failed on CalendarSelectionIntent: WidgetConfigurationIntent
requires every @Parameter to be optional, and the macOS SDK enforces that where
the iOS one lets a bare array through. selectedCalendars is now optional.

Behaviour is unchanged — the timeline provider already treated an empty
selection as "show all calendars", so nil folds into the same path.

Verified: builds for both Mac Catalyst and iOS Simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:57:10 +02:00
Scarriffle
c9bd42aae5 Unify app and widget version numbers; raise widget to iOS 26
The app shipped 3.5 (6) while the widget shipped 1.0.1 (2). Apple requires an
embedded appex to match its host's CFBundleShortVersionString and CFBundleVersion,
so this was a latent App Store validation failure that only surfaces at upload —
and it would surface on the Mac submission too. Both keys now live at project
level, where they cannot drift apart again.

The widget also targeted iOS 17 while the container app requires 26.0, so the
extension could never have been installed on 17 anyway. Raising it to 26.0
removes the availability skew that Mac Catalyst would otherwise inherit as
macOS 14 vs macOS 26.

Verified: app and appex Info.plists now both report 3.5 / 7 / 26.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:37:35 +02:00
Scarriffle
781ccd1752 Make Keychain and shared-container state correct before the Mac port
Two failures here are silent rather than loud, which is why they have gone
unnoticed on iOS and would have been much harder to diagnose on a Mac.

The old `enum Keychain` discarded all four OSStatus results. On Mac Catalyst a
missing `keychain-access-groups` entitlement makes SecItem calls fail with
errSecMissingEntitlement, and because nothing checked, that was indistinguishable
from "no token stored" — the user would be signed out on every launch, with no
error anywhere. KeychainStore now checks every status, distinguishes
errSecItemNotFound from real failures, and sets kSecUseDataProtectionKeychain so
macOS selects the modern entitlement-gated keychain instead of the legacy login
keychain.

Adding the entitlement moves the default access group to the first array entry,
so that entry is deliberately the app's own group: existing tokens keep
resolving with an unqualified query and nobody is signed out. loadToken() then
migrates forward in three steps — shared group, own default group, and the
pre-Keychain UserDefaults copy.

If the entitlement is not provisioned yet, KeychainStore falls back to the
default group rather than throwing. A hard failure would make the app unusable
for everyone whose provisioning lags; the fallback asserts in DEBUG instead, so
a misconfiguration is loud in development and survivable in production.

Separately, logout() left widget-cache.json in the App Group container, so
widgets kept rendering the signed-out user's events indefinitely. That is an
existing iOS bug, and it would have leaked the same data to any other app
reading the container. WidgetStore.clear() now removes both cache files and
reloads the timelines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:31:06 +02:00
11 changed files with 445 additions and 239 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 = "";
@@ -314,12 +323,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -341,12 +348,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -397,6 +402,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -415,8 +421,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 3.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -461,6 +468,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -473,8 +481,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 3.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -490,9 +499,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
@@ -506,18 +516,16 @@
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)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -526,6 +534,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Debug;
};
@@ -535,9 +544,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
@@ -551,18 +561,16 @@
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)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -571,6 +579,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Release;
};
@@ -579,26 +588,25 @@
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -606,6 +614,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Debug;
};
@@ -614,32 +623,32 @@
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Release;
};
@@ -683,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

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Mac Catalyst entitlements. A separate file is required because the App
Group value differs per platform: macOS demands the Team ID prefix, iOS
forbids it, and listing both in one file breaks iOS provisioning.
Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*].
This one file serves both distribution paths. app-sandbox is required by
the Mac App Store and desirable for Developer ID; ENABLE_HARDENED_RUNTIME
is required for notarization and ignored by the App Store. Only the
signing identity differs, and that is chosen at export time.
-->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Talks to the user's own Calendarr server. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- BirthdaysImporter reads Contacts to sync birthdays. -->
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<!-- .ics import (fileImporter) and calendar export (fileExporter). -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
<key>com.apple.security.application-groups</key>
<array>
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
</array>
<!-- Own group first: it stays the default access group, so tokens written
before this entitlement existed keep resolving. The .shared group is
what a second app from this team reads the auth token from. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
</array>
</dict>
</plist>

View File

@@ -6,5 +6,15 @@
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
<!-- Order matters. Without this entitlement the default access group is
<AppIdentifierPrefix><bundle id>, which is where every existing token
lives. Adding the entitlement makes the FIRST entry the new default, so
listing our own group first keeps those tokens resolving and nobody
gets signed out. The .shared group is the one the Mac app also reads. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
</array>
</dict>
</plist>

View File

@@ -1,5 +1,4 @@
import SwiftUI
import Security
@main
struct CalendarrApp: App {
@@ -25,17 +24,45 @@ class AppState {
init() {
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
// Migrate a token previously kept in UserDefaults into the Keychain once,
// so existing logins survive the change without re-authenticating.
if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty {
Keychain.set(legacy, for: "authToken")
UserDefaults.standard.removeObject(forKey: "authToken")
}
authToken = Keychain.get("authToken") ?? ""
authToken = Self.loadToken()
username = UserDefaults.standard.string(forKey: "username") ?? ""
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
}
/// Find the stored token, migrating it forward from wherever an older build
/// left it. Runs on every launch but does real work only once.
///
/// Step 2 is the one that keeps existing users signed in. Before the
/// `keychain-access-groups` entitlement existed, items landed in the app's
/// own default group. Adding the entitlement makes the *first* array entry
/// the new default and that entry is deliberately the app's own group, so
/// an unqualified query still resolves those items and we can copy them
/// across instead of stranding them.
private static func loadToken() -> String {
let key = "authToken"
// 1. Already in the shared group the steady state.
if let token = try? KeychainStore.get(key), !token.isEmpty {
return token
}
// 2. In the app's own default group, written before the entitlement.
if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty {
try? KeychainStore.set(token, for: key)
try? KeychainStore.set(nil, for: key, accessGroup: nil)
return token
}
// 3. In UserDefaults, written before secrets moved to the Keychain.
if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty {
try? KeychainStore.set(legacy, for: key)
UserDefaults.standard.removeObject(forKey: key)
return legacy
}
return ""
}
func saveServer(url: String) {
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
@@ -46,58 +73,45 @@ class AppState {
authToken = token
username = user
isAdmin = admin
Keychain.set(token, for: "authToken") // secret Keychain, not UserDefaults
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() {
authToken = ""
username = ""
isAdmin = false
Keychain.set(nil, for: "authToken")
UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy
try? KeychainStore.set(nil, for: "authToken")
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy
UserDefaults.standard.removeObject(forKey: "username")
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")
}
}
/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are
/// stored as generic passwords, accessible after first unlock.
enum Keychain {
private static let service = "Calendarr"
static func set(_ value: String?, for key: String) {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(base as CFDictionary)
guard let value, let data = value.data(using: .utf8) else { return }
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var out: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
// 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

@@ -0,0 +1,131 @@
import Foundation
import Security
/// Keychain access for secrets (currently just the auth bearer token).
///
/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`.
/// That mattered more than it looks: on Mac Catalyst a missing
/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching`
/// fail with `errSecMissingEntitlement`, and because nothing checked the status
/// the result was indistinguishable from "no token stored" the user was
/// silently signed out on every launch.
///
/// Items live in a shared access group so the Mac build and other apps from the
/// same team can read the token. Reading requires `keychain-access-groups` in
/// the entitlements listing `sharedAccessGroup`.
enum KeychainStore {
/// Access group shared across our apps. Must match an entry in the
/// `keychain-access-groups` entitlement of every app that uses it.
/// The `PP34X97WS3.` prefix is this account's Team ID the same value
/// `$(AppIdentifierPrefix)` expands to at build time.
static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared"
private static let service = "Calendarr"
/// Set when a keychain call failed because the entitlement was missing and
/// we fell back to the app's own default access group. Signing is a build
/// configuration concern, so in DEBUG we make it loud; in release we keep
/// working rather than locking the user out of their own account.
private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false
// MARK: - Public API
/// Store or delete a value. Passing `nil` deletes it.
static func set(_ value: String?, for key: String,
accessGroup: String? = sharedAccessGroup) throws {
do {
try write(value, key: key, accessGroup: accessGroup)
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
noteEntitlementFallback()
try write(value, key: key, accessGroup: nil)
}
}
/// Read a value. Returns `nil` when the item simply is not there;
/// throws when the keychain itself refused the request.
static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? {
do {
return try read(key: key, accessGroup: accessGroup)
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
noteEntitlementFallback()
return try read(key: key, accessGroup: nil)
}
}
// MARK: - Implementation
private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
// Selects the modern, entitlement-gated keychain on macOS/Catalyst
// instead of the legacy file-based login keychain. Documented no-op
// on iOS 13+, so it is safe to set unconditionally.
kSecUseDataProtectionKeychain as String: true,
]
if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
return q
}
private static func write(_ value: String?, key: String, accessGroup: String?) throws {
let base = baseQuery(key: key, accessGroup: accessGroup)
let deleteStatus = SecItemDelete(base as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw KeychainError.status(deleteStatus, operation: "delete \(key)")
}
guard let value, let data = value.data(using: .utf8) else { return }
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError.status(addStatus, operation: "add \(key)")
}
}
private static func read(key: String, accessGroup: String?) throws -> String? {
var query = baseQuery(key: key, accessGroup: accessGroup)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var out: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &out)
switch status {
case errSecSuccess:
guard let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
case errSecItemNotFound:
return nil
default:
throw KeychainError.status(status, operation: "read \(key)")
}
}
private static func noteEntitlementFallback() {
guard !didFallBackToDefaultGroup else { return }
didFallBackToDefaultGroup = true
assertionFailure("""
Keychain access group \(sharedAccessGroup) was refused \
(errSecMissingEntitlement). Add it to keychain-access-groups and \
enable Keychain Sharing on the App ID. Falling back to the app's \
own default group — the Mac app will not see this token.
""")
}
}
enum KeychainError: Error, LocalizedError {
case status(OSStatus, operation: String)
var errorDescription: String? {
switch self {
case let .status(status, operation):
let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
return "Keychain \(operation) failed: \(detail)"
}
}
}

View File

@@ -32,11 +32,16 @@ struct CalendarEntityQuery: EntityQuery {
}
/// Widget configuration intent: lets the user pick which calendars to show.
/// An empty selection means "show all calendars" (the default).
/// No selection means "show all calendars" (the default).
///
/// The parameter must be optional: WidgetConfigurationIntent requires every
/// parameter type to be optional, and the macOS/Mac Catalyst SDK enforces that
/// where the iOS one lets a bare array through. `nil` and `[]` are treated
/// alike downstream, so this is behaviour-preserving.
struct CalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Wähle welche Kalender im Widget angezeigt werden. Leer = alle Kalender.")
@Parameter(title: "Kalender")
var selectedCalendars: [CalendarAppEntity]
var selectedCalendars: [CalendarAppEntity]?
}

View File

@@ -38,19 +38,20 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider {
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
guard let snapshot else { return nil }
let ids = config.selectedCalendars.map { $0.id }
guard !ids.isEmpty else { return snapshot } // empty = show all
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

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Mac Catalyst entitlements for the widget extension. Deliberately minimal:
the widget only ever reads the snapshot from the App Group container. It
does no networking, touches no Contacts, and needs no keychain access.
-->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
<key>com.apple.security.application-groups</key>
<array>
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>

View File

@@ -1,178 +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 match the App Group capability in BOTH targets
/// and the App Group ID registered in the Apple Developer Portal.
let widgetAppGroupID = "group.com.scarriffleservices.calendarr"
// 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
/// 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
}
/// The App Group identifier for this platform. See `CalendarrAppGroup`.
let widgetAppGroupID = CalendarrAppGroup.current
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? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
/// 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()
}
/// 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.
/// 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() {
store.clear()
WidgetTimelineNotifier.reload()
}
/// 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 {