Skip to content
Open
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
53 changes: 53 additions & 0 deletions Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 45 additions & 4 deletions Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
Expand Down
154 changes: 154 additions & 0 deletions Dayflow/DayflowTests/IdleCaptureIntervalTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading