Merge pull request 'fix: enforce open-limit rules proactively in the foreground' (#1) from fix/open-limit-proactive-enforcement into main
Reviewed-on: #1
This commit was merged in pull request #1.
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