diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index 0255ce8..d9d3def 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -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 { diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index e039522..1e842e8 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -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 = [] + var blocking: Set = [] + var shielded: Set = [] 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) + } } diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index 3b6a4a3..fcab18c 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -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:00–17: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]) + } +} diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 03df5b8..761605b 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -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() diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 0a104c4..a28d2ce 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -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) diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index 92d5484..dc8fb3f 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -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 } diff --git a/Shared/OpenSessionStore.swift b/Shared/OpenSessionStore.swift new file mode 100644 index 0000000..997d483 --- /dev/null +++ b/Shared/OpenSessionStore.swift @@ -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 = [] + + func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool { + activeRuleIDs.contains(ruleID) + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index b3b4bcf..9afa742 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -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-`), 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:00–23: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.