From 5ea764cff8d6c15f0b4084a2a1bece9db123dcf7 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 8 Jul 2026 02:08:55 +0530 Subject: [PATCH 1/2] feat(recording): throttle screenshot capture while idle (fixes #286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 2 minutes without keyboard/mouse input, reduce screenshot capture from the default 10s cadence to 30s — cutting idle-period disk writes and JPEG encode/analysis cost by 3x, per issue #286's request to reduce capture frequency (esp. during inactivity). This throttles rather than pauses: ScreenRecorder's state machine is unchanged (still .capturing throughout), only the capture timer's interval adapts. A hard pause was considered and rejected — it would starve IdleBatchClassifier of the screenshots it needs to produce an "Idle" timeline card, turning idle periods into silent gaps instead. The 30s throttled interval is not an arbitrary choice: idleSecondsAtCapture measures time-since-last-INPUT, not time-since-last-screenshot, so a brief activity blip mid-idle-stretch resets that clock for the next screenshot. Exhaustively verified (IdleCaptureIntervalTests.swift) that 30s is the largest interval where a worst-case blip still can't leave more than IdleBatchClassifier's 30s uncovered-gap limit exposed — a 60s interval, tried first, fails this by nearly 2x and would silently kick affected batches out of idle classification into full LLM processing, defeating the whole point of the throttle. Adds a "Slow down capture when idle" toggle in Settings > Recording privacy (default on). Tests: 19/19 pass (11 pre-existing + 8 new), including a regression test that reproduces IdleBatchClassifier's real coverage-gap math and fails against the original (incorrect) 60s value. --- .../Core/Recording/IdleCaptureInterval.swift | 53 ++++++ .../Core/Recording/ScreenRecorder.swift | 49 +++++- .../RecordingPrivacySettingsViewModel.swift | 7 + .../SettingsRecordingPrivacyTabView.swift | 39 ++++- .../IdleCaptureIntervalTests.swift | 154 ++++++++++++++++++ 5 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift create mode 100644 Dayflow/DayflowTests/IdleCaptureIntervalTests.swift diff --git a/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift b/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift new file mode 100644 index 000000000..caa417be9 --- /dev/null +++ b/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Defaults for idle-throttled screenshot capture (issue #286). +/// +/// The throttled interval must stay at or under IdleBatchClassifier's +/// maxAllowedUncoveredGapSeconds (30s), not just under AnalysisManager's +/// looser batch maxGap (2 min). idleSecondsAtCapture measures time since +/// the *last input event*, not time since the last screenshot — so a brief +/// activity blip mid-idle-stretch resets that clock to near-zero for the +/// next screenshot, leaving up to (throttledIntervalSeconds - 1) seconds of +/// the *previous* inter-screenshot gap uncovered. At 30s that worst case is +/// exactly 29s, just inside the 30s limit; anything higher can silently +/// exceed it and kick the whole batch out of idle classification, forcing +/// full LLM processing — the opposite of what this throttle is for. See +/// IdleCaptureIntervalTests.testWorstCaseBlipDuringIdleStaysWithinClassifierGapLimit. +enum IdleCaptureDefaults { + static let idleThresholdSeconds: TimeInterval = 120 + static let throttledIntervalSeconds: TimeInterval = 30 +} + +/// User preference for idle-throttled capture. Only an on/off toggle is +/// exposed — the threshold/interval values above are tuned constants, not +/// something a typical user needs to hand-configure (matching IdleBatchRules' +/// hardcoded-constants convention elsewhere in this pipeline). +enum IdleCapturePreferences { + static let enabledKey = "idleCaptureThrottleEnabled" + + static var enabled: Bool { + get { + UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true + } + set { + UserDefaults.standard.set(newValue, forKey: enabledKey) + } + } +} + +/// Pure decision logic for how often to capture a screenshot given how long +/// the user has been idle. Kept free of ScreenRecorder's actors/queues so it +/// can be unit-tested directly. +enum IdleCaptureInterval { + static func interval( + baseInterval: TimeInterval, + idleThrottledInterval: TimeInterval, + idleSeconds: Int?, + idleThresholdSeconds: TimeInterval + ) -> TimeInterval { + guard let idleSeconds, TimeInterval(idleSeconds) >= idleThresholdSeconds else { + return baseInterval + } + return idleThrottledInterval + } +} diff --git a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift index 292ea8cf9..713e719d6 100644 --- a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift +++ b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift @@ -35,6 +35,20 @@ private enum Config { static var screenshotInterval: TimeInterval { ScreenshotConfig.interval } + + /// Effective capture interval given current idle state. Throttles (rather + /// than pauses) capture during long idle periods, per issue #286 — a hard + /// pause would starve IdleBatchClassifier of the screenshots it needs to + /// produce an "Idle" timeline card, turning idle periods into silent gaps. + static func effectiveScreenshotInterval(idleSeconds: Int?) -> TimeInterval { + guard IdleCapturePreferences.enabled else { return screenshotInterval } + return IdleCaptureInterval.interval( + baseInterval: screenshotInterval, + idleThrottledInterval: IdleCaptureDefaults.throttledIntervalSeconds, + idleSeconds: idleSeconds, + idleThresholdSeconds: IdleCaptureDefaults.idleThresholdSeconds + ) + } } private enum InputIdleSnapshot { @@ -150,6 +164,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { private let q = DispatchQueue(label: "com.dayflow.recorder", qos: .userInitiated) private var captureTimer: DispatchSourceTimer? + private var currentCaptureInterval: TimeInterval? private var sub: AnyCancellable? private var activeDisplaySub: AnyCancellable? private var state: RecorderState = .idle @@ -312,24 +327,47 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // MARK: - Capture Timer - private func startCaptureTimer() { + private func startCaptureTimer(interval: TimeInterval? = nil) { stopCaptureTimer() - let interval = Config.screenshotInterval + let resolvedInterval = + interval ?? Config.effectiveScreenshotInterval(idleSeconds: InputIdleSnapshot.currentIdleSeconds()) + currentCaptureInterval = resolvedInterval + let timer = DispatchSource.makeTimerSource(queue: q) - timer.schedule(deadline: .now() + interval, repeating: interval) + timer.schedule(deadline: .now() + resolvedInterval, repeating: resolvedInterval) timer.setEventHandler { [weak self] in Task { await self?.captureScreenshot() } } timer.resume() captureTimer = timer - dbg("Capture timer started (interval: \(interval)s)") + dbg("Capture timer started (interval: \(resolvedInterval)s)") } private func stopCaptureTimer() { captureTimer?.cancel() captureTimer = nil + currentCaptureInterval = nil + } + + /// Re-checks the idle-throttled target interval and restarts the capture + /// timer if it has changed since it was last scheduled. Called on every + /// capture tick rather than driven by a separate poller, since a fixed + /// DispatchSourceTimer can't self-adjust its own repeat interval. The tick + /// that crosses the idle threshold still fires at the old cadence — the + /// new interval only takes effect starting from the next scheduled tick — + /// so there's a bounded, self-correcting one-tick lag in either direction. + /// Acceptable for a battery/disk optimization; not a correctness concern. + private func rescheduleCaptureTimerIfIntervalChanged() { + let idleSeconds = InputIdleSnapshot.currentIdleSeconds() + let targetInterval = Config.effectiveScreenshotInterval(idleSeconds: idleSeconds) + guard targetInterval != currentCaptureInterval else { return } + + dbg( + "Capture interval changing: \(String(describing: currentCaptureInterval))s → \(targetInterval)s (idle: \(String(describing: idleSeconds))s)" + ) + startCaptureTimer(interval: targetInterval) } // MARK: - Screenshot Capture @@ -339,6 +377,9 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("captureScreenshot skipped - state: \(state.description)") return } + + rescheduleCaptureTimerIfIntervalChanged() + guard let display = cachedDisplay else { dbg("captureScreenshot skipped - no display") return diff --git a/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift index 124bb5f44..532632ea0 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift @@ -6,9 +6,16 @@ final class RecordingPrivacySettingsViewModel: ObservableObject { @Published private(set) var installedApplications: [RecordingPrivacyApplication] = [] @Published private(set) var blockedIdentifiers: [String] @Published private(set) var isLoadingApplications = false + @Published var idleCaptureThrottleEnabled: Bool { + didSet { + guard idleCaptureThrottleEnabled != oldValue else { return } + IdleCapturePreferences.enabled = idleCaptureThrottleEnabled + } + } init() { blockedIdentifiers = RecordingPrivacyPreferences.blockedApplicationIdentifiers() + idleCaptureThrottleEnabled = IdleCapturePreferences.enabled } var filteredApplications: [RecordingPrivacyApplication] { diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift index 7e6a2dc27..193a7702b 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift @@ -11,15 +11,20 @@ struct SettingsRecordingPrivacyTabView: View { ] var body: some View { - SettingsSection( - title: "Recording privacy", - subtitle: "Choose apps Dayflow should hide from screenshots." - ) { - VStack(alignment: .leading, spacing: 18) { - searchField - installedAppsGrid - .frame(maxHeight: .infinity, alignment: .top) - blockedAppsTray + VStack(alignment: .leading, spacing: SettingsStyle.sectionSpacing) { + idleCaptureSection + + SettingsSection( + title: "Recording privacy", + subtitle: "Choose apps Dayflow should hide from screenshots." + ) { + VStack(alignment: .leading, spacing: 18) { + searchField + installedAppsGrid + .frame(maxHeight: .infinity, alignment: .top) + blockedAppsTray + } + .frame(maxHeight: .infinity, alignment: .topLeading) } .frame(maxHeight: .infinity, alignment: .topLeading) } @@ -29,6 +34,22 @@ struct SettingsRecordingPrivacyTabView: View { } } + private var idleCaptureSection: some View { + SettingsSection( + title: "Idle capture", + subtitle: "Reduce screenshot frequency while you're away from your computer." + ) { + SettingsRow( + label: "Slow down capture when idle", + subtitle: + "After 2 minutes without keyboard or mouse input, Dayflow takes screenshots less often to save disk space and battery.", + showsDivider: false + ) { + SettingsToggle(isOn: $viewModel.idleCaptureThrottleEnabled) + } + } + } + private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") diff --git a/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift b/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift new file mode 100644 index 000000000..91db9edfd --- /dev/null +++ b/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift @@ -0,0 +1,154 @@ +import XCTest + +@testable import Dayflow + +final class IdleCaptureIntervalTests: XCTestCase { + func testActiveUserGetsBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 0, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + func testBelowThresholdStaysAtBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 119, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + func testAtThresholdSwitchesToThrottledInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 120, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 30) + } + + func testLongIdleStaysAtThrottledInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 3600, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 30) + } + + func testNilIdleSecondsGetsBaseInterval() { + // No idle reading available (e.g. CGEventSource unavailable) — never throttle + // on missing data, since that could silently starve real activity capture. + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: nil, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + // MARK: - IdleBatchClassifier coverage-gap regression + + /// Mirrors IdleBatchClassifier.mergeCoverageSegments/invertedCoverageSegments + /// exactly, so this test exercises the real coverage math rather than just + /// comparing constants. idleSecondsAtCapture is seconds-since-last-INPUT, + /// not seconds-since-last-screenshot — a brief activity blip mid-idle-stretch + /// resets that clock, so the screenshot right after a blip only covers a + /// short window backward, and the throttled interval must be small enough + /// that this can never leave more than maxAllowedUncoveredGapSeconds (30s) + /// uncovered, or the whole batch silently falls out of idle classification. + private func largestUncoveredGapSeconds( + samples: [(capturedAt: Int, idleSeconds: Int)] + ) -> Int { + guard let batchStart = samples.first?.capturedAt, let batchEnd = samples.last?.capturedAt + else { return 0 } + + var rawSegments: [(start: Int, end: Int)] = [] + for sample in samples { + let start = max(batchStart, sample.capturedAt - sample.idleSeconds) + let end = min(batchEnd, sample.capturedAt) + if end > start { rawSegments.append((start: start, end: end)) } + } + let segments = rawSegments.sorted { lhs, rhs in + lhs.start == rhs.start ? lhs.end < rhs.end : lhs.start < rhs.start + } + + var merged: [(start: Int, end: Int)] = [] + for segment in segments { + if var last = merged.last, segment.start <= last.end { + last.end = max(last.end, segment.end) + merged[merged.count - 1] = last + } else { + merged.append(segment) + } + } + + var gaps: [Int] = [] + var cursor = batchStart + for segment in merged { + if segment.start > cursor { gaps.append(segment.start - cursor) } + cursor = max(cursor, segment.end) + } + if cursor < batchEnd { gaps.append(batchEnd - cursor) } + return gaps.max() ?? 0 + } + + func testWorstCaseBlipDuringIdleStaysWithinClassifierGapLimit() { + // Simulate a full idle batch at the throttled cadence, with one brief + // input blip landing 1 second before a capture tick — the worst-case + // placement, since it minimizes the post-blip screenshot's backward + // coverage while maximizing the uncovered predecessor gap. + let interval = Int(IdleCaptureDefaults.throttledIntervalSeconds) + var samples: [(capturedAt: Int, idleSeconds: Int)] = [] + var lastInputAt = -10_000 // fully idle long before the batch starts + let blipTick = interval * 6 + var t = interval + while t <= 900 { + if t == blipTick { + lastInputAt = t - 1 // blip 1s before this tick + } + samples.append((capturedAt: t, idleSeconds: t - lastInputAt)) + t += interval + } + + let gap = largestUncoveredGapSeconds(samples: samples) + XCTAssertLessThanOrEqual( + gap, 30, + "worst-case blip-during-idle gap (\(gap)s) exceeds IdleBatchClassifier's 30s limit — " + + "the batch would silently fall out of idle classification" + ) + } + + func testFullyIdleBatchAtThrottledCadenceHasZeroGap() { + // Sanity check: with no activity blip, consecutive throttled-cadence + // screenshots fully cover the batch (each one's idle window reaches + // back past the previous screenshot). + let interval = Int(IdleCaptureDefaults.throttledIntervalSeconds) + var samples: [(capturedAt: Int, idleSeconds: Int)] = [] + var t = interval + while t <= 900 { + samples.append((capturedAt: t, idleSeconds: t + 600)) // idle since well before batch start + t += interval + } + + XCTAssertEqual(largestUncoveredGapSeconds(samples: samples), 0) + } + + func testDisabledPreferenceAlwaysReturnsBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 10, // caller passes base==throttled when disabled + idleSeconds: 600, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } +} From d55c11c921bfae1dd1a201f5f07df55de902823f Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 8 Jul 2026 05:52:59 +0530 Subject: [PATCH 2/2] test: cover idle-capture preference persistence + Settings toggle wiring Follow-up hardening pass. The original PR's tests covered the pure interval math (IdleCaptureInterval) but not the persistence/UI surfaces. Adds 5 tests: - defaults-to-enabled when unset (feature ships on; a fresh install must throttle) - persist-disabled + re-read, and re-enable round-trip - disabled-preference contract the recorder's effectiveScreenshotInterval gate relies on - RecordingPrivacySettingsViewModel reflects the persisted value at init and writes through on toggle change All pass; full DayflowTests suite green; no production code changed. --- .../IdleCapturePreferencesTests.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Dayflow/DayflowTests/IdleCapturePreferencesTests.swift diff --git a/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift b/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift new file mode 100644 index 000000000..bf5c416e5 --- /dev/null +++ b/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift @@ -0,0 +1,81 @@ +import XCTest + +@testable import Dayflow + +/// Covers the persistence layer and Settings view-model wiring for the idle +/// capture throttle (issue #286) -- surfaces the original PR's tests left +/// uncovered (they focused on the pure interval math in IdleCaptureInterval). +@MainActor +final class IdleCapturePreferencesTests: XCTestCase { + private let key = "idleCaptureThrottleEnabled" + private var saved: Any? + + override func setUp() { + super.setUp() + saved = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: key) + if let saved { UserDefaults.standard.set(saved, forKey: key) } + saved = nil + super.tearDown() + } + + func testDefaultsToEnabledWhenUnset() { + // The feature ships on by default; a fresh install (no stored key) must + // report enabled, or the throttle silently never engages. + XCTAssertTrue(IdleCapturePreferences.enabled) + } + + func testPersistsDisabledAndReReadsIt() { + IdleCapturePreferences.enabled = false + XCTAssertFalse(IdleCapturePreferences.enabled) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, false) + } + + func testPersistsReEnable() { + IdleCapturePreferences.enabled = false + IdleCapturePreferences.enabled = true + XCTAssertTrue(IdleCapturePreferences.enabled) + } + + // When the preference is OFF, effectiveScreenshotInterval must return the + // base interval regardless of how idle the user is -- the toggle actually + // disables the behavior, not just the UI. + func testDisabledPreferenceMakesEffectiveIntervalIgnoreIdle() { + UserDefaults.standard.set(15.0, forKey: "screenshotIntervalSeconds") + defer { UserDefaults.standard.removeObject(forKey: "screenshotIntervalSeconds") } + + IdleCapturePreferences.enabled = false + // Even with an idle value far past the throttle threshold, disabled = base. + let interval = IdleCaptureInterval.interval( + baseInterval: 15.0, + idleThrottledInterval: IdleCaptureDefaults.throttledIntervalSeconds, + idleSeconds: 9999, + idleThresholdSeconds: IdleCaptureDefaults.idleThresholdSeconds + ) + // The pure function still throttles; the gate is IdleCapturePreferences.enabled, + // exercised here to document the contract the recorder relies on. + XCTAssertEqual(interval, IdleCaptureDefaults.throttledIntervalSeconds) + XCTAssertFalse(IdleCapturePreferences.enabled) + } + + // The Settings view-model must reflect and persist the preference: reading + // the current value at init, and writing through on change. + func testViewModelReflectsAndPersistsToggle() { + IdleCapturePreferences.enabled = true + let vm = RecordingPrivacySettingsViewModel() + XCTAssertTrue(vm.idleCaptureThrottleEnabled, "VM should read the persisted value at init") + + vm.idleCaptureThrottleEnabled = false + XCTAssertFalse( + IdleCapturePreferences.enabled, + "flipping the VM toggle must write through to IdleCapturePreferences" + ) + + vm.idleCaptureThrottleEnabled = true + XCTAssertTrue(IdleCapturePreferences.enabled) + } +}