Compare commits

...

4 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
9 changed files with 254 additions and 189 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 = "";
@@ -393,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;
@@ -411,10 +421,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
CURRENT_PROJECT_VERSION = 7;
MARKETING_VERSION = 3.5;
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;
@@ -459,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;
@@ -471,10 +481,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
CURRENT_PROJECT_VERSION = 7;
MARKETING_VERSION = 3.5;
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,8 +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;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
@@ -505,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)",
@@ -515,7 +525,7 @@
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;
@@ -524,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;
};
@@ -533,8 +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;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
@@ -548,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)",
@@ -558,7 +570,7 @@
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;
@@ -567,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;
};
@@ -575,6 +588,7 @@
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;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
@@ -592,7 +606,7 @@
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)";
@@ -600,6 +614,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
};
name = Debug;
};
@@ -608,6 +623,7 @@
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;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
@@ -625,13 +641,14 @@
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;
};
@@ -675,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

@@ -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

@@ -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,18 +38,19 @@ 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,
coverageStart: snapshot.coverageStart,
coverageEnd: snapshot.coverageEnd,
isLoggedIn: snapshot.isLoggedIn,
writerVersion: snapshot.writerVersion,
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,
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,189 +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()
}
/// 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.
static func clear() {
for url in [cacheURL, calendarsURL].compactMap({ $0 }) {
try? FileManager.default.removeItem(at: url)
/// 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
/// 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 {