From 6377eda959960ec7bf76593681ab32c38bf23f80 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 18:56:31 -0400 Subject: [PATCH 1/2] docs: fold the Rules feature spec into source doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone Docs/AGENT_RULES_FEATURE_SPEC.md with the spec living where it belongs — as /// doc comments on the source each topic owns — and slim the central doc to Docs/RULES_SPEC.md, a navigational index: concept, current navigation map, the §4.8 cross-cutting "strictest wins" invariant, the background-architecture overview, a topic → source table, and a historical appendix for the pre-reskin custom design. The codebase already documented its behavior thoroughly in doc comments, so this mostly recognizes that and makes it navigable. A few durable details were added where the spec was richer than the code: the Schedule-only rationale in RuleConfiguration and the Allow-Only "can't punch a hole" invariant in ShieldController. Reframe doc ownership (AGENTS.md, CLAUDE.md, AGENT_SWIFT_GUIDELINES.md): the rules spec is now human-owned and co-maintained — update the owning file's doc comment in the same commit as the code. Adds a "shared" bucket to the AGENT_-prefix contract so RULES_SPEC.md is editable by both humans and agents. Build green; 240/240 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012E73uBVKkqL25RJrF7tfmm --- AGENTS.md | 48 +- CLAUDE.md | 4 +- Docs/AGENT_RULES_FEATURE_SPEC.md | 625 ------------------ Docs/AGENT_SWIFT_GUIDELINES.md | 3 +- Docs/RULES_SPEC.md | 205 ++++++ OpenAppLock/Views/Rules/RuleDetailSheet.swift | 2 +- OpenAppLock/Views/Rules/RuleEditorView.swift | 2 +- Shared/RuleConfiguration.swift | 6 +- Shared/ShieldController.swift | 4 + 9 files changed, 247 insertions(+), 652 deletions(-) delete mode 100644 Docs/AGENT_RULES_FEATURE_SPEC.md create mode 100644 Docs/RULES_SPEC.md diff --git a/AGENTS.md b/AGENTS.md index e4862db..3ce652d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData) RuleScheduler (rules → DeviceActivity monitoring), AppListMigration, LaunchConfiguration + SampleRules (UI-test harness) - Views/ Native SwiftUI screens (see Docs spec §6) + Views/ Native SwiftUI screens (spec in each view's doc + comment; see Docs/RULES_SPEC.md §2) Shared/ Compiled into the app AND all three extensions: RuleKind, Weekday, RuleSchedule, AppGroup, UsageLedger (per-day minutes/opens), @@ -39,11 +40,12 @@ OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open, OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app target defaults to MainActor isolation) OpenAppLockUITests/ XCUITest flows (see harness below) -Docs/AGENT_RULES_FEATURE_SPEC.md - Feature spec for the rules behavior; §6 maps it to - the native presentation. Source of truth — review - BEFORE behavior changes, keep current after them - (agent-managed; see Documentation). +Docs/RULES_SPEC.md Rules feature spec index: concept, navigation map, + cross-cutting invariants, and a topic → source map. + The detailed spec lives as doc comments on the source + each topic owns — review BEFORE behavior changes, keep + current after them, in the same commit (human-owned, + co-maintained; see Documentation). Docs/AGENT_SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards agents must follow on this project (agent-managed). @@ -51,21 +53,25 @@ Docs/AGENT_SWIFT_GUIDELINES.md ## Documentation -Documentation splits into two buckets, distinguished by **filename**, not by -directory: +Documentation falls into three buckets: - **Agent-managed** — this `AGENTS.md`, `CLAUDE.md`, and any file whose name is - prefixed with `AGENT_` (currently `Docs/AGENT_RULES_FEATURE_SPEC.md` and - `Docs/AGENT_SWIFT_GUIDELINES.md`). Agents may **read, create, and edit** these - and are expected to keep them accurate. Treat the feature spec as the source - of truth for behavior, and update it when a behavior change makes it stale. + prefixed with `AGENT_` (currently `Docs/AGENT_SWIFT_GUIDELINES.md`). Agents may + **read, create, and edit** these and are expected to keep them accurate. +- **Shared (human + agent)** — the rules feature spec. It lives as doc comments + **on the source each behavior owns**, with `Docs/RULES_SPEC.md` as a + human-owned index/map. The doc comments are the source of truth for behavior; + both humans and agents maintain them. When you change a behavior, update the + owning file's doc comment in the same commit (and the `RULES_SPEC.md` map if a + topic moves to a different file). - **Human-authored** — every other doc, e.g. `README.md`. Agents may **read** these for context but must **never create or modify** them; flag needed changes for the maintainer instead. -The `AGENT_` prefix is the contract: it marks a file as safe for agents to -maintain. Any human-authored doc added without the prefix is automatically -off-limits to agent edits. +The `AGENT_` prefix still marks a file as safe for agents to maintain. +`RULES_SPEC.md` is deliberately un-prefixed but explicitly shared, so agents may +update it (and the code doc comments it maps to) as part of a behavior change. +Any *other* un-prefixed doc remains off-limits to agent edits. ## Domain facts worth knowing @@ -107,12 +113,12 @@ when reminded: - **Always plan before execution.** Think through and lay out the approach (a written plan / plan mode for anything non-trivial) and confirm scope before editing code. Do not start changing files until the plan is clear. -- **Always use red-green TDD.** Consult `Docs/AGENT_RULES_FEATURE_SPEC.md` - first for behavior changes — it is the source of truth. If a behavior change - makes the spec inaccurate, keep it current (it is agent-managed; see - Documentation above). Then 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. +- **Always use red-green TDD.** Consult the feature spec first for behavior + changes — the doc comment on the file you're changing is the source of truth, + with `Docs/RULES_SPEC.md` as the map. If a change makes a doc comment + inaccurate, update it in the same commit (see Documentation above). Then 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. - **Always attempt to validate the UI manually before committing.** Build and run the app (simulator/device) and visually confirm the change behaves as intended. This step **may be skipped only when such tooling is unavailable** diff --git a/CLAUDE.md b/CLAUDE.md index 4b7687b..fb1f466 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ # OpenAppLock All project context for agents lives in [AGENTS.md](AGENTS.md) — read that -first. Feature behavior is specified in -[Docs/AGENT_RULES_FEATURE_SPEC.md](Docs/AGENT_RULES_FEATURE_SPEC.md). +first. Feature behavior is specified in doc comments on the source it describes, +mapped from [Docs/RULES_SPEC.md](Docs/RULES_SPEC.md). diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md deleted file mode 100644 index 05ebd01..0000000 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ /dev/null @@ -1,625 +0,0 @@ -# OpenAppLock — "Rules" Feature Spec - -This spec describes OpenAppLock's recurring app-blocking **rules** feature: the -behavior the app implements, then how it maps onto the native iOS presentation -(see §6). - ---- - -## 1. Concept - -A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off -block/timer session, a rule re-arms itself on a schedule. Three rule types are -offered, presented on the "New Rule" sheet: - -| Type | Icon | Example shown | Semantics | -|------|------|---------------|-----------| -| **Schedule** | calendar grid | "e.g. 9-5, Daily" | Block selected apps during a daily time window on chosen days | -| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point | -| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them | - -**Common attributes** — present for *every* rule kind: - -- **Name** — user-editable, free text (presets: "Morning Focus", "Deep Work", "Evening Reset", "Lights Out", "Family Dinner", "Screen-Free Sunday"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit)) -- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom -- **App List** *(OpenAppLock refinement)* — each rule points to exactly one - **App List**: a named, reusable selection of apps/categories/websites stored - independently of any rule. When editing a rule the user picks an existing - list or creates a new one; editing a list affects every rule that uses it. - Deleting a list detaches it from its rules (they fall back to "no apps"). -- **Hard Mode** — boolean; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes" -- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule") - -**Per-kind options** — each kind carries only the options that make sense for -it. The data model expresses this as a sum type (`RuleConfiguration`, see §5.2) -so a kind structurally cannot hold another kind's options: - -- **Schedule** — a recurring **time window** (`From`/`To`, may cross midnight), - a **Selection mode** (**Block** the list, or **Allow Only** = block - everything except it; the mode belongs to the *rule*, not the list), and - **Block Adult Content** (engage Screen Time's adult-website filter while the - window is active). -- **Time Limit** — a **daily minutes budget**. -- **Open Limit** — a **daily opens budget**. - - Selection mode and Block Adult Content are **Schedule-only**: a usage budget - over "everything except X" is not meaningful, and engaging a web-content - filter when a *usage* budget is spent does not fit the feature. Time Limit and - Open Limit rules are always Block and never touch the adult-content filter. - -Derived status (drives card/detail UI): -- **Active** → countdown to window end: green pill "6h left" -- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h" - ---- - -## 2. Screen inventory & navigation map - -``` -Tab bar: [Home] [My Apps] [Timer] - │ - ▼ - Apps screen (large title "Apps") - ├── "Blocked Apps" section - ├── "Rules >" section ──ta p "+ New"──▶ New Rule sheet - │ │ ├── tap rule-type card ─▶ Rule Editor (blank/default) - │ │ └── tap preset card ───▶ Rule Editor (pre-filled) - │ └── tap rule card ─▶ Rule Detail sheet - │ └── "Edit Rule" ─▶ Rule Editor (edit mode) - │ └── "Selected Apps >" ─▶ App Picker - └── "Apps" section (folders: Distracting / Always Allowed / Never Allowed) -``` - -All rules UI is presented as **sheets stacked over the Apps screen** (dimmed, -blurred background; grabber at top; circular ✕ or ‹ button top-left). Nothing -navigates by push except within the sheet stack. - ---- - -## 3. Screens in detail - -### 3.1 Apps screen ("My Apps" tab) - -Dark theme throughout (near-black background, very dark green tint). - -1. **Large title** "Apps". -2. **Blocked Apps** — section header; horizontal row of currently-blocked app - icons. Each icon has a lock badge overlay and a teal/green rounded-rect - outline; caption "Unblock" under the icon. Tapping unblocks (with friction - if hard mode). - *(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day - also appear here, blocked until midnight. -3. **Usage** *(OpenAppLock addition)* — a section - showing live tracking for every enabled Time/Open Limit rule scheduled today - **that is not currently blocking**. Each row has a leading kind icon and a - ` · ` subtitle (no trailing label); the type prefix keeps the - kind clear and "today" is dropped as implied: - - Time Limit row: subtitle "Time Limit · 18m of 45m used". - - Open Limit row: subtitle "Open Limit · 2 of 5 opens". - A rule whose budget is **spent** (actively blocking) **moves out of Usage into - the "Currently Blocking" section** (it shows its type + usage there instead); - a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage - reading "Paused". Usage numbers come from the shared app-group - ledger written by the DeviceActivity monitor and shield-action extensions. -3. **Rules** — header row: "Rules ›" (leading, tappable to a full list, - presumably) and "**+ New**" (trailing, green tint) which opens the New Rule - sheet. - - Content: horizontally scrolling row of **rule cards** (~2 visible). - - **Rule card** anatomy (rounded ~24pt corners): - - Top: icon pair — rule-type icon (calendar) → small arrow → shield icon. - Active rule: icons in color, card tinted dark green. Inactive: greyscale. - - Middle: status — active: green capsule pill "6h left"; inactive: plain - text "Starts in 22h". - - Bottom: rule **name** (semibold), then a sub-row "Block" + tiny cluster - of the blocked app icons. -4. **Apps** — section of folder-style groups: "Distracting (4 items)" showing - a 2×2 mini icon grid, "Always Allowed", "Never Allowed (Hidden)" with an - eye-slash glyph. (Out of scope for the rules clone but shares the app - selection model.) - -### 3.2 Rule Detail sheet - -Presented on tapping a rule card. Partial-height card sheet. - -- Top-left: circular ✕ close button. -- Centered: icon pair (rule type → shield), then caption - "`Schedule, Starts in 22h`" (type + live status), then large title - ("Weekend Zen"). -- **Detail rows** (single inset rounded card, label left / value right): - | Label | Example value | - |---|---| - | During this time | `09:00 – 12:00` | - | On these days | `Weekends` | - | Block | `[app icons] 1 App` / `3 Apps` | - | Unblocks allowed | `Yes` (hidden/`No` when Hard Mode) | -- Bottom: full-width white pill button "**✎ Edit Rule**" → morphs the sheet - into the Rule Editor in edit mode. - -### 3.3 New Rule sheet - -Presented from "+ New". Full-height sheet, scrollable. - -- Header: ✕ left, centered title "**New Rule**". -- **Rule type row** — 3 horizontally arranged cards (Schedule / Time Limit / - Open Limit), each: glyph, bold name, example caption ("e.g. 9-5, Daily", - "e.g. 45m/day", "e.g. 5 opens/day"). Tapping opens the matching editor with - defaults. -- **Preset gallery** — vertically scrolling sections, each with a bold header - + grey subtitle, containing a 2-up grid of photo-backed preset cards: - - **Focus Time** — "Protect your deep-work hours." - - Morning Focus (Schedule 08:00–11:30, Block, weekdays) - - Deep Work (Schedule 13:30–16:00, Block, weekdays) - - **Rest & Recharge** — "Wind the day down on schedule." - - Evening Reset (Schedule 21:00–23:00, Block) - - Lights Out (Schedule 23:00–06:30, Block) - - **Healthy Balance** — "Make room for what matters." - - Family Dinner (Schedule 18:00–19:30, Block) - - Screen-Free Sunday (Schedule 09:00–20:00, Block, Sundays) - - **Preset card** anatomy: full-bleed background photo, top row icon pair - (type → shield), time range caption, name, "Block" + suggested app icons, - and a circular "+" button bottom-right. Tapping anywhere opens the - Schedule editor pre-filled with the preset's name/times/days. - -> **Navigation (OpenAppLock):** picking a rule type or preset **pushes** the -> editor inside the sheet via native SwiftUI navigation (`NavigationStack` + -> `navigationDestination(item:)`), so the system push animation and -> edge-swipe-back work; the editor keeps its custom header chrome. - -### 3.4 Rule Editor — Schedule type - -Sheet with: ‹ back (top-left), centered **rule name** as title, ✎ pencil -button (top-right) to rename. - -Sections (each an inset rounded group with a small icon + caption header): - -1. **📅 During this time** - - Rows `From` / `To` with right-aligned time + stepper chevrons (`09:00 ⌃⌄`). - - A dotted vertical line with ●/○ endpoints visually links From → To. - - Tapping a row expands an inline wheel time picker (24h). -2. **On these days:** — trailing summary label ("Weekdays"/"Weekends"/custom); - row of 7 circular toggles `S M T W T F S`; selected = filled white circle - with black letter, unselected = dark circle. -3. **🛡 Apps are blocked** - - Row: `App List` → ` · N Apps ›` (or `Choose ›` when none) — - presents the App List picker. - - A segmented `Block | Allow Only` row (Schedule editor only) chooses how - the rule interprets its list; the section header reads "Apps are - blocked" / "Only these apps are allowed" accordingly. -4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing - toggle. -5. **Block Adult Content** *(OpenAppLock addition; **Schedule rules only**)* — - subtitle "Filter adult websites while this rule - is active"; trailing toggle. Maps to Screen Time's web-content filter - (`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied - and cleared together with the rule's shield. Surfaces in the rule detail - as an "Adult websites | Blocked/Allowed" row. Time Limit and Open Limit - editors do **not** offer this toggle (see §1, Per-kind options). -6. **CTA** - - Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold - interaction (deliberate friction) that fills, then saves and dismisses to - the Apps screen where the new card appears. - - Editing existing: "**✓ Done**" pill, plus a red text button - "**⏸ Disable Rule**" beneath it. - -### 3.5 Rule Editor — Time Limit type ("Time Keeper") - -Same chrome (back / title / rename). Sections: - -1. **⏳ When I use** — row `This App` → `Select ›` (app selection). -2. **For this long** — subtitle "Daily"; right-aligned value with stepper - `45m ⌃⌄`. -3. **On these days:** — identical day picker as Schedule. -4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄` - (reset point — e.g. tomorrow/next morning). -5. **Hard Mode** toggle — same as Schedule. -6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)* - -### 3.6 Rule Editor — Open Limit type - -Spec by analogy with the other editors: "When I open [apps]" / -"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" / -Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)* - -### 3.7 App Picker (shared component — also used in onboarding & timer) - -Full-height sheet: - -- Header: ‹ back, centered title "**Selected**", and a circular green **✓** - confirm button top-right. -- **Segmented control**: `Block` | `Allow Only`. -- Top rows: "**+ Add App or Website**", a "Suggested" horizontal row of app - icons (one-tap add), and a "Never Allowed — 0 Apps" row with footnote - "Never allowed Apps will also be blocked". -- Hint text: *Select apps/websites, tap ">" to expand*. -- **Category list** — each row: circular checkbox (tri-state: empty / - partially-selected count / checked), emoji glyph, category name, trailing - selected-count + chevron to expand into individual apps: - `All Apps & Categories, Social, Games, Entertainment, Creativity, Education, - Health & Fitness, Information & Reading, Productivity & Finance, - Shopping & Food`. -- **Search bar** pinned near bottom (with mic). Typing surfaces app matches - and website suggestions (e.g. typing "insta" offers `instagram.com`), - letting users add arbitrary domains. -- Footer: "**N Apps Selected**" caption + white pill "**Save**" (+ "Cancel"). - -> Implementation note: On iOS, third parties cannot enumerate installed apps; -> the system-sanctioned route is `FamilyActivityPicker` (FamilyControls), which -> provides its own category/app/website UI and returns opaque tokens. **v1 of -> OpenAppLock embeds `FamilyActivityPicker`** rather than a custom app picker. -> -> **App Lists (OpenAppLock):** the selection itself lives on a reusable -> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`). -> The editor's App List row presents a picker sheet listing saved lists -> (checkmark on the rule's current list; tap to select), an Edit affordance -> per list, and a "New List" flow — a name field plus an embedded -> `FamilyActivityPicker`. The `Block`/`Allow Only` segmented control lives in -> the Schedule rule editor (it is rule state, not list state). Legacy rules -> that stored an inline selection are migrated at launch: one list per -> distinct selection (rules sharing identical selection data share a list), -> named " Apps". Lists in use by a rule cannot be deleted from the -> picker. While any **Hard Mode** rule is actively blocking, all lists are -> read-only — the picker hides Edit/Delete and shows a lock notice — because -> editing a list would be a back door out of the hard block. Creating new -> lists and selecting lists for other rules remain available. - ---- - -## 4. Behavioral spec - -1. **Activation** — a Schedule rule becomes active at `From` on an enabled - day and deactivates at `To` (windows crossing midnight, e.g. 23:00–06:30, - must be supported — Lights Out preset does this). -2. **While active** — the rule's app selection is shielded (and, when the - rule's Block Adult Content toggle is on, Screen Time's adult-website - filter is engaged for the same span); blocked apps also - surface in the "Blocked Apps" row on the Apps screen; the card turns green - with a "Xh left" pill. -3. **Unblocking** — with Hard Mode off, the user may unblock mid-window - ("Unblocks allowed: Yes"). With Hard Mode on, no unblocks until the window - ends. -4. **Time-limit rules** — accumulate usage daily across the selected apps; - on crossing the threshold, shield until the `Until` reset point - (e.g. tomorrow), then reset the budget. - *(OpenAppLock specifics)*: usage lives in a per-rule, per-day **usage - ledger** in the app group. A limit rule's derived status becomes - `active(until: next midnight)` once the ledger reports the budget spent on - an enabled day — it then surfaces in Blocked Apps, Hard Mode gating - applies, and a soft unblock pauses it until midnight. Open-limit rules - work the same with an opens budget; while opens remain, their apps stay - shielded with an "Open" button on the shield (each press spends one open - and lifts the shield for up to 15 minutes — the DeviceActivity minimum - interval). Because the shield is what *counts* opens, an enabled open-limit - rule scheduled today is shielded **proactively from the start of the day, - even before any opens are spent** — by *both* the background - (`LimitEnforcement.handleDayStart`) and the foreground - (`RuleEnforcer.refresh`) paths, so a freshly created open-limit rule gates - its apps immediately and the gate survives the app being foregrounded. The - one exception is a **granted "Open" session**: pressing Open lifts the - shield for ~15 minutes, recorded as an expiry in the shared - `OpenSessionStore`; while that session is live, neither path re-shields the - rule (so the sanctioned session is never cut short), and the monitor - re-shields when the session's one-shot activity ends. Unlike a *spent* - budget, this proactive gate does **not** put the rule in "Blocked Apps" - (which lists only rules whose budget is exhausted); it shows under "Usage" - with its remaining opens. -5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the - rule (the card shows a disabled state). Delete is offered from the rule - editor's actions menu. -6. **Commit friction** — creating/committing a rule uses press-and-hold - ("Hold to Commit"), making the *start* of a commitment deliberate. Editing - uses a plain "Done". -7. **Live countdowns** — "Starts in 22h" / "6h left" update over time - (minute granularity is fine). -8. **Overlapping rules — strictest enforcement wins.** When several rules - target the same app, the app is blocked if **any** of them is currently - blocking it; rules never cancel each other out. This is structural rather - than a resolved decision: each rule owns its own `ManagedSettingsStore` - (`rule-`), Screen Time **unions** shields across all stores, and a - rule only ever writes/clears *its own* store. Consequences: - - An open-limit and a time-limit rule on the same app each block via their - own store, so whichever's budget is spent **first** blocks the app, - regardless of the other's remaining budget. - - An **Allow-Only** schedule cannot punch a hole for an app that another - rule blocks: `.all(except:)` is itself a *shield* directive ("block - everything except these"), not a whitelist that lifts other stores' - shields. So if a schedule "allows" an app but a time limit blocks it, the - time-limit block stands. - - A soft **unblock** pauses only the one rule it was invoked on; other rules - blocking the same app keep it blocked. - - There is deliberately **no** central merge of selections into a single - shield set — such a merge would be the only place a block could be - accidentally dropped. - ---- - -## 5. Implementation plan for OpenAppLock - -### 5.1 Frameworks & capabilities - -- **FamilyControls** — `AuthorizationCenter.shared.requestAuthorization(for: .individual)`; - `FamilyActivityPicker` + `FamilyActivitySelection` (app/category/web tokens). -- **ManagedSettings** — `ManagedSettingsStore` per rule - (`ManagedSettingsStore.Name("rule-")`); set - `store.shield.applications` / `applicationCategories` / `webDomains`. -- **DeviceActivity** — `DeviceActivityCenter.startMonitoring` with a - `DeviceActivitySchedule(intervalStart:intervalEnd:repeats:)` per rule; - a **DeviceActivityMonitor app extension** applies/removes shields in - `intervalDidStart`/`intervalDidEnd`. Time-limit rules use - `DeviceActivityEvent(applications:threshold:)` + - `eventDidReachThreshold`. -- Requires the **Family Controls entitlement** (works in dev; distribution - needs Apple approval) and an App Group to share rule data with the monitor - extension. - -### 5.2 Data model (SwiftData) - -The domain currency is a **sum type** so a rule can only hold the options that -belong to its kind — illegal states (e.g. an Open Limit rule with a time -window, or a Time Limit rule with Block Adult Content) are unrepresentable: - -```swift -enum RuleKind: String, Codable { case schedule, timeLimit, openLimit } -enum SelectionMode: String, Codable { case block, allowOnly } - -enum RuleConfiguration: Hashable, Sendable { - case schedule(ScheduleConfig) - case timeLimit(TimeLimitConfig) - case openLimit(OpenLimitConfig) - var kind: RuleKind { … } -} - -struct ScheduleConfig: Hashable, Sendable { // Schedule-only options - var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00 - var endMinutes: Int // may be ≤ start (crosses midnight) - var selectionMode: SelectionMode - var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...) -} -struct TimeLimitConfig: Hashable, Sendable { var dailyLimitMinutes: Int } -struct OpenLimitConfig: Hashable, Sendable { var maxOpens: Int } -``` - -The kind-common attributes (`name`, `days`, `hardMode`, `isEnabled`, -`appList`, `pausedUntil`) live alongside the configuration: - -```swift -@Model final class BlockingRule { - var id: UUID - var name: String - var isEnabled: Bool - var hardMode: Bool - var appList: AppList? - var days: [Int] // 1...7, Calendar weekday numbers - var pausedUntil: Date? - var createdAt: Date - // The kind-specific options, exposed as a computed bridge over the - // model's raw stored columns: - var configuration: RuleConfiguration { get set } - var kind: RuleKind { configuration.kind } -} -``` - -`RuleDraft` (the editors' value-type working copy) carries the same -`configuration` + common fields, so each editor only renders its kind's -options. `BlockingRule` persists the configuration as flat columns and the -cross-process `RuleSnapshot` mirror keeps its flat wire shape; both are -read/written exclusively through the sum type. `FamilyActivitySelection` is -`Codable` → stored on the `AppList` as `Data`. Status ("active", "starts in -Xh", "Xh left") is **derived**, not stored. - -### 5.3 View inventory - -| View | Notes | -|---|---| -| `AppsView` (tab) | Sections: Blocked Apps, Rules carousel, (later) app folders | -| `RuleCardView` | Card per §3.1, active/inactive styling | -| `RuleDetailSheet` | §3.2, rows + Edit Rule | -| `NewRuleSheet` | §3.3, type cards + preset gallery (`RulePreset` static data) | -| `ScheduleRuleEditor` | §3.4 | -| `TimeLimitRuleEditor` | §3.5 | -| `DayOfWeekPicker` | 7 circle toggles + summary ("Weekdays"/"Weekends"/…) | -| `AppSelectionView` | wraps `FamilyActivityPicker`, Block/Allow Only segmented control | -| `HoldToCommitButton` | long-press progress fill, haptics, fires on completion | -| `RuleScheduler` (service) | translates `BlockingRule` ⇄ DeviceActivity monitoring | -| `ShieldController` (service) | applies/clears `ManagedSettingsStore` shields | - -### 5.4 Suggested build order - -1. Data model + Apps tab with Rules section (cards from seeded sample rules, - status derivation, detail sheet) — pure UI, no entitlements needed. -2. New Rule sheet + Schedule editor + day picker + Hold to Commit (CRUD into - SwiftData; Disable/Done editing path). -3. FamilyControls authorization + `FamilyActivityPicker` integration - ("Selected Apps" row, "N Apps" counts, icon clusters via `Label(token:)`). -4. DeviceActivity monitor extension + ManagedSettings shields (real blocking, - incl. midnight-crossing windows). -5. Time Limit editor + threshold events; Open Limit last (needs shield - action extension + open counting). -6. Preset gallery content + polish (gradients, photos, haptics, live - countdown timers). - -### 5.5 Background enforcement architecture (implemented) - -- **App group** `group.dev.bchen.OpenAppLock` shares four stores between the - app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written - by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule, - per-day minutes/opens), `OpenSessionStore` (per-rule expiry of a granted - "Open" session), and the shield-store tracking list. -- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the - enabled rules: - - **Limit rules** — one repeating 00:00–23:59 activity per rule - (`rule-`); time-limit rules carry one cumulative usage-threshold - event per budget minute (`minutes-`) over the rule's app list. - - **Schedule rules** — one (or, for windows that cross midnight, two) - repeating window activit(ies) per rule matching the rule's - `From…To` window (`sched-` and, for the post-midnight half, - `sched2-`). These carry no events; they exist purely to wake the - monitor at the window edges so shields engage **in the background even - when the app is closed**. A window that ends exactly at midnight, or is - shorter than DeviceActivity's 15-minute minimum interval, may fail to - register (`intervalTooShort`) and falls back to the foreground loop. - Activities restart only when their configuration changes, because a - restart resets threshold accounting. The change-detection fingerprint must - be **process-stable** (the app selection is hashed with SHA-256, never - `Data.hashValue`, which is seeded randomly per launch and would otherwise - restart — and reset — every limit activity on each launch). -- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start - = midnight reset for limit rules (open-limit rules re-shield so opens can - be counted; time-limit shields clear for the fresh budget); each - `minutes-` event records usage and shields at the budget — **but a - checkpoint whose minute count exceeds the minutes elapsed since local - midnight is dropped**, since it cannot be today's usage (it is yesterday's - spent budget delivered late across midnight, which would otherwise re-block - unused apps); a finished `open-session-` one-shot re-shields after a - granted open. For - schedule-window activities (`sched-`/`sched2-`), **both** interval start - and interval end **recompute** the rule's live schedule state from its - snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the - midnight-crossing rule) and apply or clear the shield accordingly — the - same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths - agree. -- **Reliability posture** — DeviceActivity interval callbacks are - "first device use after the boundary", are known to fire late or be - skipped (device asleep, OS regressions on iOS 17/18/26), and a shield - written over an app the user already has open may not visibly engage until - that app is relaunched (a long-standing Screen Time platform limitation). - Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh` - (launch + 30 s foreground loop) is retained as the reconciliation safety - net and is the source of truth whenever the app runs. To keep that net - consistent with the background, `refresh` applies the **same** open-limit - proactive gate as `handleDayStart`: an enabled, scheduled-today, un-paused - open-limit rule is shielded even before its budget is spent, *unless* the - `OpenSessionStore` reports a still-running granted open for it — so the - foreground loop establishes the turnstile for newly created rules and never - re-locks an app mid-session. -- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield - carries the same generic **"App Blocked"** title — rule names are never shown, - since the rule a shield is attributed to cannot be determined reliably when - several rules cover the same app. Open-limit shields keep their functional - detail under that title ("Opened X of N times today" with an "Open (Y left)" - secondary button while opens remain); all other shields just read "This app is - blocked by OpenAppLock." The text-only decision lives in the pure, unit-tested - `ShieldPresentation` (in `Shared/`). -- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press - spends one open in the ledger, lifts the rule's shield, records the session - expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session - (DeviceActivity's minimum interval); the monitor clears that record when the - session ends. -- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit - tested from the app test target. - -### 5.6 Out of scope (not part of "rules") - -- Onboarding flow, paywall, Home tab gem/score UI, Timer tab (one-off focus - sessions, "Leave Early?" friction screen), notification nudges ("Complete - Your Setup"). - ---- - -## 6. Native UI re-skin (current presentation) - -OpenAppLock has since replaced its custom themed presentation with the bare -iOS design language, keeping the backend (models, logic, services), the -flows, and the accessibility identifiers intact. Sections 1–5 remain as the -spec for *what* the feature does; presentation now maps as follows. - -After onboarding the app is an **adaptive shell** (`MainView`) holding the same -three sections (Home / Rules / Settings), each its own `NavigationStack`. The -navigation chrome is chosen from the horizontal size class -(`MainLayout.resolve`), reusing identical section views in both layouts: - -- **Compact width** (iPhone, and iPad multitasking / Slide Over): a bottom - `TabView` (`MainTabView`). -- **Regular width** (full-screen iPad): a left sidebar `NavigationSplitView` - (`MainSidebarView`) — sections listed in the sidebar (`sidebarItem-
`), - the selected one filling the detail column. This is the iPad-idiomatic - presentation; a bottom tab bar is an iPhone idiom (Apple HIG). - -``` -MainView (adaptive): compact ─▶ TabView [Home] [Rules] [Settings] (bottom tabs) - regular ─▶ NavigationSplitView sidebar │ detail (left sidebar) - │ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode) + "About" GitHub / Website links - │ └── rules grouped into Schedule / Time Limit / Open Limit sections; "+" ─▶ New Rule sheet - │ └── tap a rule row ─▶ Rule Detail sheet ─▶ "Edit Rule" ─▶ Rule Editor - └── "Currently Blocking" section + "Usage" section -``` - -Section labels and icons come from one source of truth, the `AppSection` enum, -so the tab bar and the sidebar can't drift. The app-level **enforcement -lifecycle** (the `enforcer.refresh` 30 s loop, the rule-change reconcile, and a -scene-active reconcile) lives on `MainView`, so it runs regardless of the active -layout or selected section. - -| Spec element | Native presentation | -|---|---| -| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a ` · ` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). | -| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-` identifier lives on this subtitle); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | -| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). An **About** section holds outbound `Link` rows — **GitHub** and **Website** — each shown only when its destination is configured (see §6.2). | -| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | -| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | -| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule | -| Onboarding / app picker | System styling, `.borderedProminent` buttons, default color scheme (no forced dark, default accent) | - -Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`, -icon-pair/circle-button chrome. - -### 6.1 Uninstall Protection (Settings) - -A device-wide opt-in that makes Hard Mode harder to escape: while it is on **and** -any Hard Mode rule is actively blocking, the user cannot delete apps from the -device (so the block can't be removed by uninstalling OpenAppLock itself). The -decision (= setting on AND any rule actively blocking with Hard Mode) is applied -through `ShieldApplying.setAppRemovalDenied`, which sets -`ManagedSettingsStore(named: "uninstall-protection").application.denyAppRemoval` -(`true` to engage, `nil` to relinquish) on a **dedicated** store so per-rule -shield clears never touch it. The setting persists in the app-group defaults -under `AppGroup.uninstallProtectionKey` (`"uninstallProtectionEnabled"`), readable -by both the app and the extensions. - -Recomputed on **both** enforcement paths so it stays correct whether the app is -open or not: -- **Foreground** — `RuleEnforcer.refresh` evaluates - `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` over the live - `BlockingRule`s (launch + 30 s loop + rule change + scene-active). -- **Background** — the DeviceActivity monitor extension (interval start/end, - usage threshold) and the ShieldAction extension (after a granted open) call - `UninstallProtectionEnforcer.reconcile()`, which evaluates the snapshot mirror - `UninstallProtectionPolicy.shouldDenyAppRemoval(snapshots:enabled:usageFor:)` - over the `RuleSnapshot`s in the app group. `UninstallProtectionPolicy` mirrors - `RulePolicy`'s active/hard-locked semantics exactly (a unit test asserts parity), - so the two paths never disagree. This closes the prior v1 gap where a Hard Mode - window that started or ended while the app was closed left protection out of sync. - -The toggle is **fully locked while any Hard Mode rule is actively blocking**: -`SettingsView` replaces the switch with a trailing red `lock.fill` -(`uninstallProtectionLockIcon`, mirroring the Home "Currently Blocking" treatment) -and shows the `uninstallProtectionLockedNotice` footer; the gate is -`RulePolicy.canToggleUninstallProtection(rules:usageFor:)` (= no rule -`isHardLocked`). It can't be turned off (or on) mid-block — turning it off would be -an escape hatch. - -Like all Screen Time behavior, the real device effect is only observable on a -device (the simulator uses mock shields and delivers no DeviceActivity callbacks). - -### 6.2 About links (Settings) - -The Settings "About" section offers two outbound `Link` rows — **GitHub** -(`githubLinkButton`) and **Website** (`websiteLinkButton`) — that open the -project's repository and marketing site in the browser. - -Both destinations are **configuration, not code**. They come from the -`OAL_GITHUB_URL` / `OAL_WEBSITE_URL` user-defined build settings in -`Config/Shared.xcconfig`, ride into the app through `OpenAppLock/Info.plist` -(keys `OALGitHubURL` / `OALWebsiteURL` — custom keys can't be injected into a -*generated* Info.plist, so a real partial plist carries them), and are read at -runtime through the single accessor `AppLinks`. Point them at the real links by -editing the build settings; the website value is a placeholder until the site -exists. A row whose URL is unset, blank, or unparseable is omitted, so the -section is empty (and hidden) only when neither link is configured. - -`AppLinks.url(from:)` is the pure parse seam (trims, rejects blank/non-string); -unit tests cover it and the full build-setting → Info.plist pipeline. A UI test -asserts each button opens the configured URL by intercepting the `openURL` -action in UI-testing mode (so no browser launches) — the launch arguments -`-github-url=` / `-website-url=` (parsed by `LaunchConfiguration`) feed it -deterministic URLs decoupled from the committed build settings. diff --git a/Docs/AGENT_SWIFT_GUIDELINES.md b/Docs/AGENT_SWIFT_GUIDELINES.md index cb5b1b2..8f1eeec 100644 --- a/Docs/AGENT_SWIFT_GUIDELINES.md +++ b/Docs/AGENT_SWIFT_GUIDELINES.md @@ -118,7 +118,8 @@ swift test --enable-code-coverage ### Workflow -Red-green TDD: update `Docs/AGENT_RULES_FEATURE_SPEC.md` first for behavior changes, +Red-green TDD: update the feature spec first for behavior changes — the doc +comment on the file you're changing (mapped from `Docs/RULES_SPEC.md`) — then write the failing test, run it (a compile failure counts as red), implement, re-run focused tests, then the full suite. Run tests often and fail fast. diff --git a/Docs/RULES_SPEC.md b/Docs/RULES_SPEC.md new file mode 100644 index 0000000..48904aa --- /dev/null +++ b/Docs/RULES_SPEC.md @@ -0,0 +1,205 @@ +# OpenAppLock — Rules feature spec + +This is the spec for OpenAppLock's recurring app-blocking **rules**. The detailed +behavioral and implementation prose now lives as **doc comments on the source it +describes** — open the file named for a topic below and the `///` comment at the +top of its primary type *is* the spec for that piece. This file is the **map**: +it holds the concept overview, the navigation/screen tree, the cross-cutting +invariants that have no single owning file, and a topic → source index so you can +jump straight to the code (and its spec) for any part of the feature. + +> **Maintaining this spec.** It is human-owned and co-maintained by humans and +> agents (see [AGENTS.md](../AGENTS.md) → Documentation). When you change a +> behavior, update the doc comment on the file that owns it — that *is* the spec +> now — in the **same commit** as the code. Adjust the map below if a topic moves +> to a different file. + +--- + +## §1 Concept + +A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off +block/timer session, a rule re-arms itself on a schedule. Three kinds exist: + +| Kind | Example | Semantics | +|------|---------|-----------| +| **Schedule** | "9–5, Daily" | Block selected apps during a daily time window on chosen days | +| **Time Limit** | "45m/day" | After N minutes of cumulative use per day, block until a reset point | +| **Open Limit** | "5 opens/day" | After N opens per day, block until a reset point | + +**Common to every kind** — a user-editable **name**, a **days-of-week** set, an +**App List** (a named, reusable selection of apps/categories/websites that a rule +points at; editing a list affects every rule that uses it), **Hard Mode** (while +the rule is actively blocking it cannot be lifted, edited, or deleted), and an +**enabled/disabled** flag. + +**Per-kind options** are modelled as a sum type so a rule can only hold the +options that belong to its kind — illegal states are unrepresentable: + +- **Schedule** — a recurring time **window** (may cross midnight), a **selection + mode** (**Block** the list, or **Allow Only** = block everything except it), + and **Block Adult Content** (Screen Time's adult-website filter). +- **Time Limit** — a daily **minutes** budget. +- **Open Limit** — a daily **opens** budget. + +Selection mode and Block Adult Content are **Schedule-only** (rationale in +`RuleConfiguration`). Status ("6h left", "Starts in 22h", live usage) is always +**derived**, never stored. + +→ Documented in: `Shared/RuleConfiguration.swift`, `Shared/RuleKind.swift`, +`OpenAppLock/Models/BlockingRule.swift`, `OpenAppLock/Models/RuleDraft.swift`, +`OpenAppLock/Logic/RuleStatus.swift`. + +--- + +## §2 Navigation & screens (current native presentation) + +After onboarding the app is an **adaptive shell** (`MainView`): a bottom +`TabView` in compact width (iPhone, iPad multitasking / Slide Over) and a +left-sidebar `NavigationSplitView` in regular width (full-screen iPad). Section +labels and icons come from a single `AppSection` enum so the two layouts can't +drift. + +``` +MainView (adaptive) source +├── Home "Currently Blocking" + "Usage" HomeView +├── Rules rules grouped Schedule / Time Limit / RulesListView +│ Open Limit; "+" opens New Rule ├─ NewRuleSheet → RuleEditorView +│ tap a rule opens its detail └─ RuleDetailSheet → RuleEditorView +└── Settings Uninstall Protection, Manage App Lists, SettingsView + About links └─ ManageAppListsView → AppListLibraryView +``` + +App pickers embed the system `FamilyActivityPicker` (third parties cannot +enumerate installed apps). The enforcement lifecycle — a 30 s refresh loop plus a +reconcile on rule changes and on scene-active — lives on `MainView`, so it runs +in either layout. + +--- + +## Where each topic is documented + +Each row points to the file whose doc comment holds the spec for that topic. + +| Topic | Source (doc comment) | +|---|---| +| Rule kinds, sum-type options, Schedule-only rationale | `Shared/RuleConfiguration.swift`, `Shared/RuleKind.swift` | +| Persisted rule + common attributes | `OpenAppLock/Models/BlockingRule.swift` | +| Editor working copy (draft) | `OpenAppLock/Models/RuleDraft.swift` | +| Cross-process rule mirror | `Shared/RuleSnapshot.swift` | +| Derived status & countdown labels | `OpenAppLock/Logic/RuleStatus.swift` | +| Day-of-week picker & summary | `OpenAppLock/Views/Components/DayOfWeekPicker.swift`, `Shared/Weekday.swift` | +| Preset gallery | `OpenAppLock/Models/RulePreset.swift`, `OpenAppLock/Views/Rules/NewRuleSheet.swift` | +| Rule editors (all three kinds) | `OpenAppLock/Views/Rules/RuleEditorView.swift` | +| Rule detail sheet | `OpenAppLock/Views/Rules/RuleDetailSheet.swift` | +| App lists (model, picker, library, edit) + legacy migration | `OpenAppLock/Models/AppList.swift`, `OpenAppLock/Views/AppLists/*`, `OpenAppLock/Services/AppListMigration.swift` | +| Home: Currently Blocking + Usage, row strings | `OpenAppLock/Views/Home/HomeView.swift`, `OpenAppLock/Logic/UsageDisplay.swift` | +| Schedule activation / time-window math (incl. midnight crossing) | `Shared/RuleSchedule.swift`, `Shared/ScheduleEnforcement.swift` | +| Unblock / disable / delete / Hard Mode gating | `OpenAppLock/Logic/RulePolicy.swift` | +| Foreground shield reconciliation (source of truth while open) | `OpenAppLock/Services/RuleEnforcer.swift` | +| Time/open-limit behavior, granted opens, proactive gate | `Shared/LimitEnforcement.swift`, `Shared/UsageLedger.swift`, `Shared/OpenSessionStore.swift` | +| Shield application (per-rule `ManagedSettingsStore`) | `Shared/ShieldController.swift` | +| Shield text (never names a rule) + "Open" button | `Shared/ShieldPresentation.swift`, `OpenAppLockShieldConfig/ShieldConfigurationExtension.swift` | +| "Open" press handling | `OpenAppLockShieldAction/ShieldActionExtension.swift` | +| DeviceActivity scheduling, activity/event naming | `OpenAppLock/Services/RuleScheduler.swift`, `Shared/MonitoringPlan.swift` | +| Background monitor reactions (interval edges, thresholds) | `OpenAppLockMonitor/DeviceActivityMonitorExtension.swift` | +| Uninstall Protection (§6.1) | `OpenAppLock/Views/Settings/SettingsView.swift`, `Shared/UninstallProtectionPolicy.swift`, `Shared/UninstallProtectionEnforcer.swift`, `OpenAppLock/Services/AppSettings.swift` | +| About links — GitHub / Website (§6.2) | `OpenAppLock/Services/AppLinks.swift`, `OpenAppLock/Services/LaunchConfiguration.swift` | + +--- + +## §4.8 Overlapping rules — strictest enforcement wins + +When several rules target the same app, the app is blocked if **any** of them is +currently blocking it; rules never cancel each other out. This is structural +rather than a resolved decision: each rule owns its own `ManagedSettingsStore` +(`rule-`), Screen Time **unions** shields across all stores, and a rule +only ever writes/clears *its own* store. Consequences: + +- An open-limit and a time-limit rule on the same app each block via their own + store, so whichever's budget is spent **first** blocks the app, regardless of + the other's remaining budget. +- An **Allow-Only** schedule cannot punch a hole for an app that another rule + blocks: `.all(except:)` is itself a *shield* directive ("block everything + except these"), not a whitelist that lifts other stores' shields. So if a + schedule "allows" an app but a time limit blocks it, the time-limit block + stands. +- A soft **unblock** pauses only the one rule it was invoked on; other rules + blocking the same app keep it blocked. + +There is deliberately **no** central merge of selections into a single shield +set — such a merge would be the only place a block could be accidentally dropped. +The invariant is exercised by `OpenAppLockTests/RuleEnforcerTests.swift`. + +--- + +## Background enforcement architecture + +**Frameworks & capabilities.** FamilyControls (authorization + +`FamilyActivityPicker` / `FamilyActivitySelection` opaque tokens), ManagedSettings +(one `ManagedSettingsStore` per rule, `rule-`; a dedicated +`uninstall-protection` store), and DeviceActivity (a per-rule schedule registered +by `RuleScheduler`; time limits add `DeviceActivityEvent` usage thresholds). +Requires the **Family Controls entitlement** (works in dev; distribution needs +Apple approval for the app and each extension bundle id) and the **App Group** +`group.dev.bchen.OpenAppLock`, which carries `RuleSnapshotStore` (rule mirror), +`UsageLedger` (per-day minutes/opens), `OpenSessionStore` (granted-open expiry), +and the shield-store tracking list. + +**Two enforcement paths that must agree.** + +- **Foreground** — `RuleEnforcer.refresh` (launch + 30 s loop + rule change + + scene-active) recomputes every shield from scratch and is the source of truth + while the app runs. +- **Background** — `RuleScheduler` keeps DeviceActivity monitoring in step; + `OpenAppLockMonitor` reacts at interval start/end and usage thresholds; + `LimitEnforcement` and `ScheduleEnforcement` hold the shared reactions so both + paths converge on the same shield state. + +**Reliability posture.** DeviceActivity interval callbacks fire on "first device +use after the boundary", are known to arrive late or be skipped (device asleep, +OS regressions across iOS 17/18/26), and a shield written over an app the user +already has open may not visibly engage until that app is relaunched (a +long-standing Screen Time limitation). Background monitoring is therefore +**best-effort**, with the foreground loop as the reconciliation safety net. Real +Screen Time effects are only observable **on a device** — the simulator uses mock +shields and delivers no DeviceActivity callbacks. + +--- + +## Out of scope (not part of the rules feature) + +Paywall, the Home gem/score UI, a Timer tab (one-off focus sessions with a +"Leave Early?" friction screen), and notification nudges. The onboarding / +Screen Time permission flow exists (`OpenAppLock/Views/Onboarding/`) but is not +part of this spec. + +--- + +## Appendix — original custom-design reference (pre-reskin, historical) + +OpenAppLock originally shipped a **custom themed presentation** (near-black +background, dark-green tint) that has since been replaced by the bare native iOS +design language above; the backend, flows, and accessibility identifiers were +kept. This appendix records the original design intent so it isn't lost (the full +pre-rename document is in git history as `Docs/AGENT_RULES_FEATURE_SPEC.md`): + +- **Tab bar** `[Home] [My Apps] [Timer]`; the rules lived on an "Apps" screen + with a **Blocked Apps** row, a horizontally-scrolling **rules carousel** of + rounded **rule cards** (rule-type icon → shield icon, a green status pill, name + + blocked-app cluster), and folder-style app groups (Distracting / Always + Allowed / Never Allowed). +- **Rules UI as stacked sheets** over the Apps screen (grabber, circular ✕/‹), + rather than the current native push navigation. +- **New Rule sheet** with three rule-type cards and a **preset gallery** of + photo-backed cards (Morning Focus, Deep Work, Evening Reset, Lights Out, Family + Dinner, Screen-Free Sunday) — now plain list rows. +- **Schedule editor** with a dotted From→To connector and inline wheel time + pickers; a custom **App Picker** with a tri-state category list and a bottom + search bar (now the system `FamilyActivityPicker`). +- **"Hold to Commit"** — a press-and-hold gradient button that added deliberate + friction to *creating* a rule (editing used a plain "Done"). Replaced by a + navigation-bar checkmark. + +Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`, and the +icon-pair / circle-button chrome. diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index 5d54b50..7d790b9 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -105,7 +105,7 @@ struct RuleDetailSheet: View { row("During this time", rule.schedule.timeRangeLabel) row("On these days", rule.days.summary) row(config.selectionMode.displayName, appCountLabel) - // Adult websites is a Schedule-only option (see spec §1). + // Adult websites is a Schedule-only option (see `RuleConfiguration`). row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed") row("Unblocks allowed", rule.hardMode ? "No" : "Yes") case .timeLimit(let config): diff --git a/OpenAppLock/Views/Rules/RuleEditorView.swift b/OpenAppLock/Views/Rules/RuleEditorView.swift index 68ac55f..43cacee 100644 --- a/OpenAppLock/Views/Rules/RuleEditorView.swift +++ b/OpenAppLock/Views/Rules/RuleEditorView.swift @@ -127,7 +127,7 @@ struct RuleEditorView: View { } hardModeSection // Block Adult Content is a Schedule-only option: a usage budget - // does not pair with a web-content filter (see spec §1). + // does not pair with a web-content filter (see `RuleConfiguration`). adultContentSection case .timeLimit: Section { diff --git a/Shared/RuleConfiguration.swift b/Shared/RuleConfiguration.swift index f4509cc..3f6b74d 100644 --- a/Shared/RuleConfiguration.swift +++ b/Shared/RuleConfiguration.swift @@ -9,7 +9,11 @@ import Foundation /// only carry the options that belong to its kind. This makes illegal states /// unrepresentable: an Open Limit rule cannot hold a time window, a Time Limit /// rule cannot hold a Block/Allow-Only mode, and neither limit kind can hold -/// Block Adult Content — those are Schedule-only options. +/// Block Adult Content — those are Schedule-only options. The reasoning: a +/// usage budget over "everything except X" (Allow Only) is not meaningful, and +/// engaging a web-content filter because a *usage* budget is spent does not fit +/// the feature. Selection mode also belongs to the *rule*, not its app list, so +/// it lives in `ScheduleConfig` rather than on `AppList`. /// /// Kind-common attributes (name, days, hardMode, isEnabled, appList, /// pausedUntil) live on the owning `BlockingRule` / `RuleDraft`, not here. diff --git a/Shared/ShieldController.swift b/Shared/ShieldController.swift index d2bd14e..03ea4a1 100644 --- a/Shared/ShieldController.swift +++ b/Shared/ShieldController.swift @@ -50,6 +50,10 @@ final class ManagedSettingsShieldController: ShieldApplying { store.shield.webDomains = selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens case .allowOnly: + // `.all(except:)` is itself a *shield* directive ("block everything + // except these"), not a whitelist — it cannot lift another store's + // shield on a shared app. So an Allow-Only rule never punches a hole + // through a block another rule applies (spec §4.8, strictest wins). store.shield.applicationCategories = .all(except: selection.applicationTokens) store.shield.webDomainCategories = .all(except: selection.webDomainTokens) } From 430945aa2e7a60a3f0bd07560c7f25b61afa3b5f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:16:55 -0400 Subject: [PATCH 2/2] docs: move the residual spec map from Docs into AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the standalone Docs/RULES_SPEC.md into AGENTS.md as a "Rules feature map" section — AGENTS.md is the thin agent directory, so the spec's un-foldable residual (concept pointer, screen tree, topic → source table, out-of-scope) belongs there rather than in a parallel Docs file. The §4.8 "strictest enforcement wins" invariant goes the other way — into the RuleEnforcer doc comment that owns it — so the only remaining cross-cutting prose is the map itself. Removes Docs/RULES_SPEC.md; points CLAUDE.md and AGENT_SWIFT_GUIDELINES.md at the map; drops the now-dangling "spec §4.8" references in RuleEnforcer / ShieldController / RuleEnforcerTests (the invariant now reads self-contained in RuleEnforcer). The pre-reskin design remains recoverable from git history. Build green; 240/240 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012E73uBVKkqL25RJrF7tfmm --- AGENTS.md | 70 ++++++-- CLAUDE.md | 2 +- Docs/AGENT_SWIFT_GUIDELINES.md | 7 +- Docs/RULES_SPEC.md | 205 ----------------------- OpenAppLock/Services/RuleEnforcer.swift | 16 +- OpenAppLockTests/RuleEnforcerTests.swift | 6 +- Shared/ShieldController.swift | 3 +- 7 files changed, 75 insertions(+), 234 deletions(-) delete mode 100644 Docs/RULES_SPEC.md diff --git a/AGENTS.md b/AGENTS.md index 3ce652d..ef8103b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData) AppListMigration, LaunchConfiguration + SampleRules (UI-test harness) Views/ Native SwiftUI screens (spec in each view's doc - comment; see Docs/RULES_SPEC.md §2) + comment; see "Rules feature map" below) Shared/ Compiled into the app AND all three extensions: RuleKind, Weekday, RuleSchedule, AppGroup, UsageLedger (per-day minutes/opens), @@ -40,12 +40,6 @@ OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open, OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app target defaults to MainActor isolation) OpenAppLockUITests/ XCUITest flows (see harness below) -Docs/RULES_SPEC.md Rules feature spec index: concept, navigation map, - cross-cutting invariants, and a topic → source map. - The detailed spec lives as doc comments on the source - each topic owns — review BEFORE behavior changes, keep - current after them, in the same commit (human-owned, - co-maintained; see Documentation). Docs/AGENT_SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards agents must follow on this project (agent-managed). @@ -59,19 +53,17 @@ Documentation falls into three buckets: prefixed with `AGENT_` (currently `Docs/AGENT_SWIFT_GUIDELINES.md`). Agents may **read, create, and edit** these and are expected to keep them accurate. - **Shared (human + agent)** — the rules feature spec. It lives as doc comments - **on the source each behavior owns**, with `Docs/RULES_SPEC.md` as a - human-owned index/map. The doc comments are the source of truth for behavior; - both humans and agents maintain them. When you change a behavior, update the - owning file's doc comment in the same commit (and the `RULES_SPEC.md` map if a - topic moves to a different file). + **on the source each behavior owns**; both humans and agents maintain it. The + doc comments are the source of truth for behavior — when you change a behavior, + update the owning file's doc comment in the same commit. The "Rules feature + map" section below indexes where each topic lives; keep it current when a topic + moves to a different file. - **Human-authored** — every other doc, e.g. `README.md`. Agents may **read** these for context but must **never create or modify** them; flag needed changes for the maintainer instead. -The `AGENT_` prefix still marks a file as safe for agents to maintain. -`RULES_SPEC.md` is deliberately un-prefixed but explicitly shared, so agents may -update it (and the code doc comments it maps to) as part of a behavior change. -Any *other* un-prefixed doc remains off-limits to agent edits. +The `AGENT_` prefix marks a file as safe for agents to maintain; any other +un-prefixed doc remains off-limits to agent edits. ## Domain facts worth knowing @@ -92,6 +84,50 @@ Any *other* un-prefixed doc remains off-limits to agent edits. shell (`MainView`) runs it on rule changes and a 30s loop while the app is open, regardless of the active layout (compact `TabView` vs regular-width sidebar). +## Rules feature map + +The feature behaves as documented in `///` doc comments **on the source each +topic owns** — this section is the map to them, not a second copy of the spec. +Concept and per-kind options live in `RuleConfiguration` / `RuleKind` / +`BlockingRule`; the load-bearing invariants are in "Domain facts" above. + +Screens — post-onboarding adaptive shell (`MainView`: a tab bar in compact +width, a sidebar in regular-width iPad; section labels from one `AppSection`): + +``` +Home Currently Blocking + Usage HomeView +Rules rules grouped by kind; + opens New Rule RulesListView + New Rule → editor NewRuleSheet → RuleEditorView + tap a rule → detail → editor RuleDetailSheet → RuleEditorView +Settings Uninstall Protection, App Lists, About SettingsView → ManageAppListsView +``` + +Where each topic is documented: + +| Topic | Source (doc comment) | +|---|---| +| Rule kinds, sum-type options, Schedule-only rationale | `Shared/RuleConfiguration.swift`, `Shared/RuleKind.swift` | +| Persisted rule + common attributes; editor draft; cross-process mirror | `OpenAppLock/Models/BlockingRule.swift`, `OpenAppLock/Models/RuleDraft.swift`, `Shared/RuleSnapshot.swift` | +| Derived status & countdown labels | `OpenAppLock/Logic/RuleStatus.swift` | +| Day-of-week picker & summary | `OpenAppLock/Views/Components/DayOfWeekPicker.swift`, `Shared/Weekday.swift` | +| Presets; editors (all kinds); detail | `OpenAppLock/Models/RulePreset.swift`, `OpenAppLock/Views/Rules/RuleEditorView.swift`, `OpenAppLock/Views/Rules/RuleDetailSheet.swift` | +| App lists (model, picker, library, edit) + legacy migration | `OpenAppLock/Models/AppList.swift`, `OpenAppLock/Views/AppLists/*`, `OpenAppLock/Services/AppListMigration.swift` | +| Home: Currently Blocking + Usage, row strings | `OpenAppLock/Views/Home/HomeView.swift`, `OpenAppLock/Logic/UsageDisplay.swift` | +| Schedule activation / time-window math (incl. midnight crossing) | `Shared/RuleSchedule.swift`, `Shared/ScheduleEnforcement.swift` | +| Unblock / disable / delete / Hard Mode gating | `OpenAppLock/Logic/RulePolicy.swift` | +| Foreground reconciliation; **overlapping rules → strictest wins** | `OpenAppLock/Services/RuleEnforcer.swift`, `Shared/ShieldController.swift` | +| Time/open-limit behavior, granted opens, proactive gate | `Shared/LimitEnforcement.swift`, `Shared/UsageLedger.swift`, `Shared/OpenSessionStore.swift` | +| Shield text + "Open" button / press handling | `Shared/ShieldPresentation.swift`, `OpenAppLockShieldConfig/ShieldConfigurationExtension.swift`, `OpenAppLockShieldAction/ShieldActionExtension.swift` | +| DeviceActivity scheduling, naming; background monitor | `OpenAppLock/Services/RuleScheduler.swift`, `Shared/MonitoringPlan.swift`, `OpenAppLockMonitor/DeviceActivityMonitorExtension.swift` | +| Uninstall Protection | `OpenAppLock/Views/Settings/SettingsView.swift`, `Shared/UninstallProtectionPolicy.swift`, `Shared/UninstallProtectionEnforcer.swift`, `OpenAppLock/Services/AppSettings.swift` | +| About links (GitHub / Website) | `OpenAppLock/Services/AppLinks.swift`, `OpenAppLock/Services/LaunchConfiguration.swift` | + +Not part of the feature: paywall, the Home gem/score UI, a Timer tab (one-off +sessions), notification nudges. Onboarding exists (`OpenAppLock/Views/Onboarding/`) +but is out of scope. The pre-reskin custom-themed design (Hold-to-Commit, rule +cards, photo preset gallery) is recoverable from git history +(`Docs/AGENT_RULES_FEATURE_SPEC.md`, removed when the spec was folded into code). + ## Build & test - Open `OpenAppLock.xcodeproj` in Xcode; build/test through the **Xcode MCP** @@ -115,7 +151,7 @@ when reminded: editing code. Do not start changing files until the plan is clear. - **Always use red-green TDD.** Consult the feature spec first for behavior changes — the doc comment on the file you're changing is the source of truth, - with `Docs/RULES_SPEC.md` as the map. If a change makes a doc comment + indexed by the "Rules feature map" above. If a change makes a doc comment inaccurate, update it in the same commit (see Documentation above). Then 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. diff --git a/CLAUDE.md b/CLAUDE.md index fb1f466..fa08178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,4 @@ All project context for agents lives in [AGENTS.md](AGENTS.md) — read that first. Feature behavior is specified in doc comments on the source it describes, -mapped from [Docs/RULES_SPEC.md](Docs/RULES_SPEC.md). +indexed by the "Rules feature map" in [AGENTS.md](AGENTS.md). diff --git a/Docs/AGENT_SWIFT_GUIDELINES.md b/Docs/AGENT_SWIFT_GUIDELINES.md index 8f1eeec..d59a044 100644 --- a/Docs/AGENT_SWIFT_GUIDELINES.md +++ b/Docs/AGENT_SWIFT_GUIDELINES.md @@ -119,9 +119,10 @@ swift test --enable-code-coverage ### Workflow Red-green TDD: update the feature spec first for behavior changes — the doc -comment on the file you're changing (mapped from `Docs/RULES_SPEC.md`) — then -write the failing test, run it (a compile failure counts as red), implement, -re-run focused tests, then the full suite. Run tests often and fail fast. +comment on the file you're changing (indexed by AGENTS.md → "Rules feature +map") — then write the failing test, run it (a compile failure counts as red), +implement, re-run focused tests, then the full suite. Run tests often and fail +fast. --- diff --git a/Docs/RULES_SPEC.md b/Docs/RULES_SPEC.md deleted file mode 100644 index 48904aa..0000000 --- a/Docs/RULES_SPEC.md +++ /dev/null @@ -1,205 +0,0 @@ -# OpenAppLock — Rules feature spec - -This is the spec for OpenAppLock's recurring app-blocking **rules**. The detailed -behavioral and implementation prose now lives as **doc comments on the source it -describes** — open the file named for a topic below and the `///` comment at the -top of its primary type *is* the spec for that piece. This file is the **map**: -it holds the concept overview, the navigation/screen tree, the cross-cutting -invariants that have no single owning file, and a topic → source index so you can -jump straight to the code (and its spec) for any part of the feature. - -> **Maintaining this spec.** It is human-owned and co-maintained by humans and -> agents (see [AGENTS.md](../AGENTS.md) → Documentation). When you change a -> behavior, update the doc comment on the file that owns it — that *is* the spec -> now — in the **same commit** as the code. Adjust the map below if a topic moves -> to a different file. - ---- - -## §1 Concept - -A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off -block/timer session, a rule re-arms itself on a schedule. Three kinds exist: - -| Kind | Example | Semantics | -|------|---------|-----------| -| **Schedule** | "9–5, Daily" | Block selected apps during a daily time window on chosen days | -| **Time Limit** | "45m/day" | After N minutes of cumulative use per day, block until a reset point | -| **Open Limit** | "5 opens/day" | After N opens per day, block until a reset point | - -**Common to every kind** — a user-editable **name**, a **days-of-week** set, an -**App List** (a named, reusable selection of apps/categories/websites that a rule -points at; editing a list affects every rule that uses it), **Hard Mode** (while -the rule is actively blocking it cannot be lifted, edited, or deleted), and an -**enabled/disabled** flag. - -**Per-kind options** are modelled as a sum type so a rule can only hold the -options that belong to its kind — illegal states are unrepresentable: - -- **Schedule** — a recurring time **window** (may cross midnight), a **selection - mode** (**Block** the list, or **Allow Only** = block everything except it), - and **Block Adult Content** (Screen Time's adult-website filter). -- **Time Limit** — a daily **minutes** budget. -- **Open Limit** — a daily **opens** budget. - -Selection mode and Block Adult Content are **Schedule-only** (rationale in -`RuleConfiguration`). Status ("6h left", "Starts in 22h", live usage) is always -**derived**, never stored. - -→ Documented in: `Shared/RuleConfiguration.swift`, `Shared/RuleKind.swift`, -`OpenAppLock/Models/BlockingRule.swift`, `OpenAppLock/Models/RuleDraft.swift`, -`OpenAppLock/Logic/RuleStatus.swift`. - ---- - -## §2 Navigation & screens (current native presentation) - -After onboarding the app is an **adaptive shell** (`MainView`): a bottom -`TabView` in compact width (iPhone, iPad multitasking / Slide Over) and a -left-sidebar `NavigationSplitView` in regular width (full-screen iPad). Section -labels and icons come from a single `AppSection` enum so the two layouts can't -drift. - -``` -MainView (adaptive) source -├── Home "Currently Blocking" + "Usage" HomeView -├── Rules rules grouped Schedule / Time Limit / RulesListView -│ Open Limit; "+" opens New Rule ├─ NewRuleSheet → RuleEditorView -│ tap a rule opens its detail └─ RuleDetailSheet → RuleEditorView -└── Settings Uninstall Protection, Manage App Lists, SettingsView - About links └─ ManageAppListsView → AppListLibraryView -``` - -App pickers embed the system `FamilyActivityPicker` (third parties cannot -enumerate installed apps). The enforcement lifecycle — a 30 s refresh loop plus a -reconcile on rule changes and on scene-active — lives on `MainView`, so it runs -in either layout. - ---- - -## Where each topic is documented - -Each row points to the file whose doc comment holds the spec for that topic. - -| Topic | Source (doc comment) | -|---|---| -| Rule kinds, sum-type options, Schedule-only rationale | `Shared/RuleConfiguration.swift`, `Shared/RuleKind.swift` | -| Persisted rule + common attributes | `OpenAppLock/Models/BlockingRule.swift` | -| Editor working copy (draft) | `OpenAppLock/Models/RuleDraft.swift` | -| Cross-process rule mirror | `Shared/RuleSnapshot.swift` | -| Derived status & countdown labels | `OpenAppLock/Logic/RuleStatus.swift` | -| Day-of-week picker & summary | `OpenAppLock/Views/Components/DayOfWeekPicker.swift`, `Shared/Weekday.swift` | -| Preset gallery | `OpenAppLock/Models/RulePreset.swift`, `OpenAppLock/Views/Rules/NewRuleSheet.swift` | -| Rule editors (all three kinds) | `OpenAppLock/Views/Rules/RuleEditorView.swift` | -| Rule detail sheet | `OpenAppLock/Views/Rules/RuleDetailSheet.swift` | -| App lists (model, picker, library, edit) + legacy migration | `OpenAppLock/Models/AppList.swift`, `OpenAppLock/Views/AppLists/*`, `OpenAppLock/Services/AppListMigration.swift` | -| Home: Currently Blocking + Usage, row strings | `OpenAppLock/Views/Home/HomeView.swift`, `OpenAppLock/Logic/UsageDisplay.swift` | -| Schedule activation / time-window math (incl. midnight crossing) | `Shared/RuleSchedule.swift`, `Shared/ScheduleEnforcement.swift` | -| Unblock / disable / delete / Hard Mode gating | `OpenAppLock/Logic/RulePolicy.swift` | -| Foreground shield reconciliation (source of truth while open) | `OpenAppLock/Services/RuleEnforcer.swift` | -| Time/open-limit behavior, granted opens, proactive gate | `Shared/LimitEnforcement.swift`, `Shared/UsageLedger.swift`, `Shared/OpenSessionStore.swift` | -| Shield application (per-rule `ManagedSettingsStore`) | `Shared/ShieldController.swift` | -| Shield text (never names a rule) + "Open" button | `Shared/ShieldPresentation.swift`, `OpenAppLockShieldConfig/ShieldConfigurationExtension.swift` | -| "Open" press handling | `OpenAppLockShieldAction/ShieldActionExtension.swift` | -| DeviceActivity scheduling, activity/event naming | `OpenAppLock/Services/RuleScheduler.swift`, `Shared/MonitoringPlan.swift` | -| Background monitor reactions (interval edges, thresholds) | `OpenAppLockMonitor/DeviceActivityMonitorExtension.swift` | -| Uninstall Protection (§6.1) | `OpenAppLock/Views/Settings/SettingsView.swift`, `Shared/UninstallProtectionPolicy.swift`, `Shared/UninstallProtectionEnforcer.swift`, `OpenAppLock/Services/AppSettings.swift` | -| About links — GitHub / Website (§6.2) | `OpenAppLock/Services/AppLinks.swift`, `OpenAppLock/Services/LaunchConfiguration.swift` | - ---- - -## §4.8 Overlapping rules — strictest enforcement wins - -When several rules target the same app, the app is blocked if **any** of them is -currently blocking it; rules never cancel each other out. This is structural -rather than a resolved decision: each rule owns its own `ManagedSettingsStore` -(`rule-`), Screen Time **unions** shields across all stores, and a rule -only ever writes/clears *its own* store. Consequences: - -- An open-limit and a time-limit rule on the same app each block via their own - store, so whichever's budget is spent **first** blocks the app, regardless of - the other's remaining budget. -- An **Allow-Only** schedule cannot punch a hole for an app that another rule - blocks: `.all(except:)` is itself a *shield* directive ("block everything - except these"), not a whitelist that lifts other stores' shields. So if a - schedule "allows" an app but a time limit blocks it, the time-limit block - stands. -- A soft **unblock** pauses only the one rule it was invoked on; other rules - blocking the same app keep it blocked. - -There is deliberately **no** central merge of selections into a single shield -set — such a merge would be the only place a block could be accidentally dropped. -The invariant is exercised by `OpenAppLockTests/RuleEnforcerTests.swift`. - ---- - -## Background enforcement architecture - -**Frameworks & capabilities.** FamilyControls (authorization + -`FamilyActivityPicker` / `FamilyActivitySelection` opaque tokens), ManagedSettings -(one `ManagedSettingsStore` per rule, `rule-`; a dedicated -`uninstall-protection` store), and DeviceActivity (a per-rule schedule registered -by `RuleScheduler`; time limits add `DeviceActivityEvent` usage thresholds). -Requires the **Family Controls entitlement** (works in dev; distribution needs -Apple approval for the app and each extension bundle id) and the **App Group** -`group.dev.bchen.OpenAppLock`, which carries `RuleSnapshotStore` (rule mirror), -`UsageLedger` (per-day minutes/opens), `OpenSessionStore` (granted-open expiry), -and the shield-store tracking list. - -**Two enforcement paths that must agree.** - -- **Foreground** — `RuleEnforcer.refresh` (launch + 30 s loop + rule change + - scene-active) recomputes every shield from scratch and is the source of truth - while the app runs. -- **Background** — `RuleScheduler` keeps DeviceActivity monitoring in step; - `OpenAppLockMonitor` reacts at interval start/end and usage thresholds; - `LimitEnforcement` and `ScheduleEnforcement` hold the shared reactions so both - paths converge on the same shield state. - -**Reliability posture.** DeviceActivity interval callbacks fire on "first device -use after the boundary", are known to arrive late or be skipped (device asleep, -OS regressions across iOS 17/18/26), and a shield written over an app the user -already has open may not visibly engage until that app is relaunched (a -long-standing Screen Time limitation). Background monitoring is therefore -**best-effort**, with the foreground loop as the reconciliation safety net. Real -Screen Time effects are only observable **on a device** — the simulator uses mock -shields and delivers no DeviceActivity callbacks. - ---- - -## Out of scope (not part of the rules feature) - -Paywall, the Home gem/score UI, a Timer tab (one-off focus sessions with a -"Leave Early?" friction screen), and notification nudges. The onboarding / -Screen Time permission flow exists (`OpenAppLock/Views/Onboarding/`) but is not -part of this spec. - ---- - -## Appendix — original custom-design reference (pre-reskin, historical) - -OpenAppLock originally shipped a **custom themed presentation** (near-black -background, dark-green tint) that has since been replaced by the bare native iOS -design language above; the backend, flows, and accessibility identifiers were -kept. This appendix records the original design intent so it isn't lost (the full -pre-rename document is in git history as `Docs/AGENT_RULES_FEATURE_SPEC.md`): - -- **Tab bar** `[Home] [My Apps] [Timer]`; the rules lived on an "Apps" screen - with a **Blocked Apps** row, a horizontally-scrolling **rules carousel** of - rounded **rule cards** (rule-type icon → shield icon, a green status pill, name - + blocked-app cluster), and folder-style app groups (Distracting / Always - Allowed / Never Allowed). -- **Rules UI as stacked sheets** over the Apps screen (grabber, circular ✕/‹), - rather than the current native push navigation. -- **New Rule sheet** with three rule-type cards and a **preset gallery** of - photo-backed cards (Morning Focus, Deep Work, Evening Reset, Lights Out, Family - Dinner, Screen-Free Sunday) — now plain list rows. -- **Schedule editor** with a dotted From→To connector and inline wheel time - pickers; a custom **App Picker** with a tri-state category list and a bottom - search bar (now the system `FamilyActivityPicker`). -- **"Hold to Commit"** — a press-and-hold gradient button that added deliberate - friction to *creating* a rule (editing used a plain "Done"). Replaced by a - navigation-bar checkmark. - -Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`, and the -icon-pair / circle-button chrome. diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 6fbecb5..bcfe75a 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -55,10 +55,18 @@ final class RuleEnforcer { /// Recomputes shields from scratch. Call on launch, on any rule change, /// and periodically while the app is visible. Also expires stale pauses. /// - /// Each rule shields its *own* `ManagedSettingsStore`, and Screen Time - /// unions shields across stores, so overlapping rules enforce strictly: - /// an app is blocked if *any* covering rule blocks it (see spec §4.8). A - /// rule is shielded when it is actively blocking (a schedule window is + /// **Overlapping rules — strictest enforcement wins.** Each rule shields its + /// *own* `ManagedSettingsStore`, Screen Time unions shields across stores, + /// and a rule only ever writes/clears its own store, so an app is blocked if + /// *any* covering rule blocks it and rules never cancel each other out: + /// whichever limit's budget is spent first blocks the app regardless of the + /// other's remaining budget; an Allow-Only schedule cannot punch a hole + /// through another rule's block (see `ShieldController`); and a soft unblock + /// pauses only the rule it was invoked on. There is deliberately no central + /// merge of selections — that would be the one place a block could be + /// accidentally dropped. + /// + /// A rule is shielded when it is actively blocking (a schedule window is /// open, or a limit budget is spent) *or* when it is an open-limit rule /// that must gate its apps so opens can be counted. func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) { diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index 2d433d1..4324ae3 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -169,9 +169,9 @@ struct RuleEnforcerTests { } /// Validates the "strictest enforcement wins" model for rules that target the -/// same apps (spec §4.8). Each rule shields its own store and Screen Time -/// unions them, so the unit-level invariant is: every rule that should block -/// applies its own shield, and no rule's shield is suppressed by another. +/// same apps (see `RuleEnforcer`). Each rule shields its own store and Screen +/// Time unions them, so the unit-level invariant is: every rule that should +/// block applies its own shield, and no rule's shield is suppressed by another. @MainActor @Suite("Overlapping rules → strictest enforcement") struct OverlappingRuleEnforcementTests { diff --git a/Shared/ShieldController.swift b/Shared/ShieldController.swift index 03ea4a1..292b127 100644 --- a/Shared/ShieldController.swift +++ b/Shared/ShieldController.swift @@ -53,7 +53,8 @@ final class ManagedSettingsShieldController: ShieldApplying { // `.all(except:)` is itself a *shield* directive ("block everything // except these"), not a whitelist — it cannot lift another store's // shield on a shared app. So an Allow-Only rule never punches a hole - // through a block another rule applies (spec §4.8, strictest wins). + // through a block another rule applies (strictest wins — see + // `RuleEnforcer`). store.shield.applicationCategories = .all(except: selection.applicationTokens) store.shield.webDomainCategories = .all(except: selection.webDomainTokens) }