diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index c3f052d..8c4e31f 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -177,6 +177,7 @@ final class AppState { didSet { if isLaunchComplete { refreshEffectiveDoNotDisturb() } } } @ObservationIgnored nonisolated(unsafe) private var quietHoursTask: Task? + @ObservationIgnored nonisolated(unsafe) private var idleHibernationTask: Task? /// App lock (Touch ID / password), loaded from AppPreferences. `isLocked` /// drives an opaque cover over the window content in ContentView. @@ -198,6 +199,12 @@ final class AppState { /// AppPreferences at launch. Written via `setAutoDarkModeEnabled(_:)`. var autoDarkModeEnabled = false + /// Auto-hibernate idle background services. Loaded from AppPreferences at + /// launch; written via `setAutoHibernateIdleEnabled(_:)`. + var autoHibernateIdleEnabled = false + /// Idle minutes before auto-hibernation fires. Mirrored from AppPreferences. + var autoHibernateIdleMinutes = 10 + /// Default camera/microphone permission for services that haven't pinned /// their own, mirrored from AppPreferences at launch. Written via /// `setDefaultCameraPolicy(_:)` / `setDefaultMicrophonePolicy(_:)`. Read on @@ -1019,6 +1026,66 @@ final class AppState { } } + /// Catalog categories whose services must never auto-hibernate, because a + /// hibernated web app can't fire a real-time notification — only refresh its + /// badge on the 60s poll. Chat apps are the ones you need to hear from the + /// instant a message lands, so they stay fully live. Email tolerates the + /// badge delay, so it isn't exempted here; a user who wants instant mail can + /// mark that service "Keep Loaded". + private static let notificationCriticalCategories: Set = ["Messaging"] + + /// Whether a service must stay live for real-time notifications, by its + /// catalog category. Custom (non-catalog) services aren't covered — use + /// "Keep Loaded" for those. + private func isNotificationCritical(_ serviceID: UUID) -> Bool { + guard let service = fetchService(id: serviceID), + let catalogID = service.catalogEntryID, + let entry = ServiceCatalog.shared.entry(for: catalogID) + else { return false } + return Self.notificationCriticalCategories.contains(entry.category) + } + + /// Runs a periodic idle sweep while the feature is on, fully hibernating + /// background services idle past the threshold — except chat apps (kept live + /// for instant alerts), "Keep Loaded" services, and any service in a call. + private func startIdleHibernationTimer() { + idleHibernationTask?.cancel() + guard autoHibernateIdleEnabled else { return } + idleHibernationTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(60)) + await self?.hibernateIdleServices() + } + } + } + + private func hibernateIdleServices() async { + guard autoHibernateIdleEnabled, !isLocked else { return } + let threshold = TimeInterval(autoHibernateIdleMinutes * 60) + let candidates = webViewPool.idleServiceIDs(idleFor: threshold, now: Date()) + for id in candidates { + guard !isNotificationCritical(id) else { continue } + // The pool does the call check and re-validates the guards across + // that await, so a service the user switches to mid-sweep is never + // hibernated out from under them. + await webViewPool.hibernateIfStillIdle(id) + } + } + + /// Turns auto-hibernation on/off, persists it, and starts or stops the sweep. + func setAutoHibernateIdleEnabled(_ enabled: Bool) { + autoHibernateIdleEnabled = enabled + let prefs = ensurePreferences() + prefs.autoHibernateIdleEnabled = enabled + do { + try modelContainer.mainContext.save() + } catch { + AppLogger.dataStore.error("Failed to save auto-hibernate toggle: \(error.localizedDescription)") + modelContainer.mainContext.rollback() + } + startIdleHibernationTimer() + } + // MARK: - App lock /// Shows the lock screen. No-op unless the lock is enabled, so a stray @@ -1605,6 +1672,8 @@ final class AppState { autoDarkModeEnabled = prefs?.autoDarkModeEnabledEffective ?? false let googleFallback = prefs?.googleFaviconFallbackEnabledEffective ?? false Task { await FaviconFetcher.shared.setGoogleFallbackEnabled(googleFallback) } + autoHibernateIdleEnabled = prefs?.autoHibernateIdleEnabledEffective ?? false + autoHibernateIdleMinutes = prefs?.autoHibernateIdleMinutesEffective ?? 10 defaultCameraPolicy = prefs?.defaultCameraPolicyRaw.flatMap(MediaPermissionPolicy.init(rawValue:)) ?? .ask defaultMicrophonePolicy = prefs?.defaultMicrophonePolicyRaw.flatMap(MediaPermissionPolicy.init(rawValue:)) ?? .ask // Start locked at launch when opted in; ContentView's lock overlay @@ -1624,6 +1693,7 @@ final class AppState { // Apply any active quiet-hours schedule now, then keep it current. self.refreshEffectiveDoNotDisturb() self.startQuietHoursTimer() + self.startIdleHibernationTimer() self.setupLockObservers() self.startDarkMode() } diff --git a/Chorus/Models/AppPreferences.swift b/Chorus/Models/AppPreferences.swift index f275e65..446e499 100644 --- a/Chorus/Models/AppPreferences.swift +++ b/Chorus/Models/AppPreferences.swift @@ -108,6 +108,17 @@ final class AppPreferences { /// and a custom service's host can be private. Off just means a service /// whose own host serves no usable icon falls back to its monogram. var googleFaviconFallbackEnabled: Bool? + /// Fully hibernate a background service after it has been idle for + /// `autoHibernateIdleMinutes`, freeing its WebContent process. Optional for + /// SwiftData lightweight migration; nil is treated as off — opt-in because it + /// changes runtime behaviour. Notification-critical services (the Messaging + /// catalog category) and any service marked "Keep Loaded" are never touched, + /// so real-time alerts for chat apps are preserved; a hibernated service still + /// updates its unread badge via the 60s poll. + var autoHibernateIdleEnabled: Bool? + + /// Idle minutes before auto-hibernation kicks in. Optional; nil resolves to 10. + var autoHibernateIdleMinutes: Int? init( id: UUID = UUID(), @@ -132,7 +143,9 @@ final class AppPreferences { annoyanceBlockingEnabled: Bool? = nil, defaultCameraPolicyRaw: String? = nil, defaultMicrophonePolicyRaw: String? = nil, - googleFaviconFallbackEnabled: Bool? = nil + googleFaviconFallbackEnabled: Bool? = nil, + autoHibernateIdleEnabled: Bool? = nil, + autoHibernateIdleMinutes: Int? = nil ) { self.id = id self.appPresenceMode = appPresenceMode @@ -157,6 +170,8 @@ final class AppPreferences { self.defaultCameraPolicyRaw = defaultCameraPolicyRaw self.defaultMicrophonePolicyRaw = defaultMicrophonePolicyRaw self.googleFaviconFallbackEnabled = googleFaviconFallbackEnabled + self.autoHibernateIdleEnabled = autoHibernateIdleEnabled + self.autoHibernateIdleMinutes = autoHibernateIdleMinutes } /// Materialises the storage-optional default zoom (nil → 1.0). @@ -184,4 +199,11 @@ final class AppPreferences { /// Materialises the storage-optional Google favicon fallback flag (nil → false). var googleFaviconFallbackEnabledEffective: Bool { googleFaviconFallbackEnabled ?? false } + /// Materialises the storage-optional auto-hibernate flag (nil → false). + var autoHibernateIdleEnabledEffective: Bool { autoHibernateIdleEnabled ?? false } + + /// Idle minutes before auto-hibernation, clamped to a sane 1...120 (nil → 10). + var autoHibernateIdleMinutesEffective: Int { + min(120, max(1, autoHibernateIdleMinutes ?? 10)) + } } diff --git a/Chorus/Views/Settings/SettingsView.swift b/Chorus/Views/Settings/SettingsView.swift index 711d310..8226fef 100644 --- a/Chorus/Views/Settings/SettingsView.swift +++ b/Chorus/Views/Settings/SettingsView.swift @@ -148,6 +148,36 @@ struct GeneralSettingsView: View { .foregroundStyle(.secondary) } + Section("Performance") { + Toggle("Hibernate idle background services", isOn: Binding( + get: { prefs.autoHibernateIdleEnabledEffective }, + set: { value in + appState.setAutoHibernateIdleEnabled(value) + } + )) + + if prefs.autoHibernateIdleEnabledEffective { + Picker("After", selection: Binding( + get: { prefs.autoHibernateIdleMinutesEffective }, + set: { value in + ensurePrefs().autoHibernateIdleMinutes = value + appState.autoHibernateIdleMinutes = value + save("auto-hibernate interval") + } + )) { + Text("5 minutes").tag(5) + Text("10 minutes").tag(10) + Text("15 minutes").tag(15) + Text("30 minutes").tag(30) + Text("1 hour").tag(60) + } + } + + Text("Frees the memory and CPU of a service you haven't opened in a while, releasing its process until you return. Chat apps (Slack, Teams, WhatsApp, and the like) stay live so their notifications still arrive the instant a message lands; a hibernated service still updates its unread badge about once a minute. Mark any service \"Keep Loaded\" to exempt it.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Startup") { Toggle("Open at login", isOn: Binding( get: { presenceManager.isLaunchAtLoginEnabled }, diff --git a/Chorus/Views/WebView/WebViewPool.swift b/Chorus/Views/WebView/WebViewPool.swift index a402ee9..cea3c33 100644 --- a/Chorus/Views/WebView/WebViewPool.swift +++ b/Chorus/Views/WebView/WebViewPool.swift @@ -702,11 +702,63 @@ final class WebViewPool { darkThemeCache.remove(for: id) } + /// Live services idle for at least `threshold`, eligible for auto-hibernation: + /// not the active service, not "Keep Loaded", not pinned, and actually loaded. + /// The caller applies the category and active-call exemptions — this only does + /// the time-and-flag selection the pool can answer on its own. + func idleServiceIDs(idleFor threshold: TimeInterval, now: Date) -> [UUID] { + lastAccessTimes.compactMap { id, accessed in + guard id != activeServiceID, + !neverHibernateIDs.contains(id), + !pinnedIDs.contains(id), + webViews[id] != nil, + now.timeIntervalSince(accessed) >= threshold + else { return nil } + return id + } + } + + /// Fully hibernates `id` iff it is still eligible after the async call check. + /// + /// `hasActiveCall` is a suspension point (up to its own 2s timeout), and the + /// user can switch to this service — or pin it, mark it never-hibernate, or + /// close it — while it's suspended. So the guards are re-checked AFTER the + /// await, with no further suspension before `hibernate`, so a service the + /// user is now viewing is never torn down under them. `evictionInFlight` + /// keeps two passes (the cap sweep and the idle sweep) from racing the same + /// id. Shared by both callers so the re-validation lives in one place. + /// Returns true iff it hibernated. + @discardableResult + func hibernateIfStillIdle(_ id: UUID) async -> Bool { + guard webViews[id] != nil, + id != activeServiceID, + !pinnedIDs.contains(id), + !neverHibernateIDs.contains(id), + !evictionInFlight.contains(id) + else { return false } + + evictionInFlight.insert(id) + let hasCall = await hasActiveCall(for: id) + evictionInFlight.remove(id) + + // Re-validate every guard across the suspension. + guard webViews[id] != nil, + id != activeServiceID, + !pinnedIDs.contains(id), + !neverHibernateIDs.contains(id) + else { return false } + + if hasCall { + AppLogger.webView.info("Skipping hibernation of \(id) — active call detected") + return false + } + + hibernate(id) + return true + } + /// When exceeding maxLoaded web views, fully hibernate the least recently used ones. - /// Skips services that have an active WebRTC call. - /// Uses `evictionInFlight` to prevent race conditions between the async JS check - /// and the synchronous hibernation — a service won't be hibernated while its call - /// state is being queried by another eviction pass. + /// Skips services that have an active WebRTC call, via `hibernateIfStillIdle`. private func evictIfNeeded() async { guard webViews.count > maxLoaded else { return } @@ -719,34 +771,11 @@ final class WebViewPool { for (id, _) in sorted { // Re-check the live count each pass, not a count captured up front: - // a concurrent evictIfNeeded (they interleave at the `await` below) - // may have already hibernated views, and a stale target would evict - // past the cap, dropping the pool below maxLoaded. + // a concurrent pass (they interleave at the await inside + // hibernateIfStillIdle) may have already hibernated views, and a + // stale target would evict past the cap, dropping below maxLoaded. guard webViews.count > maxLoaded else { break } - guard webViews[id] != nil else { continue } - - evictionInFlight.insert(id) - let hasCall = await hasActiveCall(for: id) - evictionInFlight.remove(id) - - // Re-check the web view still exists (may have been removed during await) - guard webViews[id] != nil else { continue } - - // The await above is a suspension point: the user may have switched - // to this service (making it active), or it may have been pinned / - // marked never-hibernate in the meantime. Re-validate the eviction - // guards so we never hibernate the service the user is now viewing. - guard id != activeServiceID, - !pinnedIDs.contains(id), - !neverHibernateIDs.contains(id) - else { continue } - - if hasCall { - AppLogger.webView.info("Skipping eviction of \(id) — active call detected") - continue - } - - hibernate(id) + await hibernateIfStillIdle(id) } } } diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index a5592cd..2b2722b 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -750,6 +750,36 @@ final class ChorusTests: XCTestCase { .googleFaviconFallbackEnabledEffective) } + func testAutoHibernateDefaultsToOffAndTenMinutes() { + // Off on a legacy row — an upgrade must not start hibernating services + // without the user opting in. + XCTAssertFalse(AppPreferences().autoHibernateIdleEnabledEffective) + XCTAssertTrue(AppPreferences(autoHibernateIdleEnabled: true) + .autoHibernateIdleEnabledEffective) + XCTAssertEqual(AppPreferences().autoHibernateIdleMinutesEffective, 10) + } + + func testAutoHibernateMinutesClampToSaneRange() { + // A stored value outside 1...120 is clamped rather than trusted, so a + // corrupt or hostile row can't set a zero/negative sweep interval. + XCTAssertEqual(AppPreferences(autoHibernateIdleMinutes: 0).autoHibernateIdleMinutesEffective, 1) + XCTAssertEqual(AppPreferences(autoHibernateIdleMinutes: -5).autoHibernateIdleMinutesEffective, 1) + XCTAssertEqual(AppPreferences(autoHibernateIdleMinutes: 5).autoHibernateIdleMinutesEffective, 5) + XCTAssertEqual(AppPreferences(autoHibernateIdleMinutes: 9999).autoHibernateIdleMinutesEffective, 120) + } + + func testMessagingServicesAreNotificationCriticalInCatalog() { + // The auto-hibernation exemption keys off the catalog category, so guard + // that the messaging apps the user relies on carry it and a heavy + // non-chat service does not. + let catalog = ServiceCatalog.shared + for id in ["slack", "teams", "whatsapp", "discord"] { + XCTAssertEqual(catalog.entry(for: id)?.category, "Messaging", + "\(id) must stay in the Messaging category") + } + XCTAssertNotEqual(catalog.entry(for: "spotify")?.category, "Messaging") + } + // MARK: - Scheduled DND (quiet hours) @MainActor