Files
OpenAppLock/AGENTS.md
Brendan Chen 1652c2f410 feat: enforce time and open limits in the background via Screen Time extensions
- 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)
2026-06-12 21:06:08 -04:00

8.8 KiB

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 ModelContainers 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 still rely on the app running (launch / foreground 30s loop); schedule rules have no DeviceActivity monitoring yet — only limit rules do.
  • 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.