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
13 changes: 10 additions & 3 deletions Docs/AGENT_RULES_FEATURE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<k>` event records usage and shields at the budget; a finished
`open-session-<uuid>` one-shot re-shields after a granted open. For
`minutes-<k>` 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-<uuid>` 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
Expand Down
13 changes: 12 additions & 1 deletion OpenAppLock/Services/RuleScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// OpenAppLock
//

import CryptoKit
import DeviceActivity
import FamilyControls
import Foundation
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions OpenAppLockTests/SchedulingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions Shared/LimitEnforcement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading