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. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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