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
56 changes: 44 additions & 12 deletions Cotabby/Services/Focus/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
}
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
70 changes: 61 additions & 9 deletions Cotabby/Services/Permission/PermissionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// 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.
Expand All @@ -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.
Expand Down
29 changes: 13 additions & 16 deletions Cotabby/Support/FocusPollBackoff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -34,24 +34,21 @@ 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)
}

/// An explicit refresh (real activity, e.g. a keystroke) returns the loop to full cadence.
mutating func reset() {
idleCaptureCount = 0
ticksSinceCapture = 0
}
}
54 changes: 26 additions & 28 deletions CotabbyTests/FocusPollBackoffTests.swift
Original file line number Diff line number Diff line change
@@ -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 changingso 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..<count where backoff.shouldCaptureOnTick() {
for _ in 0..<count {
backoff.recordCapture(didChange: false)
}
return backoff
Expand Down Expand Up @@ -47,46 +47,44 @@ final class FocusPollBackoffTests: XCTestCase {

// MARK: - State machine

func test_capturesEveryTickWhileChanging() {
func test_instanceStrideMatchesSchedule() {
XCTAssertEqual(FocusPollBackoff().captureStride, 1)
XCTAssertEqual(idledBackoff(captures: 5).captureStride, 3)
XCTAssertEqual(idledBackoff(captures: 12).captureStride, 6)
XCTAssertEqual(idledBackoff(captures: 30).captureStride, 10)
}

func test_capturesWhileChangingStayAtFullCadence() {
var backoff = FocusPollBackoff()
for _ in 0..<10 {
XCTAssertTrue(backoff.shouldCaptureOnTick())
backoff.recordCapture(didChange: true)
}
XCTAssertEqual(backoff.idleCaptureCount, 0)
XCTAssertEqual(backoff.captureStride, 1)
}

func test_sustainedIdleStretchesStrideSoMostTicksSkip() {
var backoff = FocusPollBackoff()
var captures = 0
for _ in 0..<400 where backoff.shouldCaptureOnTick() {
backoff.recordCapture(didChange: false)
captures += 1
}
XCTAssertGreaterThanOrEqual(backoff.idleCaptureCount, 30)
XCTAssertLessThanOrEqual(backoff.idleCaptureCount, FocusPollBackoff.idleCaptureCountCap)
// With the stride ramping to 10, 400 ticks should yield far fewer than 400 captures.
XCTAssertLessThan(captures, 100)
func test_sustainedIdleGrowsStrideAndCaps() {
let backoff = idledBackoff(captures: 400)
XCTAssertEqual(backoff.idleCaptureCount, FocusPollBackoff.idleCaptureCountCap)
XCTAssertEqual(backoff.captureStride, 10)
}

/// The invariant Greptile flagged: a change after a long idle period must snap back to full
/// cadence, not stay permanently backed off. (A dropped reset here would leave stride at 10.)
/// cadence, not stay permanently backed off. (A dropped reset here would leave the stride at 10.)
func test_changeAfterIdleResetsToFullCadence() {
var backoff = idledBackoff(ticks: 400)
XCTAssertGreaterThan(FocusPollBackoff.captureStride(idleCaptureCount: backoff.idleCaptureCount), 1)
var backoff = idledBackoff(captures: 400)
XCTAssertGreaterThan(backoff.captureStride, 1)

while !backoff.shouldCaptureOnTick() {}
backoff.recordCapture(didChange: true)

XCTAssertEqual(backoff.idleCaptureCount, 0)
XCTAssertEqual(FocusPollBackoff.captureStride(idleCaptureCount: backoff.idleCaptureCount), 1)
XCTAssertTrue(backoff.shouldCaptureOnTick(), "the tick after a change should capture immediately")
XCTAssertEqual(backoff.captureStride, 1)
}

func test_resetReturnsToFullCadence() {
var backoff = idledBackoff(ticks: 400)
var backoff = idledBackoff(captures: 400)
backoff.reset()
XCTAssertEqual(backoff.idleCaptureCount, 0)
XCTAssertTrue(backoff.shouldCaptureOnTick(), "the tick after an explicit refresh should capture immediately")
XCTAssertEqual(backoff.captureStride, 1)
}
}