rename: Jarvis -> Jervis target, scheme, and folder throughout
Some checks are pending
CI / Build · Debug (push) Waiting to run
CI / Build · Release (push) Waiting to run

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:
Kutesir
2026-07-13 03:49:31 +03:00
parent 3a6f1bbdd8
commit bd632a1592
46 changed files with 20 additions and 19 deletions

View 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)
}
}

View File

@@ -0,0 +1,60 @@
// CacheMaintenance.swift
// Jarvis bounds the on-device SwiftData caches so they don't grow forever.
// Saved (bookmarked) stories are always protected.
import Foundation
import SwiftData
@MainActor
enum CacheMaintenance {
private static let hour: TimeInterval = 3_600
private static let day: TimeInterval = 86_400
private static let recentHours = 72.0 // 3 days: mirrors backend active story window
private static let markerHours = 192.0 // 8 days: read suppression survives weekly repeats
private static let articleDays = 14.0 // cached article bodies (backend article retention)
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
static func prune(_ context: ModelContext) {
let now = Date()
let seenCutoff = now.addingTimeInterval(-markerHours * hour)
let readCutoff = now.addingTimeInterval(-markerHours * hour)
let articleCutoff = now.addingTimeInterval(-articleDays * day)
let storyCutoff = now.addingTimeInterval(-recentHours * hour)
// Markers: simple age batch deletes.
try? context.delete(model: SeenStory.self, where: #Predicate { $0.seenAt < seenCutoff })
try? context.delete(model: ReadStory.self, where: #Predicate { $0.readAt < readCutoff })
// Offline content: drop old entries that aren't saved.
let saved = savedIDs(context)
if let arts = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.cachedAt < articleCutoff })) {
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
}
if let stories = try? context.fetch(
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.cachedAt < storyCutoff })) {
for s in stories where !saved.contains(s.id) { context.delete(s) }
}
try? context.save()
}
/// Manual "Clear cache" wipes offline stories/articles + seen/read state,
/// keeps your saved bookmarks (and their articles).
static func clear(_ context: ModelContext) {
try? context.delete(model: SeenStory.self)
try? context.delete(model: ReadStory.self)
let saved = savedIDs(context)
if let arts = try? context.fetch(FetchDescriptor<CachedArticle>()) {
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
}
if let stories = try? context.fetch(FetchDescriptor<CachedStory>()) {
for s in stories where !saved.contains(s.id) { context.delete(s) }
}
try? context.save()
}
private static func savedIDs(_ context: ModelContext) -> Set<String> {
Set((try? context.fetch(FetchDescriptor<SavedStory>()))?.map(\.id) ?? [])
}
}

View File

@@ -0,0 +1,48 @@
// ServerSettings.swift
// Jarvis persists the configured server host
import Foundation
@MainActor
final class ServerSettings: ObservableObject {
static let shared = ServerSettings()
private let key = "jarvis_server_host"
@Published var host: String? {
didSet { UserDefaults.standard.set(host, forKey: key) }
}
var isConfigured: Bool { host != nil }
private init() {
host = UserDefaults.standard.string(forKey: key)
}
func save(host: String) async throws {
try await APIClient.shared.configure(host: host)
_ = try await APIClient.shared.checkHealth()
self.host = host
WebSocketManager.shared.connect(host: host)
}
func reset() {
host = nil
WebSocketManager.shared.disconnect()
}
/// Re-establish REST + WebSocket from a previously saved host on app launch.
func reconnectIfConfigured() async {
guard let host else { return }
try? await APIClient.shared.configure(host: host)
WebSocketManager.shared.connect(host: host)
}
/// Point REST + WebSocket at a host the ConnectivityManager already verified
/// (skips the health check the probe just confirmed it).
func activate(host: String) async {
try? await APIClient.shared.configure(host: host)
self.host = host
WebSocketManager.shared.connect(host: host)
}
}

View File

@@ -0,0 +1,334 @@
// StoryStore.swift
// Jarvis central observable store. Views read from here, never hit the API directly.
import Foundation
import Combine
import SwiftData
@MainActor
final class StoryStore: ObservableObject {
static let shared = StoryStore()
@Published var stories: [StorySummary] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: APIError?
@Published var lastSyncedAt: Date?
@Published var hasMore = false
@Published var selectedTopic: String? = nil
@Published var selectedTags: Set<String> = []
@Published var sectionSupplement: [String: [StorySummary]] = [:]
private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var foregroundSyncTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
// MARK: - Quick cache (UserDefaults synchronous, no async gap)
private let quickCacheKey = "jarvis.quickStories.v1"
private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days
/// Restore the last-known feed from UserDefaults synchronously on init.
/// This runs before the first SwiftUI render so the feed is never blank.
private func restoreQuickCache() {
guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return }
// Discard if too stale matches CacheMaintenance window.
guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else {
UserDefaults.standard.removeObject(forKey: quickCacheKey)
return
}
if !payload.stories.isEmpty { stories = payload.stories }
}
/// Persist the current feed to UserDefaults so it survives process kills.
/// Encoding runs off the main thread; UserDefaults.set is thread-safe.
private func persistQuickCache() {
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
let key = quickCacheKey
Task.detached {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(payload) {
UserDefaults.standard.set(data, forKey: key)
}
}
}
private struct QuickCachePayload: Codable {
let stories: [StorySummary]
let savedAt: Date
}
// MARK: - SwiftData bootstrap (secondary, async covers first-ever launch before
// the quick cache exists, e.g. app reinstall).
func bootstrap(container: ModelContainer) {
guard stories.isEmpty else { return }
let ctx = ModelContext(container)
if let cached = try? ctx.fetch(FetchDescriptor<CachedStory>()) {
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
if !sorted.isEmpty { stories = sorted }
}
}
// MARK: - Foreground sync
private var isFetchingFull = false
/// Load page 1 then paginate to 100 stories.
/// Guarded against concurrent invocations and rate-limited to 30 s between
/// fetches so rapid foreground/background cycles don't hammer the API.
func loadFeedFull() async {
guard !isFetchingFull else { return }
// Rate-limit: if the last *successful* sync was < 30 s ago, skip.
if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return }
isFetchingFull = true
defer { isFetchingFull = false }
await loadStories(refresh: true)
var pages = 0
while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 {
await loadMore()
pages += 1
}
loadSectionSupplements()
}
/// Call when app becomes active. Polls every 90 s so new stories surface fast.
func startForegroundSync() {
foregroundSyncTask?.cancel()
foregroundSyncTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
guard !Task.isCancelled else { return }
await self?.loadFeedFull()
}
}
}
func stopForegroundSync() {
foregroundSyncTask?.cancel()
foregroundSyncTask = nil
}
private let ws = WebSocketManager.shared
private let api = APIClient.shared
private init() {
restoreQuickCache() // synchronous stories are ready before first render
subscribeToWebSocket()
}
// MARK: - Load
@discardableResult
func loadStories(refresh: Bool = false) async -> Int {
guard !isLoading else { return 0 }
isLoading = true
error = nil
let cursor = refresh ? nil : nextCursor
var newCount = 0
do {
let result = try await api.fetchStories(
cursor: cursor,
topic: selectedTopic,
tags: selectedTags
)
if refresh {
// Replace list; count only genuinely new IDs for reshuffle logic.
let existingIds = Set(stories.map(\.id))
newCount = result.data.filter { !existingIds.contains($0.id) }.count
stories = result.data
} else {
// Append only IDs not already present cursor can overlap.
let existingIds = Set(stories.map(\.id))
let novel = result.data.filter { !existingIds.contains($0.id) }
stories += novel
newCount = novel.count
}
nextCursor = result.nextCursor
hasMore = result.hasMore
lastSyncedAt = Date()
persistQuickCache() // survives the next process kill
preCacheArticlesInBackground()
} catch let e as APIError where !e.isCancelled {
error = e
print("[StoryStore] fetch failed: \(e.localizedDescription)")
} catch {
print("[StoryStore] unexpected fetch error: \(error)")
}
isLoading = false
return newCount
}
private func preCacheArticlesInBackground() {
guard let container = BackgroundRefreshManager.container else { return }
let snapshot = stories
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
}
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
func mergeStories(_ extra: [StorySummary]) async {
let existing = Set(stories.map(\.id))
let novel = extra.filter { !existing.contains($0.id) }
guard !novel.isEmpty else { return }
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
}
func loadMore() async {
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
isLoadingMore = true
do {
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
// Deduplicate: cursor-based pages can overlap when stories are added mid-fetch.
let existingIds = Set(stories.map(\.id))
let novel = result.data.filter { !existingIds.contains($0.id) }
stories += novel
nextCursor = result.nextCursor
hasMore = result.hasMore
} catch {
print("[StoryStore] loadMore error: \(error)")
}
isLoadingMore = false
}
/// Coalesce bursts of `story.created` WS events waits 3 s then does a
/// full paginated load so stories on page 2+ are captured.
private func scheduleCoalescedRefresh() {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard !Task.isCancelled else { return }
await self?.loadFeedFull()
}
}
// MARK: - Section supplements
// Sections with regional or niche tags rarely rank in the global top-100,
// so we fetch a small targeted pool for each sparse section and let
// makeDigest() use it as a fallback.
private let supplementSections: [(id: String, tags: Set<String>)] = [
("east-africa", ["east-africa"]),
("africa", ["africa"]),
("sport", ["sports", "esports", "formula-1"]),
("science", ["science", "astronomy"]),
("health", ["health", "health-and-wellness"]),
]
func loadSectionSupplements() {
guard selectedTags.isEmpty else { return } // only for "All" pill
let sections = supplementSections
Task { [weak self] in
guard let self else { return }
var merged: [String: [StorySummary]] = [:]
await withTaskGroup(of: (String, [StorySummary]?).self) { group in
for sec in sections {
let id = sec.id
let tags = sec.tags
group.addTask {
let result = try? await APIClient.shared.fetchStories(tags: tags, limit: 8)
return (id, result?.data)
}
}
for await (id, data) in group {
if let data { merged[id] = data }
}
}
self.sectionSupplement = merged // single main-thread update
}
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
selectedTags = []
await loadFeedFull()
}
func setPill(_ pill: StoryPill) async {
selectedTopic = nil
selectedTags = pill.slugs
nextCursor = nil
// Filter changed must fetch regardless of rate-limit, otherwise
// the old stories stay in place and the new pill finds almost nothing.
lastSyncedAt = nil
await loadFeedFull()
}
// MARK: - WebSocket
private func subscribeToWebSocket() {
ws.events
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handle(event: event)
}
.store(in: &cancellables)
}
private func handle(event: WSEvent) {
switch event.type {
case .storyUpdated:
guard let id = event.storyId,
let score = event.signalScore,
let count = event.sourceCount else { return }
updateStory(id: id, signalScore: score, sourceCount: count)
Task { await refetch(storyId: id) }
case .storyCreated:
scheduleCoalescedRefresh()
case .storyStale:
guard let id = event.storyId else { return }
removeStory(id: id)
case .feedHealth:
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
default:
break
}
}
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
let old = stories[idx]
let updated = StorySummary(
id: old.id,
headline: old.headline,
summary: old.summary,
topic: old.topic,
tags: old.tags,
signalScore: signalScore,
scoreBreakdown: old.scoreBreakdown,
sourceCount: sourceCount,
sources: old.sources,
consensus: old.consensus,
conflict: old.conflict,
updatedAt: old.updatedAt,
createdAt: old.createdAt,
firstSeenAt: old.firstSeenAt
)
stories[idx] = updated
stories.sort(by: StorySummary.feedOrder)
}
private func removeStory(id: String) {
stories.removeAll { $0.id == id }
}
private func refetch(storyId: String) async {
// Lightweight: only update what changed via WS patch above.
// Full detail is fetched lazily when user taps into the story.
}
}
extension Notification.Name {
static let feedHealthChanged = Notification.Name("feedHealthChanged")
}