Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions Chorus/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ final class AppState {
didSet { if isLaunchComplete { refreshEffectiveDoNotDisturb() } }
}
@ObservationIgnored nonisolated(unsafe) private var quietHoursTask: Task<Void, Never>?
@ObservationIgnored nonisolated(unsafe) private var idleHibernationTask: Task<Void, Never>?

/// App lock (Touch ID / password), loaded from AppPreferences. `isLocked`
/// drives an opaque cover over the window content in ContentView.
Expand All @@ -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
Expand Down Expand Up @@ -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<String> = ["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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
24 changes: 23 additions & 1 deletion Chorus/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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))
}
}
30 changes: 30 additions & 0 deletions Chorus/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
91 changes: 60 additions & 31 deletions Chorus/Views/WebView/WebViewPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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)
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions ChorusTests/ChorusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down