From 46da6cecf9e040d27dcb4e08abbf7978d20ca774 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:32:54 -0700 Subject: [PATCH 1/2] Back off idle timers so the menu-bar app stops waking the main thread when idle Two always-on timers woke the main thread continuously regardless of typing. On Apple Silicon, wake frequency (not per-wake work) dominates idle package power, so this is a real idle-energy floor (refs #661). PermissionManager polled AXIsProcessTrusted + two CGPreflight calls every 2s for the entire session, even after every permission was granted (the steady state for any established user). It now refreshes on NSApplication.didBecomeActive and keeps the 2s catch-up poll only while a required permission is still missing; once the required set is granted the timer is torn down. The menu, settings, and onboarding surfaces already call refresh() on appear, so a later revocation is still caught promptly. FocusTracker ran a fixed 50ms (20Hz) timer and relied on FocusPollBackoff to *skip* the expensive AX walk when idle, but the timer still fired 20x/s and hopped to the main actor only to no-op. The backoff now drives the timer *interval* itself (base * stride), so an idle machine wakes ~2x/s instead of 20x/s. Capture cadence is unchanged (still base * stride between walks); only the wasted in-between wakes are removed, and the timer is re-armed only when the stride actually changes, so active typing (stride 1) adds zero timer churn. FocusPollBackoff drops tick-counting (shouldCaptureOnTick) for a captureStride multiplier; its tests now assert the stride schedule and state machine directly. --- Cotabby/Services/Focus/FocusTracker.swift | 56 +++++++++++++---- .../Permission/PermissionManager.swift | 63 ++++++++++++++++--- Cotabby/Support/FocusPollBackoff.swift | 29 ++++----- CotabbyTests/FocusPollBackoffTests.swift | 54 ++++++++-------- 4 files changed, 137 insertions(+), 65 deletions(-) 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..7ae4d16c 100644 --- a/Cotabby/Services/Permission/PermissionManager.swift +++ b/Cotabby/Services/Permission/PermissionManager.swift @@ -17,22 +17,35 @@ 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 + 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 +70,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.. Date: Wed, 10 Jun 2026 21:17:48 -0700 Subject: [PATCH 2/2] Make the activation observer's main-actor hop explicit via assumeIsolated Greptile review: the addObserver(forName:queue:.main) closure is not formally actor-isolated even though delivery is guaranteed on the main thread, so calling the @MainActor refresh() directly would trip Swift 6 strict concurrency checking. assumeIsolated makes the hop explicit and keeps the refresh synchronous with activation; matches the existing SystemMetricsStore/InputMonitor pattern. --- Cotabby/Services/Permission/PermissionManager.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Cotabby/Services/Permission/PermissionManager.swift b/Cotabby/Services/Permission/PermissionManager.swift index 7ae4d16c..f448ec7b 100644 --- a/Cotabby/Services/Permission/PermissionManager.swift +++ b/Cotabby/Services/Permission/PermissionManager.swift @@ -34,7 +34,14 @@ final class PermissionManager: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.refresh() + // 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() + } } // `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.