feat(bg): add background feed refresh every ~30 min
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Registers com.kisani.jarvis.feed.refresh as a BGAppRefreshTask.
On each fire it fetches the latest 40 stories and upserts CachedStory
rows so the SwiftData cache is warm before the user opens the app.
Already-cached rows have signalScore / sourceCount refreshed in-place.

Single ModelContainer owned by JarvisApp and shared via
BackgroundRefreshManager.container to avoid multi-container conflicts.
BG task ID registered in AppDelegate and declared in project.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-21 01:42:20 +03:00
parent 7085a4dd45
commit 3b09fa2889
3 changed files with 98 additions and 3 deletions

View File

@@ -8,7 +8,7 @@ import BackgroundTasks
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// Register the briefing background-refresh handler before launch completes. // Briefing notifications refresh
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
Task { @MainActor in Task { @MainActor in
await NotificationManager.shared.applySettings() await NotificationManager.shared.applySettings()
@@ -16,6 +16,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
} }
// Story feed cache refresh
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisFeedRefreshID, using: nil) { task in
guard let refresh = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false); return
}
BackgroundRefreshManager.handleFeedRefresh(task: refresh)
}
return true return true
} }
} }
@@ -31,6 +38,13 @@ struct JarvisApp: App {
@StateObject private var connManager = ConnectivityManager.shared @StateObject private var connManager = ConnectivityManager.shared
@StateObject private var notifications = NotificationManager.shared @StateObject private var notifications = NotificationManager.shared
// Single ModelContainer instance shared with BackgroundRefreshManager.
private let container: ModelContainer = {
let schema = Schema([CachedStory.self, CachedArticle.self,
ReadStory.self, SavedStory.self, SeenStory.self])
return try! ModelContainer(for: schema)
}()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
@@ -48,12 +62,15 @@ struct JarvisApp: App {
.environmentObject(connManager) .environmentObject(connManager)
.environmentObject(notifications) .environmentObject(notifications)
.task { .task {
// Wire the shared container before scheduling BG work.
BackgroundRefreshManager.container = container
BackgroundRefreshManager.scheduleFeedRefresh()
connManager.start() connManager.start()
await connManager.resolveAndActivate() await connManager.resolveAndActivate()
await notifications.bootstrap() await notifications.bootstrap()
} }
} }
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, .modelContainer(container)
SavedStory.self, SeenStory.self])
} }
} }

View File

@@ -0,0 +1,77 @@
// BackgroundRefreshManager.swift
// Jarvis background story cache refresh. Fires via BGAppRefreshTask every ~30 min,
// fetches the latest feed, and upserts CachedStory rows so the SwiftData cache is
// warm before the user opens the app.
import Foundation
import BackgroundTasks
import SwiftData
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
enum BackgroundRefreshManager {
// Injected once from JarvisApp so the BG task can open a context on the same store.
static var container: ModelContainer?
// MARK: - Task handler
static func handleFeedRefresh(task: BGAppRefreshTask) {
// Reschedule first if the task is killed we still get another run later.
scheduleFeedRefresh()
guard let container else {
task.setTaskCompleted(success: false)
return
}
let work = Task {
await fetchAndCache(container: container)
}
task.expirationHandler = {
work.cancel()
task.setTaskCompleted(success: false)
}
Task {
_ = await work.result
task.setTaskCompleted(success: !work.isCancelled)
}
}
// MARK: - Core fetch + upsert
private static func fetchAndCache(container: ModelContainer) async {
do {
let page = try await APIClient.shared.fetchStories(limit: 40)
let context = ModelContext(container)
for story in page.data {
let id = story.id
if let existing = (try? context.fetch(
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
// Refresh signal score and freshness on already-cached rows.
existing.signalScore = story.signalScore
existing.sourceCount = story.sourceCount
existing.updatedAt = story.updatedAt
} else {
context.insert(CachedStory(from: story))
}
}
try? context.save()
} catch {
// Network unavailable in background fail silently, next run will catch up.
}
}
// MARK: - Schedule
static func scheduleFeedRefresh() {
let req = BGAppRefreshTaskRequest(identifier: jarvisFeedRefreshID)
// iOS enforces a minimum interval (~15 min); 30 min is a reasonable target.
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
try? BGTaskScheduler.shared.submit(req)
}
}

View File

@@ -34,6 +34,7 @@ targets:
- processing - processing
BGTaskSchedulerPermittedIdentifiers: BGTaskSchedulerPermittedIdentifiers:
- com.kisani.jarvis.briefing.refresh - com.kisani.jarvis.briefing.refresh
- com.kisani.jarvis.feed.refresh
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis