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>
228 lines
9.8 KiB
Swift
228 lines
9.8 KiB
Swift
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
|
||
}
|
||
}
|