Files
Calendarr-IOS/Shared/WidgetData.swift
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

228 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
#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
}
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
}
}
enum WidgetStore {
private static let cacheFilename = "widget-cache.json"
private static let calendarsFilename = "widget-calendars.json"
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
}
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)
}
}
/// 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)
}
/// 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)
}
}
/// 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)) ?? []
}
/// 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)
}
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.
static func republishAppearanceOnly() {
guard let existing = read() else { return }
let defaults = UserDefaults.standard
let updated = WidgetSnapshot(
writtenAt: Date(),
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
)
write(updated)
WidgetTimelineNotifier.reload()
}
}
enum WidgetTimelineNotifier {
static func reload() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}