- 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)
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 <= startmeans 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:
RulePolicyis the single gate — while a hard-mode rule is actively blocking, canEdit/canDisable/canDelete/canUnblock are all false. Soft rules can be "unblocked", which setspausedUntil= window end (the rule re-arms at its next window). - Shields: one
ManagedSettingsStoreper rule (rule-<uuid>), tracked in UserDefaults for stray cleanup.blockAdultContentengageswebContent.blockedByFilter = .auto()alongside the shield. RuleEnforcer.refreshis the only place shields change; the home view runs it on rule changes and a 30s loop while visible.
Build & test
- Open
OpenAppLock.xcodeprojin Xcode; build/test through the Xcode MCP tools (BuildProject,RunAllTests,RunSomeTests— get the tab id fromXcodeListWindows). 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
.swiftfiles 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.mdfirst 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'sinitor 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 throughmakeInMemoryContext()(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/.tertiaryforeground styles resolve to the tint (e.g. blue chevrons); use concreteColor.primary/Color.secondary/Color(.tertiaryLabel). - The unblock confirmation dialog is queried via
app.sheets.buttons[...](a barebuttons["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.
FamilyActivityPickershows few apps on the simulator; fine on device.FamilyActivityPickersilently ignores selections (binding never updates, rows still show checkmarks) unless real FamilyControls authorization has been granted — in-ui-testinglaunches 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.