Compare commits
1 Commits
fc0f518608
...
fix/open-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 31830cb141 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,8 @@ struct LimitEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let ledger: UsageLedger
|
||||
let shields: ShieldApplying
|
||||
/// Granted-open session bookkeeping shared with the foreground enforcer.
|
||||
var sessions = OpenSessionStore()
|
||||
|
||||
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
||||
/// proactively shielded on enabled days so the shield can count opens;
|
||||
@@ -62,6 +64,7 @@ struct LimitEnforcement {
|
||||
func handleOpenSessionEnded(
|
||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) {
|
||||
sessions.endSession(for: ruleID)
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .openLimit,
|
||||
!snapshot.isPaused(at: now),
|
||||
@@ -84,6 +87,13 @@ struct LimitEnforcement {
|
||||
guard !snapshot.limitReached(given: usage) else { return false }
|
||||
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
// Mark the session so neither enforcement path re-shields the app until
|
||||
// it ends (+1 minute matches the one-shot activity's padding).
|
||||
if let expiry = calendar.date(
|
||||
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
|
||||
{
|
||||
sessions.startSession(for: ruleID, until: expiry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
59
Shared/OpenSessionStore.swift
Normal file
59
Shared/OpenSessionStore.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// OpenSessionStore.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Read access to in-progress granted "Open" sessions, keyed by rule.
|
||||
protocol OpenSessionReading: AnyObject {
|
||||
/// Whether a granted open for `ruleID` is still running at `now`.
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool
|
||||
}
|
||||
|
||||
/// Records when a granted "Open" expires, per rule, in the shared app-group
|
||||
/// defaults. Pressing the shield's "Open" button lifts an open-limit rule's
|
||||
/// shield for ~15 minutes (`MonitoringPlan.openSessionMinutes`); this marker
|
||||
/// lets the foreground enforcer leave that one rule un-shielded for the life of
|
||||
/// the session instead of re-locking the app mid-session. The monitor clears it
|
||||
/// when the session's one-shot activity ends.
|
||||
final class OpenSessionStore: OpenSessionReading {
|
||||
private static let key = "openSessionExpiry"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date = .now) -> Bool {
|
||||
guard let expiry = expiries[ruleID.uuidString] else { return false }
|
||||
return Date(timeIntervalSince1970: expiry) > now
|
||||
}
|
||||
|
||||
/// Marks a granted open for `ruleID` running until `expiry`.
|
||||
func startSession(for ruleID: UUID, until expiry: Date) {
|
||||
var map = expiries
|
||||
map[ruleID.uuidString] = expiry.timeIntervalSince1970
|
||||
defaults.set(map, forKey: Self.key)
|
||||
}
|
||||
|
||||
/// Ends a granted open (its one-shot activity fired, or it is being reset).
|
||||
func endSession(for ruleID: UUID) {
|
||||
var map = expiries
|
||||
map[ruleID.uuidString] = nil
|
||||
defaults.set(map, forKey: Self.key)
|
||||
}
|
||||
|
||||
private var expiries: [String: TimeInterval] {
|
||||
defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory granted sessions for tests and UI-test launches.
|
||||
final class MockOpenSessionStore: OpenSessionReading {
|
||||
var activeRuleIDs: Set<UUID> = []
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool {
|
||||
activeRuleIDs.contains(ruleID)
|
||||
}
|
||||
}
|
||||
@@ -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: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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user