Replace the wide BlockingRule/RuleDraft "god struct" — where every option
existed for every kind — with a RuleConfiguration sum type that carries only
the options each kind actually has:
.schedule(ScheduleConfig: window + selectionMode + blockAdultContent)
.timeLimit(TimeLimitConfig: dailyLimitMinutes)
.openLimit(OpenLimitConfig: maxOpens)
Name, days, Hard Mode, app list, and pause stay common to all kinds. This
makes illegal states unrepresentable: Block / Allow Only and Block Adult
Content are now structurally Schedule-only.
User-visible behavior change: the Time Limit and Open Limit editors no longer
offer a Block Adult Content toggle, and their detail sheets drop the "Adult
websites" row — those never made sense for a usage budget. Limit rules are
always Block and never engage the web-content filter.
BlockingRule keeps flat columns as raw persistence behind a computed
`configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot
wire format and the Screen Time extensions are untouched). Logic, the editors,
and the detail sheet all switch on the sum type.
Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with
new structural-guarantee unit tests and a UI test asserting the Time Limit
editor/detail omit adult content. Full suite green (180 tests).
Schedule (time-window) rules had no background enforcement: RuleScheduler.sync()
skipped them, so their shields were applied only by RuleEnforcer.refresh() — the
launch + 30s foreground loop. A window that began while the app was closed didn't
engage until the user reopened the app, which is why scheduled blocks could land
late or unevenly.
RuleScheduler now registers a repeating DeviceActivitySchedule per enabled
schedule rule's window (sched-<uuid>, plus sched2-<uuid> for windows that cross
midnight, since DeviceActivity can't express an interval whose end precedes its
start). The monitor extension routes these to a new ScheduleEnforcement.reconcile(),
which recomputes the rule's live schedule state from its snapshot
(RuleSchedule.isActive, honouring days, pause and the midnight-crossing rule) and
applies or clears the shield to match — the same logic RuleEnforcer.refresh runs
in the foreground, kept as the reconciliation safety net because interval callbacks
are known to fire late or not at all.
- RuleSnapshot gains startMinutes/endMinutes, with a tolerant decoder so snapshots
written before these fields still load instead of failing the whole batch and
blinding the extensions until the app reopens.
- Window activities are fingerprinted on their interval alone, so changing days,
mode or apps no longer needlessly restarts monitoring.
On-device verification of the background transition is still pending (the simulator
does not deliver DeviceActivity callbacks); covered by new unit tests across the
scheduler, the snapshot codec and the new enforcement reactions.
- Shared/ layer compiled into the app and three new extension targets:
rule snapshots in the app group, the usage ledger, monitoring-plan
naming, and LimitEnforcement (shared, unit-tested event reactions)
- RuleScheduler mirrors rules to the app group and reconciles
DeviceActivity monitoring: one daily 00:00-23:59 activity per limit
rule, with a cumulative usage-threshold event per budget minute for
time limits; activities restart only when their configuration changes
(a restart resets threshold accounting)
- OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets,
records usage minutes, shields at the budget, re-shields when a
granted open session ends
- OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times
today' with an 'Open (Y left)' secondary button
- OpenAppLockShieldAction: an Open press spends one open, lifts the
rule's shield, and starts the ~15-minute one-shot session
- extensions are classic NSExtension app extensions (the ExtensionKit
product type expects an @main entry and made the app fail to install);
shield-store tracking moved to app-group defaults so the app and
extensions see one consistent set
- maps iOS 26's new .approvedWithDataAccess authorization status to
approved (it previously fell through @unknown default to notDetermined)
- shares the OpenAppLock scheme (Xcode dropped the autocreated one when
targets were added)