From ad7a405f3295537b2e94e2a593408b9b1214c257 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:39:34 +0200 Subject: [PATCH] Refresh the shared snapshot in the background so widgets stop going stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishWidgetSnapshot() only ever ran from a user action, so an event created on the web or on Android did not reach the widgets until the app was opened again. A BGAppRefreshTask now republishes without the foreground. The two keys this needs are arrays, which Xcode does not expose as INFOPLIST_KEY_ build settings — verified by building and inspecting the result. Hence a partial Calendarr-Info.plist that generation merges into. It sits at the project root rather than inside the target folder: that folder is a synchronized group, which would also copy the file as a resource and collide with the processed one. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 2 + Calendarr iOS/CalendarrApp.swift | 7 ++ .../Services/BackgroundRefresh.swift | 82 +++++++++++++++++++ Calendarr-Info.plist | 14 ++++ 4 files changed, 105 insertions(+) create mode 100644 Calendarr iOS/Services/BackgroundRefresh.swift create mode 100644 Calendarr-Info.plist diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index bdaaaad..1152630 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -509,6 +509,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Calendarr-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -558,6 +559,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Calendarr-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index a12c147..c6bbe26 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -4,10 +4,17 @@ import SwiftUI struct CalendarrApp: App { @State private var appState = AppState() + init() { + // Registration has to happen before the app finishes launching, which + // is why it is here and not in a .task modifier. + BackgroundRefresh.register() + } + var body: some Scene { WindowGroup { RootView() .environment(appState) + .task { BackgroundRefresh.schedule() } } } } diff --git a/Calendarr iOS/Services/BackgroundRefresh.swift b/Calendarr iOS/Services/BackgroundRefresh.swift new file mode 100644 index 0000000..fbacf28 --- /dev/null +++ b/Calendarr iOS/Services/BackgroundRefresh.swift @@ -0,0 +1,82 @@ +import BackgroundTasks +import Foundation +import CalendarrCore + +/// Keeps the shared snapshot fresh while the app is not in the foreground. +/// +/// Without this, `publishWidgetSnapshot()` only ever runs from a user action, +/// so an event created on the web or on Android does not reach the widgets — or +/// the watch — until the app is opened again. iOS decides when this actually +/// runs, based on how the user uses the app: it improves freshness, it does not +/// guarantee it. +@MainActor +enum BackgroundRefresh { + static let taskIdentifier = "com.scarriffleservices.calendarr.ios.refresh" + + /// Minimum gap between runs we ask for. iOS treats this as the earliest + /// acceptable time, not a promise. + private static let interval: TimeInterval = 30 * 60 + + /// Call once at launch, before the app finishes starting up. + static func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, + using: nil) { task in + guard let refresh = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + Task { @MainActor in await handle(refresh) } + } + } + + /// Ask for the next run. Safe to call repeatedly; a duplicate submission + /// replaces the pending request rather than queueing a second one. + static func schedule() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval) + do { try BGTaskScheduler.shared.submit(request) } + catch { + // Submission fails on a simulator and when the user has disabled + // background refresh. Neither is worth interrupting anyone over. + print("[BackgroundRefresh] submit failed: \(error.localizedDescription)") + } + } + + private static func handle(_ task: BGAppRefreshTask) async { + // Reschedule first: if the work below throws or is killed, the chain + // must not end here. + schedule() + + let work = Task { @MainActor in await refreshSnapshot() } + task.expirationHandler = { work.cancel() } + + let succeeded = await work.value + task.setTaskCompleted(success: succeeded) + } + + /// Fetch the snapshot window and republish. Returns false when there was + /// nothing to do or the fetch failed. + private static func refreshSnapshot() async -> Bool { + let state = AppState() + guard state.isConfigured, state.isLoggedIn else { return false } + + // An SSO session can lapse while the app is closed; renewing first + // avoids spending the whole background window on a 401. + await OIDCSessionRefresher.refreshIfNeeded(state) + + let api = CalendarrAPI(baseURL: state.serverURL, token: state.authToken) + let store = CalendarStore() + + let calendar = Calendar(identifier: .gregorian) + let dayStart = calendar.startOfDay(for: Date()) + guard let from = calendar.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart), + let to = calendar.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) + else { return false } + + // force: a freshly created store has an empty in-memory cache, so + // without this the "already cached" short-circuit would skip the fetch. + await store.loadEvents(api: api, start: from, end: to, force: true) + + return store.lastError == nil + } +} diff --git a/Calendarr-Info.plist b/Calendarr-Info.plist new file mode 100644 index 0000000..c982e17 --- /dev/null +++ b/Calendarr-Info.plist @@ -0,0 +1,14 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.scarriffleservices.calendarr.ios.refresh + + UIBackgroundModes + + fetch + + +