rename: Jarvis -> Jervis target, scheme, and folder throughout
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>
This commit is contained in:
151
Jervis/Store/BackgroundRefreshManager.swift
Normal file
151
Jervis/Store/BackgroundRefreshManager.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user