From 002ac1955ea2672d69f54bb32530c1d0f0e697f0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 16 Jun 2026 14:54:44 -0400 Subject: [PATCH] fix: stop time-limit rules re-blocking unused apps after midnight A time-limit rule that legitimately spent its budget one day could re-shield its apps the next morning with zero usage that day, often right after an unrelated schedule rule's window opened. Root cause: Screen Time batches DeviceActivity threshold callbacks and fires them late, when it next wakes the monitor extension (e.g. as a schedule window opens). A leftover `minutes-k` checkpoint from yesterday's spent budget was delivered after midnight and recorded by handleUsageMinutes as *today's* usage, tripping the limit and shielding apps the user never opened. - handleUsageMinutes now drops any checkpoint whose minute count exceeds the minutes elapsed since local midnight, since that usage cannot belong to today (it is a stale cross-midnight delivery). - RuleScheduler's restart fingerprint hashed the app selection with Data.hashValue, which is seeded randomly per process, so every limit activity restarted on each launch and reset its threshold accounting (and re-armed the per-minute event chain, widening the stale-callback window). Switched to a deterministic SHA-256 fingerprint. On-device verification of the background cross-midnight path is still pending (the simulator delivers no DeviceActivity callbacks); covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/AGENT_RULES_FEATURE_SPEC.md | 13 +++++-- OpenAppLock/Services/RuleScheduler.swift | 13 ++++++- OpenAppLockTests/SchedulingTests.swift | 46 ++++++++++++++++++++++++ Shared/LimitEnforcement.swift | 11 ++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md index 0eed12b..703c0fc 100644 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ b/Docs/AGENT_RULES_FEATURE_SPEC.md @@ -459,12 +459,19 @@ Xh", "Xh left") is **derived**, not stored. shorter than DeviceActivity's 15-minute minimum interval, may fail to register (`intervalTooShort`) and falls back to the foreground loop. Activities restart only when their configuration changes, because a - restart resets threshold accounting. + restart resets threshold accounting. The change-detection fingerprint must + be **process-stable** (the app selection is hashed with SHA-256, never + `Data.hashValue`, which is seeded randomly per launch and would otherwise + restart — and reset — every limit activity on each launch). - **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start = midnight reset for limit rules (open-limit rules re-shield so opens can be counted; time-limit shields clear for the fresh budget); each - `minutes-` event records usage and shields at the budget; a finished - `open-session-` one-shot re-shields after a granted open. For + `minutes-` event records usage and shields at the budget — **but a + checkpoint whose minute count exceeds the minutes elapsed since local + midnight is dropped**, since it cannot be today's usage (it is yesterday's + spent budget delivered late across midnight, which would otherwise re-block + unused apps); a finished `open-session-` one-shot re-shields after a + granted open. For schedule-window activities (`sched-`/`sched2-`), **both** interval start and interval end **recompute** the rule's live schedule state from its snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index 7822edb..9c76d94 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -3,6 +3,7 @@ // OpenAppLock // +import CryptoKit import DeviceActivity import FamilyControls import Foundation @@ -69,7 +70,7 @@ final class RuleScheduler { ? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes) : [:] let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|" - + "\(selectionData.hashValue)" + + Self.selectionFingerprint(selectionData) guard needsRestart(name, fingerprint, in: fingerprints) else { continue } start(name: name) { try monitor.startDailyMonitoring( @@ -116,6 +117,16 @@ final class RuleScheduler { fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name) } + /// Process-stable fingerprint of an app selection. `Data.hashValue` is + /// seeded randomly per process, so feeding it into the monitoring + /// fingerprint changed the fingerprint on every launch — restarting each + /// limit activity and resetting its threshold accounting. SHA-256 is + /// deterministic across launches, so an unchanged selection keeps the same + /// fingerprint and the activity is left running. + static func selectionFingerprint(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + /// Runs a best-effort `startMonitoring` call. Monitoring throws on the /// simulator, when authorization is missing, and when the activity cap or /// minimum interval is exceeded; the next sync retries. diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 761605b..c2130f2 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -362,6 +362,18 @@ struct RuleSchedulerTests { scheduler.sync(rules: [rule]) #expect(monitor.startCallCount == 2) } + + @Test("Selection fingerprint is a deterministic SHA-256, stable across launches") + func selectionFingerprintIsProcessStable() { + // `Data.hashValue` is seeded randomly per process, so using it in the + // limit-activity fingerprint restarted every daily activity on each + // launch (resetting threshold accounting). SHA-256 is fixed, so the + // fingerprint can be asserted against a constant — a value that random + // per-process hashing could never satisfy. + #expect( + RuleScheduler.selectionFingerprint(Data([1])) + == "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a") + } } @MainActor @@ -431,6 +443,40 @@ struct LimitEnforcementTests { #expect(shields.shieldedRuleIDs == [snap.id]) } + @Test("A stale checkpoint exceeding today's elapsed minutes is ignored") + func staleCrossMidnightCheckpointIgnored() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + + // 00:30 — only 30 minutes have elapsed since midnight, so a 45-minute + // cumulative checkpoint can only be yesterday's spent budget delivered + // late across midnight. It must not be recorded as today's usage, and + // must not re-shield apps the user hasn't touched today. + let earlyMorning = date(2025, 1, 6, 0, 30) + enforcement.handleUsageMinutes(45, ruleID: snap.id, now: earlyMorning, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: earlyMorning, calendar: utc).minutesUsed == 0) + #expect(shields.shieldedRuleIDs.isEmpty) + } + + @Test("A checkpoint within today's elapsed time still records and shields") + func freshCheckpointWithinElapsedHonoured() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + + // 00:45 — 45 minutes have elapsed, so a 45-minute checkpoint is + // physically possible today and must be honoured (boundary case). + let quarterToOne = date(2025, 1, 6, 0, 45) + enforcement.handleUsageMinutes(45, ruleID: snap.id, now: quarterToOne, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: quarterToOne, calendar: utc).minutesUsed == 45) + #expect(shields.shieldedRuleIDs == [snap.id]) + } + @Test("An Open press spends one open and lifts the shield") func openRequestSpendsAndLifts() { let (enforcement, shields, ledger, store) = makeEnforcement() diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index dc8fb3f..7bd8922 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -47,6 +47,17 @@ struct LimitEnforcement { func handleUsageMinutes( _ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current ) { + // A `minutes-k` checkpoint reports k minutes of *today's* usage, which + // cannot have accrued before k minutes have elapsed since local + // midnight. A larger value means the callback is stale — typically + // yesterday's spent budget delivered late across midnight, since Screen + // Time batches threshold events and fires them when it next wakes the + // monitor (e.g. as another rule's window opens). Recording it would + // re-block apps the user never opened today, so drop it. + let minutesSinceMidnight = Int( + now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) + guard minutes <= minutesSinceMidnight else { return } + ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, snapshot.kind == .timeLimit,