diff --git a/Cotabby/Services/Focus/FocusTracker.swift b/Cotabby/Services/Focus/FocusTracker.swift index 4b1d1925..1a36dc22 100644 --- a/Cotabby/Services/Focus/FocusTracker.swift +++ b/Cotabby/Services/Focus/FocusTracker.swift @@ -38,6 +38,9 @@ final class FocusTracker { private let snapshotResolver: FocusSnapshotResolver private var timer: Timer? + /// The interval the running `timer` was created with, so idle-backoff transitions can re-arm it + /// only when the effective interval actually changes (no per-keystroke timer churn while active). + private var scheduledInterval: TimeInterval? private var pollSequence = 0 private var focusChangeSequence: UInt64 = 0 private var lastFocusedInputSignature: FocusedInputPollingSignature? @@ -92,9 +95,35 @@ final class FocusTracker { } CotabbyLogger.focus.info("Focus polling started at \(Int(self.pollInterval * 1000))ms interval") + // Capture once immediately (this also resets idle backoff), then arm the timer at the + // resulting effective interval. refreshNow() + scheduleTimer() + } + + /// Stops polling while leaving the most recent snapshot available to callers. + func stop() { + CotabbyLogger.focus.info("Focus polling stopped") + timer?.invalidate() + timer = nil + scheduledInterval = nil + } + + /// The interval the poll timer should currently run at: the base interval stretched by idle + /// backoff. While the user is active the stride is 1, so this is just `pollInterval`. + private func effectiveInterval() -> TimeInterval { + pollInterval * Double(backoff.captureStride) + } - let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in + /// Creates (or replaces) the poll timer at the current effective interval. Each fire performs + /// exactly one capture, so an idle machine wakes the main thread every `base * stride` instead of + /// every base tick. The capture cadence is identical to the previous tick-skipping design; only + /// the no-op wakeups are removed. + private func scheduleTimer() { + timer?.invalidate() + let interval = effectiveInterval() + scheduledInterval = interval + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.handleTimerTick() } @@ -103,11 +132,13 @@ final class FocusTracker { RunLoop.main.add(timer, forMode: .common) } - /// Stops polling while leaving the most recent snapshot available to callers. - func stop() { - CotabbyLogger.focus.info("Focus polling stopped") - timer?.invalidate() - timer = nil + /// Re-arms the timer only when idle backoff has moved it to a new effective interval. During + /// active use the stride stays at 1, so this is a no-op and avoids per-keystroke timer churn. + private func rescheduleTimerIfIntervalChanged() { + guard timer != nil, effectiveInterval() != scheduledInterval else { + return + } + scheduleTimer() } /// Restarts the polling timer with a new interval. No-op if the interval hasn't changed. @@ -137,18 +168,19 @@ final class FocusTracker { func refreshNow() { backoff.reset() performCaptureAndPublish() + rescheduleTimerIfIntervalChanged() } - /// Timer entry point that applies idle backoff before the expensive Accessibility walk. + /// Timer entry point: capture once, fold the result into idle backoff, then re-arm the timer at + /// the backoff-derived interval. /// /// While captures keep producing changes (typing, focus churn) the stride stays at 1 and the - /// poll runs at full cadence. Once captures stop changing, the stride grows so an idle machine - /// isn't paying for ~12.5 full Chrome AX tree walks per second — the dominant idle cost in #280. + /// timer stays at the base interval. Once captures stop changing, the stride grows and the timer + /// is re-armed to a longer interval, so an idle machine stops waking ~12.5x/second only to skip + /// the walk it would not run anyway. That wasteful wake was the dominant idle cost in #280. private func handleTimerTick() { - guard backoff.shouldCaptureOnTick() else { - return - } backoff.recordCapture(didChange: performCaptureAndPublish()) + rescheduleTimerIfIntervalChanged() } /// Captures the current snapshot, publishes any change, and reports whether anything changed. diff --git a/Cotabby/Services/Permission/PermissionManager.swift b/Cotabby/Services/Permission/PermissionManager.swift index 43a69818..f448ec7b 100644 --- a/Cotabby/Services/Permission/PermissionManager.swift +++ b/Cotabby/Services/Permission/PermissionManager.swift @@ -17,22 +17,42 @@ final class PermissionManager: ObservableObject { @Published private(set) var screenRecordingGranted = false private var pollTimer: Timer? + private var activationObserver: NSObjectProtocol? - /// Polling keeps UI state aligned with system settings changes performed outside the app. + /// Keeps UI state aligned with permission changes the user makes in System Settings. + /// + /// A permission can only change while the user is in System Settings, i.e. while Cotabby is + /// backgrounded; returning to the app fires `didBecomeActive`, and the menu/settings surfaces + /// already call `refresh()` when they appear. So instead of a forever-running 2s poll (a 0.5 Hz + /// main-thread wake for the whole session, long after every grant is already in place), we + /// refresh on activation and keep the short catch-up poll alive ONLY while a required permission + /// is still missing — the onboarding window where snappy feedback matters. Once the required set + /// is granted the timer is torn down, so an established user pays zero idle wakeups here. init() { - refresh() - let pollTimer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in - DispatchQueue.main.async { self?.refresh() } + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + // The observer closure is not formally actor-isolated even though `queue: .main` + // guarantees main-thread delivery. `assumeIsolated` makes the hop explicit for strict + // concurrency checking (and keeps the refresh synchronous with activation, so surfaces + // reading permission state on this same turn see fresh values). Same pattern as + // `SystemMetricsStore`'s main-queue timer. + MainActor.assumeIsolated { + self?.refresh() + } } - // Menu panels and drag sessions can move the main run loop out of its default mode. - // Common modes keep the permission cache from freezing during exactly the flows that - // change permissions. - RunLoop.main.add(pollTimer, forMode: .common) - self.pollTimer = pollTimer + // `refresh()` also (re)configures polling for the current grant state, so a process that + // launches with every permission already granted never arms the timer at all. + refresh() } deinit { pollTimer?.invalidate() + if let activationObserver { + NotificationCenter.default.removeObserver(activationObserver) + } } /// Re-reads the current system permission state and republishes any changes to observers. @@ -57,6 +77,38 @@ final class PermissionManager: ObservableObject { CotabbyLogger.app.info("Screen Recording permission changed: \(latestScreenRecordingGranted)") screenRecordingGranted = latestScreenRecordingGranted } + + updatePollingForCurrentState() + } + + /// Arms the 2-second catch-up poll only while a required permission is still missing, and stops + /// it once the required set is granted. Permission changes after that are rare and deliberate, + /// and every one is followed by the user returning to the app (`didBecomeActive`) or opening a + /// Cotabby surface that already calls `refresh()`, so the standing timer buys nothing but idle + /// main-thread wakeups. + private func updatePollingForCurrentState() { + if requiredPermissionsGranted { + stopPolling() + } else { + startPollingIfNeeded() + } + } + + private func startPollingIfNeeded() { + guard pollTimer == nil else { return } + let pollTimer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in + DispatchQueue.main.async { self?.refresh() } + } + // Menu panels and drag sessions can move the main run loop out of its default mode. Common + // modes keep the permission cache from freezing during exactly the flows that change + // permissions. + RunLoop.main.add(pollTimer, forMode: .common) + self.pollTimer = pollTimer + } + + private func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil } /// Asks macOS to register or prompt for the current process before showing manual guidance. diff --git a/Cotabby/Support/FocusPollBackoff.swift b/Cotabby/Support/FocusPollBackoff.swift index 56b9f03c..5aa9ddc9 100644 --- a/Cotabby/Support/FocusPollBackoff.swift +++ b/Cotabby/Support/FocusPollBackoff.swift @@ -9,18 +9,18 @@ import Foundation struct FocusPollBackoff { /// Consecutive captures that produced no change. Drives the stride. private(set) var idleCaptureCount = 0 - /// Base timer ticks elapsed since the last expensive capture. - private var ticksSinceCapture = 0 /// Cap on `idleCaptureCount` so a long idle period can't overflow; the stride is already maxed /// well before this is reached. static let idleCaptureCountCap = 60 - /// How many base poll ticks to wait between expensive captures, given how many consecutive - /// captures have produced no change. + /// Poll-interval multiplier for the given idle level. The focus timer runs at + /// `baseInterval * captureStride`, so this is how much an idle machine stretches the gap between + /// expensive Accessibility walks. /// - /// The first few idle captures stay at full cadence so a brief pause doesn't make the field feel - /// laggy; sustained idleness ramps toward ~800ms (at the 80ms base) before the next AX walk. + /// The first few idle captures stay at full cadence (stride 1) so a brief pause doesn't make the + /// field feel laggy; sustained idleness ramps toward 10x (e.g. ~500ms at the 50ms base) before + /// the next AX walk. static func captureStride(idleCaptureCount: Int) -> Int { switch idleCaptureCount { case ..<5: @@ -34,17 +34,15 @@ struct FocusPollBackoff { } } - /// Advances one timer tick. Returns `true` when the caller should run the expensive capture now. - mutating func shouldCaptureOnTick() -> Bool { - ticksSinceCapture += 1 - guard ticksSinceCapture >= Self.captureStride(idleCaptureCount: idleCaptureCount) else { - return false - } - ticksSinceCapture = 0 - return true + /// The current poll-interval multiplier. `FocusTracker` multiplies its base interval by this to + /// get the interval the timer actually runs at, so an idle machine wakes the main thread every + /// `base * stride` instead of waking every base tick only to skip the work. + var captureStride: Int { + Self.captureStride(idleCaptureCount: idleCaptureCount) } - /// Records a completed capture: a change returns the loop to full cadence, no change grows the stride. + /// Records a completed capture: a change returns the loop to full cadence, no change grows the + /// stride. mutating func recordCapture(didChange: Bool) { idleCaptureCount = didChange ? 0 : min(idleCaptureCount + 1, Self.idleCaptureCountCap) } @@ -52,6 +50,5 @@ struct FocusPollBackoff { /// An explicit refresh (real activity, e.g. a keystroke) returns the loop to full cadence. mutating func reset() { idleCaptureCount = 0 - ticksSinceCapture = 0 } } diff --git a/CotabbyTests/FocusPollBackoffTests.swift b/CotabbyTests/FocusPollBackoffTests.swift index ffcb3bef..59dfa819 100644 --- a/CotabbyTests/FocusPollBackoffTests.swift +++ b/CotabbyTests/FocusPollBackoffTests.swift @@ -1,15 +1,15 @@ import XCTest @testable import Cotabby -/// Verifies the focus-poll idle backoff (`FocusPollBackoff`). This is the #280 fix: the poll stays -/// responsive right after activity, then stretches the interval between the expensive Accessibility -/// walks once the focused state stops changing — so an idle machine isn't paying for ~12.5 Chrome AX -/// tree walks per second. +/// Verifies the focus-poll idle backoff (`FocusPollBackoff`). This is the #280 fix plus its energy +/// follow-up: the poll stays responsive right after activity, then stretches the interval between the +/// expensive Accessibility walks once the focused state stops changing, so an idle machine isn't +/// waking the main thread ~12.5x/second for a walk it would only skip. final class FocusPollBackoffTests: XCTestCase { - /// Drives `count` timer ticks, recording every capture as "no change", and returns the result. - private func idledBackoff(ticks count: Int) -> FocusPollBackoff { + /// Returns a backoff that has recorded `count` consecutive no-change captures (i.e. gone idle). + private func idledBackoff(captures count: Int) -> FocusPollBackoff { var backoff = FocusPollBackoff() - for _ in 0..