fix: enforce open-limit rules proactively in the foreground
RuleEnforcer.refresh only shielded a limit rule once its budget was spent, so it tore down the background's proactive open-limit "turnstile" shield (the shield is how opens are counted) whenever the app was foregrounded, and a freshly created open-limit rule did nothing until the next midnight reset. refresh now applies the same proactive gate as LimitEnforcement.handleDayStart: an enabled, scheduled-today, un-paused open-limit rule is shielded even before its budget is spent, so it takes effect immediately. A new app-group OpenSessionStore records a granted "Open" session's expiry so neither enforcement path re-shields (and cuts short) a sanctioned ~15-minute session. Overlapping rules already enforce strictly: each rule shields its own ManagedSettingsStore and Screen Time unions them, so an app is blocked if any covering rule blocks it. Document that model in the spec (§4.8) and lock it in with tests, including a time limit blocking during an open-limit's granted session and a daily opens reset. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ struct LimitEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let ledger: UsageLedger
|
||||
let shields: ShieldApplying
|
||||
/// Granted-open session bookkeeping shared with the foreground enforcer.
|
||||
var sessions = OpenSessionStore()
|
||||
|
||||
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
||||
/// proactively shielded on enabled days so the shield can count opens;
|
||||
@@ -62,6 +64,7 @@ struct LimitEnforcement {
|
||||
func handleOpenSessionEnded(
|
||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) {
|
||||
sessions.endSession(for: ruleID)
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .openLimit,
|
||||
!snapshot.isPaused(at: now),
|
||||
@@ -84,6 +87,13 @@ struct LimitEnforcement {
|
||||
guard !snapshot.limitReached(given: usage) else { return false }
|
||||
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
// Mark the session so neither enforcement path re-shields the app until
|
||||
// it ends (+1 minute matches the one-shot activity's padding).
|
||||
if let expiry = calendar.date(
|
||||
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
|
||||
{
|
||||
sessions.startSession(for: ruleID, until: expiry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
59
Shared/OpenSessionStore.swift
Normal file
59
Shared/OpenSessionStore.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// OpenSessionStore.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Read access to in-progress granted "Open" sessions, keyed by rule.
|
||||
protocol OpenSessionReading: AnyObject {
|
||||
/// Whether a granted open for `ruleID` is still running at `now`.
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool
|
||||
}
|
||||
|
||||
/// Records when a granted "Open" expires, per rule, in the shared app-group
|
||||
/// defaults. Pressing the shield's "Open" button lifts an open-limit rule's
|
||||
/// shield for ~15 minutes (`MonitoringPlan.openSessionMinutes`); this marker
|
||||
/// lets the foreground enforcer leave that one rule un-shielded for the life of
|
||||
/// the session instead of re-locking the app mid-session. The monitor clears it
|
||||
/// when the session's one-shot activity ends.
|
||||
final class OpenSessionStore: OpenSessionReading {
|
||||
private static let key = "openSessionExpiry"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date = .now) -> Bool {
|
||||
guard let expiry = expiries[ruleID.uuidString] else { return false }
|
||||
return Date(timeIntervalSince1970: expiry) > now
|
||||
}
|
||||
|
||||
/// Marks a granted open for `ruleID` running until `expiry`.
|
||||
func startSession(for ruleID: UUID, until expiry: Date) {
|
||||
var map = expiries
|
||||
map[ruleID.uuidString] = expiry.timeIntervalSince1970
|
||||
defaults.set(map, forKey: Self.key)
|
||||
}
|
||||
|
||||
/// Ends a granted open (its one-shot activity fired, or it is being reset).
|
||||
func endSession(for ruleID: UUID) {
|
||||
var map = expiries
|
||||
map[ruleID.uuidString] = nil
|
||||
defaults.set(map, forKey: Self.key)
|
||||
}
|
||||
|
||||
private var expiries: [String: TimeInterval] {
|
||||
defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory granted sessions for tests and UI-test launches.
|
||||
final class MockOpenSessionStore: OpenSessionReading {
|
||||
var activeRuleIDs: Set<UUID> = []
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool {
|
||||
activeRuleIDs.contains(ruleID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user