Refresh the shared snapshot in the background so widgets stop going stale

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-11 16:39:34 +02:00
co-authored by Claude Opus 5
parent c8a36c161f
commit ad7a405f32
4 changed files with 105 additions and 0 deletions
+2
View File
@@ -509,6 +509,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Calendarr-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -558,6 +559,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Calendarr-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+7
View File
@@ -4,10 +4,17 @@ import SwiftUI
struct CalendarrApp: App { struct CalendarrApp: App {
@State private var appState = AppState() @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 { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(appState) .environment(appState)
.task { BackgroundRefresh.schedule() }
} }
} }
} }
@@ -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
}
}
+14
View File
@@ -0,0 +1,14 @@
<?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>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.scarriffleservices.calendarr.ios.refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict>
</plist>