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).
Co-Authored-By: Claude <noreply@anthropic.com>
Time-limit and open-limit rules reuse BlockingRule's startMinutes/
endMinutes, which default to 09:00-17:00. Those fields are meaningless
for limit rules (the budget applies all day, resetting at midnight), but
status() still falls through to schedule.nextStart(), so an idle limit
rule reported .upcoming(startsAt: next 09:00) — rendered in the detail
sheet header as "Starts in 22h".
The home card already special-cased this; the detail sheet rendered the
raw status label, so the misleading countdown leaked there. Centralize a
kind-aware BlockingRule.statusLabel(for:relativeTo:) used by both the
card and the detail header: limit rules that aren't blocking show their
daily budget ("45m / day", "5 opens / day"); schedule rules and any
blocking/paused/dormant rule keep the live status label.
Verified on simulator: the Time Keeper detail header now reads
"Time Limit, 45m / day". 173 tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>
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.
Co-Authored-By: Claude <noreply@anthropic.com>
- 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)
Co-Authored-By: Claude <noreply@anthropic.com>
- RuleUsage + UsageLedger: per-rule, per-day minutes/opens in app-group
defaults (monotonic minutes, incrementing opens, midnight rollover by
day-keying); MockUsageLedger for tests and seeded UI scenarios
- usage-aware status: a limit rule whose daily budget is spent on an
enabled day is active (blocking) until next midnight; Hard Mode gating
and app-list locking honor it; soft unblock pauses until midnight
- RuleEnforcer shields spent limit rules (always Block mode) while the
app runs
- new Usage section under Blocked Apps: '18m of 45m used today · 27m
left', '2 of 5 opens today · 3 opens left', 'Blocked until tomorrow'
- new 'limits' seed scenario + UI tests
Co-Authored-By: Claude <noreply@anthropic.com>
Screen 1 is a plain List (name field, the apps the list contains via
FamilyControls token Labels, and an Edit Apps button); screen 2 pushes
Apple's FamilyActivityPicker whose Save applies the selection back.
Documents that the picker silently drops selections without real
FamilyControls authorization (mocked in -ui-testing launches).
Co-Authored-By: Claude <noreply@anthropic.com>
- AppList @Model with launch migration from legacy inline selections
(rules with identical selections share one list; idempotent)
- rules point at one app list; Block/Allow Only belongs to the rule and
is offered only in the Schedule editor (limit kinds sanitize to Block)
- app-list picker sheet (select / create / edit / delete with in-use
protection) replaces the per-rule selection sheet
- Hard Mode locks app-list editing while any hard rule is blocking
- SwiftData stability: relationships are only wired between managed
models, and unit tests share one in-memory container (fresh context +
data wipe per test) — per-test container creation trapped
intermittently inside SwiftData
Co-Authored-By: Claude <noreply@anthropic.com>
Full rename ahead of App Store submission: project, targets, bundle
identifiers (dev.bchen.Severed -> dev.bchen.OpenAppLock), entitlements
file, app entry point, user-facing onboarding strings, test helpers,
and docs. Verified: all 62 unit tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>
- Both editor modes commit via a checkmark confirmation button
(role: .confirm) in the navigation bar — accessibility labels
'Add Rule' (create) and 'Done' (edit); replaces the bottom
'Add Rule' button, so nothing floats over the form anymore
- Edit mode gains an ellipsis 'Rule Actions' menu next to the
checkmark holding Disable/Enable Rule and destructive Delete Rule;
the red form rows are removed
- Tests pin the checkmark to the navigation bar with its label and
drive disable/delete through the menu; the swipe-to-reach
workarounds are gone. 95 passing
- Spec §6 updated
Co-Authored-By: Claude <noreply@anthropic.com>
- Rule name is now an inline text field at the top of the editor; the
pencil/rename button and alert are gone (it was unclear what the edit
button did). Names are sanitized on commit: trimmed, falling back to
the kind's default when emptied
- Day-of-week toggles fill the full row width with equal cells and
>= 44pt tap targets
- 'Add Rule' commit button sits on a bar-material bottom inset so form
content no longer collides with it while scrolling (found via
computer-use scan; verified clean in a follow-up scan)
- Tests: 95 passing — new specs for the inline rename flow, day-toggle
geometry, and name sanitization; three tests now scroll to reach
bottom-of-form controls, matching real user behavior
- Spec §6 updated for the editor changes
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the Opal-style custom presentation with the bare iOS design
language, keeping the backend, flows, and accessibility identifiers:
- Home: NavigationStack + List; rules now presented as list rows with
kind icon, block summary, and trailing live status; '+' in the toolbar
- New Rule: plain list of rule types and presets; editor still pushed
via navigationDestination
- Editor: native Form (DatePicker rows, day circles with summary in the
section header, toggle rows with footers, stepper rows); create
commits with a prominent 'Add Rule' button replacing Hold to Commit;
edit uses toolbar Done plus red Disable/Delete rows
- Detail: sheet with inline title + status caption, LabeledContent
rows; Edit Rule pushes the editor natively
- Default color scheme: drop forced dark mode and custom tint; system
appearance and accent throughout
- Delete Theme, HoldToCommitButton, RuleCardView and custom chrome
- Use concrete Color.primary/secondary in button row labels (the
hierarchical styles resolve against the button tint)
- Tests: 93 passing; only delta is holdToCommitButton press →
commitRuleButton tap in 5 call sites
- Spec: add §6 mapping the original presentation to the native one
Co-Authored-By: Claude <noreply@anthropic.com>
- Add Block Adult Content toggle to all three rule editors, persisted as
BlockingRule.blockAdultContent (inline default for clean migration of
existing stores) and surfaced in the detail sheet as an
'Adult websites: Blocked/Allowed' row
- Engage Screen Time's adult-website filter
(webContent.blockedByFilter = .auto()) alongside the rule's shield and
clear it when the shield clears
- Replace the New Rule sheet's view-swap with a NavigationStack push
(navigationDestination(item:)); the editor uses native chrome there
(system back button, inline title, toolbar rename), enabling the push
animation and edge-swipe back
- Tests: 93 passing (+2 unit: draft round-trip and enforcer forwarding;
+3 UI: toggle-to-detail flow, default-allowed row, swipe-back as a
behavioral proof of native navigation)
- Spec updated accordingly (editor sections, behavior, data model,
navigation note)
Co-Authored-By: Claude <noreply@anthropic.com>
Rebuild the app around recurring screen-time blocking rules modeled on
Opal's My Apps tab:
- Onboarding with FamilyControls Screen Time authorization
- Apps home with Blocked Apps tiles and live rule cards
- New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery
- Rule editors: time windows (incl. overnight), day picker, app selection
via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit
- Hard Mode: active hard rules cannot be edited, disabled, deleted, or
unblocked until their window ends; soft rules pause until next window
- Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer
- 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg
harness for in-memory storage, mocked authorization, seeded scenarios
- docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording
Known gap: background window transitions and time/open-limit thresholds
need a DeviceActivityMonitor extension; shields currently sync while the
app is running.
Co-Authored-By: Claude <noreply@anthropic.com>