The bundle-ID and display-name renames weren't enough — the actual Xcode target/product name was still "Jarvis", which drives CFBundleName, Xcode's Organizer archive list, the .xcodeproj filename, and the scheme name. Renamed all the way through: - project.yml: top-level name, target key, PRODUCT_NAME, source/info paths - Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content diffs on the moved files) - .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj / scheme Jarvis -> Jervis.xcodeproj / scheme Jervis Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app, CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis". CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior decision — bundle ID isn't user-facing anywhere including Organizer). Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo name/URL are unchanged — pure internal source identifiers, not user or developer-facing product identity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
5.3 KiB
Swift
152 lines
5.3 KiB
Swift
// 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
|
|
import UserNotifications
|
|
|
|
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)
|
|
|
|
var newStories: [StorySummary] = []
|
|
|
|
for story in page.data {
|
|
let id = story.id
|
|
if let existing = (try? context.fetch(
|
|
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
|
existing.signalScore = story.signalScore
|
|
existing.sourceCount = story.sourceCount
|
|
existing.updatedAt = story.updatedAt
|
|
} else {
|
|
context.insert(CachedStory(from: story))
|
|
newStories.append(story)
|
|
}
|
|
}
|
|
|
|
try? context.save()
|
|
|
|
// Pre-cache articles for offline reading
|
|
await preCacheArticles(page.data, container: container)
|
|
|
|
if !newStories.isEmpty {
|
|
notifyNewStories(newStories)
|
|
}
|
|
} catch {
|
|
// Network unavailable in background — fail silently, next run will catch up.
|
|
}
|
|
}
|
|
|
|
// MARK: - Article pre-caching
|
|
|
|
/// Fetch and store the top article for each high-signal uncached story so
|
|
/// the user can read them offline without having opened them first.
|
|
/// Safe to call in background or foreground — creates its own ModelContext.
|
|
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
|
|
let context = ModelContext(container)
|
|
let top = stories
|
|
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
|
|
.sorted { $0.signalScore > $1.signalScore }
|
|
.prefix(20)
|
|
|
|
for story in top {
|
|
let sid = story.id
|
|
let alreadyCached = (try? context.fetch(
|
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
|
|
))?.isEmpty == false
|
|
guard !alreadyCached, let src = story.sources.first else { continue }
|
|
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
|
|
context.insert(CachedArticle(from: art))
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - New-story notifications
|
|
|
|
// Slugs for the featured categories the user cares about.
|
|
private static let featuredSlugs: Set<String> = [
|
|
"artificial-intelligence", "machine-learning", "robotics",
|
|
"technology", "cybersecurity", "security", "privacy-and-data-protection",
|
|
"programming-and-software-development", "cloud-computing", "web-design-and-ui-ux",
|
|
"homelab",
|
|
]
|
|
|
|
private static let minSignal = 65
|
|
|
|
private static func notifyNewStories(_ stories: [StorySummary]) {
|
|
// Best overall story above threshold.
|
|
let eligible = stories
|
|
.filter { $0.signalScore >= minSignal }
|
|
.sorted { $0.signalScore > $1.signalScore }
|
|
guard let lead = eligible.first else { return }
|
|
|
|
let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? []))
|
|
let isFeatured = !tags.isDisjoint(with: featuredSlugs)
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = isFeatured
|
|
? "📡 \(Topic.label(lead.topic))"
|
|
: "📡 New signals"
|
|
content.body = lead.headline
|
|
if eligible.count > 1 {
|
|
content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")"
|
|
}
|
|
content.sound = .default
|
|
|
|
let req = UNNotificationRequest(
|
|
identifier: "feed.new.\(UUID().uuidString)",
|
|
content: content,
|
|
trigger: nil
|
|
)
|
|
UNUserNotificationCenter.current().add(req)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|