Compare commits

...

1 Commits

Author SHA1 Message Date
31830cb141 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.
2026-06-13 13:52:54 -04:00
8 changed files with 356 additions and 17 deletions

View File

@@ -53,8 +53,12 @@ struct OpenAppLockApp: App {
let scheduler =
config.isUITesting
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
let openSessions: OpenSessionReading =
config.isUITesting ? MockOpenSessionStore() : OpenSessionStore()
_enforcer = State(
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler))
initialValue: RuleEnforcer(
shields: shields, usage: usageLedger, scheduler: scheduler,
openSessions: openSessions))
}
var body: some Scene {

View File

@@ -24,14 +24,19 @@ final class RuleEnforcer {
/// Day-usage source consulted for limit rules; also exposed to views for
/// the Usage section.
let usageReader: UsageReading
/// Granted-open sessions, so a proactively-gated open-limit rule is left
/// un-shielded while the user is inside a session they paid an open for.
private let openSessions: OpenSessionReading
init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil
scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore()
) {
self.shields = shields
self.usageReader = usage
self.scheduler = scheduler
self.openSessions = openSessions
}
/// The day's usage for a rule (nil for schedule rules, which don't track).
@@ -44,15 +49,27 @@ final class RuleEnforcer {
/// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses.
///
/// Each rule shields its *own* `ManagedSettingsStore`, and Screen Time
/// unions shields across stores, so overlapping rules enforce strictly:
/// an app is blocked if *any* covering rule blocks it (see spec §4.8). A
/// rule is shielded when it is actively blocking (a schedule window is
/// open, or a limit budget is spent) *or* when it is an open-limit rule
/// that must gate its apps so opens can be counted.
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
var active: Set<UUID> = []
var blocking: Set<UUID> = []
var shielded: Set<UUID> = []
for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
let usage = usage(for: rule, at: now, calendar: calendar)
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
active.insert(rule.id)
let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
if isBlocking { blocking.insert(rule.id) }
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
continue
}
shielded.insert(rule.id)
shields.applyShield(
ruleID: rule.id,
selectionData: rule.appList?.selectionData,
@@ -63,8 +80,24 @@ final class RuleEnforcer {
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
shields.clearShields(except: shielded)
// "Blocked Apps" lists only rules whose budget/window is spent not the
// proactive open-limit gate, which surfaces under "Usage" instead.
blockingRuleIDs = blocking
scheduler?.sync(rules: rules, at: now)
}
/// Whether an open-limit rule should carry its proactive gate right now:
/// enabled, scheduled today, not unblocked, and not inside a granted open
/// session (which would otherwise be cut short). Mirrors
/// `LimitEnforcement.handleDayStart` so the foreground and background agree.
private func shouldGateOpenLimit(
_ rule: BlockingRule, at now: Date, calendar: Calendar
) -> Bool {
rule.kind == .openLimit
&& rule.isEnabled
&& rule.pausedUntil == nil
&& rule.isScheduledToday(at: now, calendar: calendar)
&& !openSessions.hasActiveSession(for: rule.id, at: now)
}
}

View File

@@ -112,6 +112,47 @@ struct RuleEnforcerTests {
#expect(shields.appliedModes[rule.id] == .allowOnly)
}
@Test("Open-limit rules are proactively shielded while opens remain")
func proactivelyShieldsOpenLimit() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
// 2 of 5 opens spent: budget not reached, so the rule is not "active",
// but its apps must still be gated so the next open can be counted.
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id])
#expect(shields.appliedModes[rule.id] == .block)
// A proactive gate (opens remaining) is not "Blocked Apps"-blocking.
#expect(enforcer.blockingRuleIDs.isEmpty)
}
@Test("A granted open session is left un-shielded, not re-locked")
func respectsGrantedOpenSession() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let sessions = MockOpenSessionStore()
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
// The user spent an open and is mid-session; re-shielding would cut the
// sanctioned ~15-minute session short.
sessions.activeRuleIDs = [rule.id]
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("The adult-content flag is forwarded to the shield layer")
func forwardsAdultContentFlag() {
let shields = MockShieldController()
@@ -126,3 +167,105 @@ struct RuleEnforcerTests {
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
}
}
/// Validates the "strictest enforcement wins" model for rules that target the
/// same apps (spec §4.8). Each rule shields its own store and Screen Time
/// unions them, so the unit-level invariant is: every rule that should block
/// applies its own shield, and no rule's shield is suppressed by another.
@MainActor
@Suite("Overlapping rules → strictest enforcement")
struct OverlappingRuleEnforcementTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
}
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
}
@Test("Every rule that should block applies its own shield")
func eachRuleShieldsIndependently() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let schedule = BlockingRule(name: "Work Time") // 09:0017:00, active now
let timeLimit = timeLimitRule()
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // spent blocking
enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc)
// Neither cancels the other: both carry their own shield.
#expect(shields.shieldedRuleIDs == [schedule.id, timeLimit.id])
}
@Test("The first limit to be spent blocks, whatever the other's budget")
func firstSpentLimitBlocks() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let openLimit = openLimitRule(maxOpens: 5)
let timeLimit = timeLimitRule(limit: 45)
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1) // opens remain
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // budget spent
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
// Time-limit blocks (spent) while the open-limit still gates its turnstile.
#expect(shields.shieldedRuleIDs == [openLimit.id, timeLimit.id])
// Only the spent budget counts as "Blocked Apps"; the gate shows in Usage.
#expect(enforcer.blockingRuleIDs == [timeLimit.id])
}
@Test("A time limit blocks even during an open-limit's granted session")
func timeLimitBlocksDuringGrantedOpen() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let sessions = MockOpenSessionStore()
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
let openLimit = openLimitRule(maxOpens: 5)
let timeLimit = timeLimitRule(limit: 45)
sessions.activeRuleIDs = [openLimit.id] // user is mid-open on the shared app
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1)
// The metered minutes during the open push the time limit over budget.
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45)
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
// The open-limit stays lifted (its session is sanctioned), but the time
// limit shields the app anyway strictest wins.
#expect(shields.shieldedRuleIDs == [timeLimit.id])
}
@Test("Spent opens reset the next day: re-gated, not blocked")
func opensResetNextDay() {
let suite = "overlap-tests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let ledger = UsageLedger(defaults: defaults)
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = openLimitRule(maxOpens: 5)
let yesterday = date(2025, 1, 5, 10, 0)
let today = date(2025, 1, 6, 10, 0)
for _ in 0..<5 {
ledger.recordOpen(for: rule.id, onDayContaining: yesterday, calendar: utc)
}
// Yesterday: budget exhausted blocking.
enforcer.refresh(rules: [rule], at: yesterday, calendar: utc)
#expect(enforcer.blockingRuleIDs == [rule.id])
// Today: fresh budget not blocking, but the turnstile is back up.
enforcer.refresh(rules: [rule], at: today, calendar: utc)
#expect(enforcer.blockingRuleIDs.isEmpty)
#expect(shields.shieldedRuleIDs == [rule.id])
}
}

View File

@@ -373,7 +373,12 @@ struct LimitEnforcementTests {
let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(defaults: freshDefaults())
return (LimitEnforcement(snapshots: store, ledger: ledger, shields: shields), shields, ledger, store)
// Isolated session store so granted-open writes don't touch the app group.
return (
LimitEnforcement(
snapshots: store, ledger: ledger, shields: shields,
sessions: OpenSessionStore(defaults: freshDefaults())),
shields, ledger, store)
}
private func snapshot(
@@ -450,6 +455,28 @@ struct LimitEnforcementTests {
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("Granting an open records a session marker; ending it clears it")
func openSessionMarkerLifecycle() {
let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(defaults: freshDefaults())
let sessions = OpenSessionStore(defaults: freshDefaults())
let enforcement = LimitEnforcement(
snapshots: store, ledger: ledger, shields: shields, sessions: sessions)
let snap = snapshot(kind: .openLimit, maxOpens: 3)
store.save([snap])
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
#expect(enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
// The granted ~15-minute session is now recorded and live...
#expect(sessions.hasActiveSession(for: snap.id, at: monday))
// ...but expires after the session length.
#expect(!sessions.hasActiveSession(for: snap.id, at: date(2025, 1, 6, 10, 17)))
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
}
@Test("Session end re-shields the rule for the next open")
func sessionEndReshields() {
let (enforcement, shields, _, store) = makeEnforcement()

View File

@@ -170,17 +170,38 @@ struct UsageEnforcementTests {
#expect(shields.appliedAdultContentFlags[rule.id] == false)
}
@Test("A limit rule with budget left is not shielded")
func leavesUnspentLimitAlone() {
@Test("A time-limit rule with budget left is not shielded")
func leavesUnspentTimeLimitAlone() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 20)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
// Time limits let the OS meter usage on the unshielded app, so nothing
// is shielded until the budget is spent. (Open limits differ the
// shield is the meter, so they are gated proactively; see
// RuleEnforcerTests.proactivelyShieldsOpenLimit.)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("An open-limit rule not scheduled today is not gated")
func leavesOffDayOpenLimitAlone() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
days: Weekday.weekends)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
// mondayMorning is a weekday, so the weekend-only rule does not gate.
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)

View File

@@ -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
}

View 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)
}
}

View File

@@ -288,7 +288,20 @@ Full-height sheet:
work the same with an opens budget; while opens remain, their apps stay
shielded with an "Open" button on the shield (each press spends one open
and lifts the shield for up to 15 minutes — the DeviceActivity minimum
interval).
interval). Because the shield is what *counts* opens, an enabled open-limit
rule scheduled today is shielded **proactively from the start of the day,
even before any opens are spent** — by *both* the background
(`LimitEnforcement.handleDayStart`) and the foreground
(`RuleEnforcer.refresh`) paths, so a freshly created open-limit rule gates
its apps immediately and the gate survives the app being foregrounded. The
one exception is a **granted "Open" session**: pressing Open lifts the
shield for ~15 minutes, recorded as an expiry in the shared
`OpenSessionStore`; while that session is live, neither path re-shields the
rule (so the sanctioned session is never cut short), and the monitor
re-shields when the session's one-shot activity ends. Unlike a *spent*
budget, this proactive gate does **not** put the rule in "Blocked Apps"
(which lists only rules whose budget is exhausted); it shows under "Usage"
with its remaining opens.
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.
@@ -297,6 +310,26 @@ Full-height sheet:
should be deliberate. Editing uses a plain "Done".
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
(minute granularity is fine).
8. **Overlapping rules — strictest enforcement wins.** When several rules
target the same app, the app is blocked if **any** of them is currently
blocking it; rules never cancel each other out. This is structural rather
than a resolved decision: each rule owns its own `ManagedSettingsStore`
(`rule-<uuid>`), Screen Time **unions** shields across all stores, and a
rule only ever writes/clears *its own* store. Consequences:
- An open-limit and a time-limit rule on the same app each block via their
own store, so whichever's budget is spent **first** blocks the app,
regardless of the other's remaining budget.
- An **Allow-Only** schedule cannot punch a hole for an app that another
rule blocks: `.all(except:)` is itself a *shield* directive ("block
everything except these"), not a whitelist that lifts other stores'
shields. So if a schedule "allows" an app but a time limit blocks it, the
time-limit block stands.
- A soft **unblock** pauses only the one rule it was invoked on; other rules
blocking the same app keep it blocked.
There is deliberately **no** central merge of selections into a single
shield set — such a merge would be the only place a block could be
accidentally dropped.
---
@@ -407,10 +440,11 @@ Xh", "Xh left") is **derived**, not stored.
### 5.5 Background enforcement architecture (implemented)
- **App group** `group.dev.bchen.OpenAppLock` shares three stores between the
- **App group** `group.dev.bchen.OpenAppLock` shares four stores between the
app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
per-day minutes/opens), and the shield-store tracking list.
per-day minutes/opens), `OpenSessionStore` (per-rule expiry of a granted
"Open" session), and the shield-store tracking list.
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
enabled rules:
- **Limit rules** — one repeating 00:0023:59 activity per rule
@@ -444,13 +478,21 @@ Xh", "Xh left") is **derived**, not stored.
that app is relaunched (a long-standing Screen Time platform limitation).
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
(launch + 30 s foreground loop) is retained as the reconciliation safety
net and is the source of truth whenever the app runs.
net and is the source of truth whenever the app runs. To keep that net
consistent with the background, `refresh` applies the **same** open-limit
proactive gate as `handleDayStart`: an enabled, scheduled-today, un-paused
open-limit rule is shielded even before its budget is spent, *unless* the
`OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name.
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, and starts the
~15-minute one-shot session (DeviceActivity's minimum interval).
spends one open in the ledger, lifts the rule's shield, records the session
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
(DeviceActivity's minimum interval); the monitor clears that record when the
session ends.
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
tested from the app test target.