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>
163 lines
9.1 KiB
Markdown
163 lines
9.1 KiB
Markdown
# OpenAppLock — Agent Guide
|
|
|
|
OpenAppLock is an iOS Screen Time app: recurring **rules** that block selected
|
|
apps (Schedule windows, Time Limits, Open Limits), with a **Hard Mode** that
|
|
makes an active block impossible to lift, edit, or delete until it ends. The
|
|
feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
|
|
(List/Form/NavigationStack, default color scheme).
|
|
|
|
## Repo layout
|
|
|
|
```
|
|
OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData)
|
|
Models/ BlockingRule + AppList (@Model), RuleDraft,
|
|
RulePreset
|
|
Logic/ Pure, heavily unit-tested:
|
|
RuleStatus (derived status + labels, usage-aware),
|
|
RulePolicy (Hard Mode gating, unblock/pause,
|
|
app-list lock), UsageDisplay (Usage-section text)
|
|
Services/ ScreenTimeAuthorization (FamilyControls behind a
|
|
protocol + mock), RuleEnforcer (rules → shields),
|
|
RuleScheduler (rules → DeviceActivity monitoring),
|
|
AppListMigration, LaunchConfiguration +
|
|
SampleRules (UI-test harness)
|
|
Views/ Native SwiftUI screens (see docs spec §6)
|
|
Shared/ Compiled into the app AND all three extensions:
|
|
RuleKind, Weekday, RuleSchedule, AppGroup,
|
|
UsageLedger (per-day minutes/opens),
|
|
RuleSnapshot(+Store) (rule mirror in the app
|
|
group), MonitoringPlan (activity/event naming),
|
|
LimitEnforcement (shared event reactions),
|
|
ShieldController, ShieldLookup
|
|
OpenAppLockMonitor/ DeviceActivityMonitor extension: midnight resets,
|
|
usage-minute checkpoints → shield at the limit,
|
|
open-session expiry
|
|
OpenAppLockShieldConfig/ ShieldConfiguration extension: "Opened X of N" +
|
|
Open button on open-limit shields
|
|
OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open,
|
|
lifts the shield, starts the ~15-min session
|
|
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
|
|
target defaults to MainActor isolation)
|
|
OpenAppLockUITests/ XCUITest flows (see harness below)
|
|
docs/RULES_FEATURE_SPEC.md Feature spec derived from the Opal reference
|
|
recording; §6 maps it to the native presentation.
|
|
Review/update this BEFORE behavior changes.
|
|
```
|
|
|
|
## Domain facts worth knowing
|
|
|
|
- Times are stored as **minutes from midnight**; `end <= start` means the
|
|
window crosses midnight (e.g. 22:00→06:00) and belongs to the day it
|
|
*starts* on. `start == end` = 24h window.
|
|
- Status is always **derived** (`rule.status(at:calendar:)`), never stored:
|
|
`disabled / dormant / active(until:) / paused(until:) / upcoming(startsAt:)`.
|
|
Labels match the reference app ("6h left" rounds hours **up**).
|
|
- **Hard Mode**: `RulePolicy` is the single gate — while a hard-mode rule is
|
|
actively blocking, canEdit/canDisable/canDelete/canUnblock are all false.
|
|
Soft rules can be "unblocked", which sets `pausedUntil` = window end (the
|
|
rule re-arms at its next window).
|
|
- Shields: one `ManagedSettingsStore` per rule (`rule-<uuid>`), tracked in
|
|
UserDefaults for stray cleanup. `blockAdultContent` engages
|
|
`webContent.blockedByFilter = .auto()` alongside the shield.
|
|
- `RuleEnforcer.refresh` is the only place shields change; the home view runs
|
|
it on rule changes and a 30s loop while visible.
|
|
|
|
## Build & test
|
|
|
|
- Open `OpenAppLock.xcodeproj` in Xcode; build/test through the **Xcode MCP**
|
|
tools (`BuildProject`, `RunAllTests`, `RunSomeTests` — get the tab id from
|
|
`XcodeListWindows`). Make sure the scheme destination is an iOS
|
|
**simulator**; a physical-device destination makes test runs hang or get
|
|
cancelled.
|
|
- The project uses Xcode file-system-synchronized groups: adding/removing
|
|
`.swift` files on disk is enough, no pbxproj editing.
|
|
- Family Controls entitlement is configured (`OpenAppLock/OpenAppLock.entitlements`).
|
|
FamilyControls/ManagedSettings compile and run on the simulator, but real
|
|
blocking behavior is only observable on a device.
|
|
|
|
## Workflow expectations (user preference)
|
|
|
|
- **Red-green TDD**: update `docs/RULES_FEATURE_SPEC.md` first for behavior
|
|
changes, write the failing test, run it (compile failure counts as red),
|
|
implement, re-run focused tests, then the full suite. Run tests often and
|
|
fail fast.
|
|
- Conventional commits (`feat:`, `fix:`, `refactor:` …), no AI attribution
|
|
footers. Commit only when the user asks.
|
|
|
|
## UI-test harness
|
|
|
|
`OpenAppLockApp` reads launch arguments (parsed by `LaunchConfiguration`):
|
|
|
|
| Argument | Effect |
|
|
|---|---|
|
|
| `-ui-testing` | In-memory SwiftData store, mock authorization, mock shields |
|
|
| `-onboarding-completed` / `-onboarding-required` | Force the onboarding flag |
|
|
| `-seed-scenario=standard` | Active soft rule "Work Time" + upcoming "Sleep" |
|
|
| `-seed-scenario=hard-mode-active` | Active Hard Mode rule "Locked In" + upcoming "Sleep" |
|
|
|
|
Use `XCUIApplication.launchOpenAppLock(...)` (UITestSupport.swift), which also
|
|
provides `app.element(_:)` for identifier lookup across element types and
|
|
`waitToAppear()`.
|
|
|
|
Key accessibility identifiers (keep stable — tests and future work rely on
|
|
them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
|
|
`blockedTile-<name>`, `nothingBlockedLabel`, `emptyRulesCard`,
|
|
`closeNewRuleButton`, `ruleKind-<kind>`, `preset-<id>`, `ruleEditorTitle`,
|
|
`fromTimePicker`/`toTimePicker`, `dayToggle-1…7`, `selectedAppsRow`,
|
|
`hardModeToggle`, `adultContentToggle`, `dailyLimitStepper(+Value)`,
|
|
`maxOpensStepper(+Value)`, `commitRuleButton`, `doneButton`,
|
|
`toggleEnabledButton`, `deleteRuleButton`, `closeDetailButton`,
|
|
`detailRuleName`, `detailStatusLabel`, `detailRow-<label>`,
|
|
`hardModeLockedNotice`, onboarding: `onboardingContinueButton`,
|
|
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.
|
|
|
|
Gotchas learned the hard way:
|
|
- **SwiftData relationships**: never assign a relationship property (e.g.
|
|
`rule.appList`) inside a model's `init` or on un-inserted instances —
|
|
insert both models into a context first, then wire them.
|
|
- **SwiftData container churn**: repeatedly creating `ModelContainer`s for
|
|
this schema traps intermittently (EXC_BREAKPOINT inside SwiftData's
|
|
configuration setup), which Xcode shows as a test "hang" paused at a
|
|
breakpoint. Unit tests must go through `makeInMemoryContext()`
|
|
(TestSupport.swift): one shared container per process, fresh context +
|
|
data wipe per test.
|
|
- Identifiers on SwiftUI containers need `.accessibilityElement(children:
|
|
.combine)` (or a Button/control) to be queryable.
|
|
- List/Form section headers render uppercased unless `.textCase(nil)` —
|
|
tests assert exact header strings.
|
|
- Inside tinted Button rows, hierarchical `.primary`/`.secondary`/`.tertiary`
|
|
foreground styles resolve to the tint (e.g. blue chevrons); use concrete
|
|
`Color.primary`/`Color.secondary`/`Color(.tertiaryLabel)`.
|
|
- The unblock confirmation dialog is queried via `app.sheets.buttons[...]`
|
|
(a bare `buttons["Unblock"]` is ambiguous with the row label).
|
|
|
|
## Known gaps / next steps
|
|
|
|
- **On-device verification of limit enforcement is pending.** The
|
|
DeviceActivity monitor + shield extensions and the app group are in place,
|
|
but real blocking/usage tracking is only observable on a device (the
|
|
simulator neither tracks usage nor renders custom shields). Verify: time
|
|
limits accrue in the Usage section and block at the budget; open-limit
|
|
apps shield immediately with an "Open (N left)" button; an open lasts
|
|
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
|
|
- **Schedule-rule background transitions** are now backed by DeviceActivity:
|
|
`RuleScheduler` registers a repeating window activity per schedule rule
|
|
(`sched-<uuid>`, plus `sched2-<uuid>` for midnight-crossing windows) and the
|
|
monitor extension recomputes + applies/clears the shield on interval
|
|
start/end. The foreground 30s loop remains as the reconciliation safety net
|
|
because interval callbacks are unreliable. On-device verification of the
|
|
background transition is still pending (the simulator does not deliver
|
|
DeviceActivity callbacks).
|
|
- `FamilyActivityPicker` shows few apps on the simulator; fine on device.
|
|
- `FamilyActivityPicker` **silently ignores selections** (binding never
|
|
updates, rows still show checkmarks) unless real FamilyControls
|
|
authorization has been granted — in `-ui-testing` launches authorization
|
|
is mocked, so picker selections can never be asserted in UI tests. To
|
|
verify selection flows manually on the simulator, launch without
|
|
`-ui-testing`, complete onboarding, and approve the system Screen Time
|
|
prompts ("Allow with Passcode" works on the simulator).
|
|
- Distribution (App Store) requires Apple's approval for the Family Controls
|
|
entitlement **for the app and each extension bundle ID**
|
|
(`dev.bchen.OpenAppLock`, `.Monitor`, `.ShieldConfig`, `.ShieldAction`);
|
|
development builds work with the dev entitlement.
|