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.
This commit is contained in:
@@ -53,8 +53,12 @@ struct OpenAppLockApp: App {
|
|||||||
let scheduler =
|
let scheduler =
|
||||||
config.isUITesting
|
config.isUITesting
|
||||||
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
|
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
|
||||||
|
let openSessions: OpenSessionReading =
|
||||||
|
config.isUITesting ? MockOpenSessionStore() : OpenSessionStore()
|
||||||
_enforcer = State(
|
_enforcer = State(
|
||||||
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler))
|
initialValue: RuleEnforcer(
|
||||||
|
shields: shields, usage: usageLedger, scheduler: scheduler,
|
||||||
|
openSessions: openSessions))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ final class RuleEnforcer {
|
|||||||
/// Day-usage source consulted for limit rules; also exposed to views for
|
/// Day-usage source consulted for limit rules; also exposed to views for
|
||||||
/// the Usage section.
|
/// the Usage section.
|
||||||
let usageReader: UsageReading
|
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(
|
init(
|
||||||
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
|
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
|
||||||
scheduler: RuleScheduler? = nil
|
scheduler: RuleScheduler? = nil,
|
||||||
|
openSessions: OpenSessionReading = OpenSessionStore()
|
||||||
) {
|
) {
|
||||||
self.shields = shields
|
self.shields = shields
|
||||||
self.usageReader = usage
|
self.usageReader = usage
|
||||||
self.scheduler = scheduler
|
self.scheduler = scheduler
|
||||||
|
self.openSessions = openSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
/// 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,
|
/// Recomputes shields from scratch. Call on launch, on any rule change,
|
||||||
/// and periodically while the app is visible. Also expires stale pauses.
|
/// 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) {
|
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 {
|
for rule in rules {
|
||||||
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
|
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
|
||||||
rule.pausedUntil = nil
|
rule.pausedUntil = nil
|
||||||
}
|
}
|
||||||
let usage = usage(for: rule, at: now, calendar: calendar)
|
let usage = usage(for: rule, at: now, calendar: calendar)
|
||||||
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
|
let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
|
||||||
active.insert(rule.id)
|
if isBlocking { blocking.insert(rule.id) }
|
||||||
|
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shielded.insert(rule.id)
|
||||||
shields.applyShield(
|
shields.applyShield(
|
||||||
ruleID: rule.id,
|
ruleID: rule.id,
|
||||||
selectionData: rule.appList?.selectionData,
|
selectionData: rule.appList?.selectionData,
|
||||||
@@ -63,8 +80,24 @@ final class RuleEnforcer {
|
|||||||
blockAdultContent: rule.blockAdultContent
|
blockAdultContent: rule.blockAdultContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
shields.clearShields(except: active)
|
shields.clearShields(except: shielded)
|
||||||
blockingRuleIDs = active
|
// "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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,47 @@ struct RuleEnforcerTests {
|
|||||||
#expect(shields.appliedModes[rule.id] == .allowOnly)
|
#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")
|
@Test("The adult-content flag is forwarded to the shield layer")
|
||||||
func forwardsAdultContentFlag() {
|
func forwardsAdultContentFlag() {
|
||||||
let shields = MockShieldController()
|
let shields = MockShieldController()
|
||||||
@@ -126,3 +167,105 @@ struct RuleEnforcerTests {
|
|||||||
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
|
#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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -373,7 +373,12 @@ struct LimitEnforcementTests {
|
|||||||
let shields = MockShieldController()
|
let shields = MockShieldController()
|
||||||
let ledger = UsageLedger(defaults: freshDefaults())
|
let ledger = UsageLedger(defaults: freshDefaults())
|
||||||
let store = RuleSnapshotStore(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(
|
private func snapshot(
|
||||||
@@ -450,6 +455,28 @@ struct LimitEnforcementTests {
|
|||||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
#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")
|
@Test("Session end re-shields the rule for the next open")
|
||||||
func sessionEndReshields() {
|
func sessionEndReshields() {
|
||||||
let (enforcement, shields, _, store) = makeEnforcement()
|
let (enforcement, shields, _, store) = makeEnforcement()
|
||||||
|
|||||||
@@ -170,17 +170,38 @@ struct UsageEnforcementTests {
|
|||||||
#expect(shields.appliedAdultContentFlags[rule.id] == false)
|
#expect(shields.appliedAdultContentFlags[rule.id] == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A limit rule with budget left is not shielded")
|
@Test("A time-limit rule with budget left is not shielded")
|
||||||
func leavesUnspentLimitAlone() {
|
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 shields = MockShieldController()
|
||||||
let ledger = MockUsageLedger()
|
let ledger = MockUsageLedger()
|
||||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
let rule = BlockingRule(
|
let rule = BlockingRule(
|
||||||
name: "Gate Keeper",
|
name: "Gate Keeper",
|
||||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||||
days: Weekday.everyDay)
|
days: Weekday.weekends)
|
||||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
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)
|
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct LimitEnforcement {
|
|||||||
let snapshots: RuleSnapshotStore
|
let snapshots: RuleSnapshotStore
|
||||||
let ledger: UsageLedger
|
let ledger: UsageLedger
|
||||||
let shields: ShieldApplying
|
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
|
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
||||||
/// proactively shielded on enabled days so the shield can count opens;
|
/// proactively shielded on enabled days so the shield can count opens;
|
||||||
@@ -62,6 +64,7 @@ struct LimitEnforcement {
|
|||||||
func handleOpenSessionEnded(
|
func handleOpenSessionEnded(
|
||||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||||
) {
|
) {
|
||||||
|
sessions.endSession(for: ruleID)
|
||||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||||
snapshot.kind == .openLimit,
|
snapshot.kind == .openLimit,
|
||||||
!snapshot.isPaused(at: now),
|
!snapshot.isPaused(at: now),
|
||||||
@@ -84,6 +87,13 @@ struct LimitEnforcement {
|
|||||||
guard !snapshot.limitReached(given: usage) else { return false }
|
guard !snapshot.limitReached(given: usage) else { return false }
|
||||||
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||||
shields.clearShield(ruleID: ruleID)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,7 +288,20 @@ Full-height sheet:
|
|||||||
work the same with an opens budget; while opens remain, their apps stay
|
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
|
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
|
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
|
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
|
||||||
rule (card presumably shows disabled state). No delete flow was shown;
|
rule (card presumably shows disabled state). No delete flow was shown;
|
||||||
add delete via swipe/long-press or a button in the editor.
|
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".
|
should be deliberate. Editing uses a plain "Done".
|
||||||
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
|
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
|
||||||
(minute granularity is fine).
|
(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)
|
### 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
|
app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
|
||||||
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
|
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
|
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
|
||||||
enabled rules:
|
enabled rules:
|
||||||
- **Limit rules** — one repeating 00:00–23:59 activity per rule
|
- **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).
|
that app is relaunched (a long-standing Screen Time platform limitation).
|
||||||
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
|
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
|
||||||
(launch + 30 s foreground loop) is retained as the reconciliation safety
|
(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
|
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
||||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
||||||
button; other shields show the blocking rule's name.
|
button; other shields show the blocking rule's name.
|
||||||
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
|
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
|
||||||
spends one open in the ledger, lifts the rule's shield, and starts the
|
spends one open in the ledger, lifts the rule's shield, records the session
|
||||||
~15-minute one-shot session (DeviceActivity's minimum interval).
|
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
|
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
|
||||||
tested from the app test target.
|
tested from the app test target.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user