From 05bda578279a7bc298bc1b8d3a2059f534702bfa Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:08:14 -0400 Subject: [PATCH 01/13] docs: add time-limit counting hardening spec and Docs/Agents convention Add the design spec for making time-limit enforcement robust against batched Screen Time threshold events: Part A (background hardening) and Part B (foreground authoritative reconciliation via a DeviceActivityReport extension). Establish Docs/Agents/ as an agent-modifiable working-docs folder (ownership by location, no AGENT_ prefix needed inside it) and record it in AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- AGENTS.md | 14 +- .../Specs/TIME_LIMIT_COUNTING_HARDENING.md | 302 ++++++++++++++++++ 2 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md diff --git a/AGENTS.md b/AGENTS.md index e4862db..edf6f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,9 @@ Docs/AGENT_RULES_FEATURE_SPEC.md Docs/AGENT_SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards agents must follow on this project (agent-managed). +Docs/Agents/ Agent working docs — the whole folder is + agent-modifiable. Design specs live under + Docs/Agents/Specs/ (agent-managed). ``` ## Documentation @@ -54,11 +57,14 @@ Docs/AGENT_SWIFT_GUIDELINES.md Documentation splits into two buckets, distinguished by **filename**, not by directory: -- **Agent-managed** — this `AGENTS.md`, `CLAUDE.md`, and any file whose name is +- **Agent-managed** — this `AGENTS.md`, `CLAUDE.md`, 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. + `Docs/AGENT_SWIFT_GUIDELINES.md`), and **anything under `Docs/Agents/`** + (e.g. design specs in `Docs/Agents/Specs/`) — the folder marks ownership by + location, so files inside it need no `AGENT_` prefix. 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. - **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. diff --git a/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md b/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md new file mode 100644 index 0000000..554199a --- /dev/null +++ b/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md @@ -0,0 +1,302 @@ +# Time-Limit Counting Hardening + +Design spec for making time-limit enforcement robust against unreliable Screen +Time threshold events. Agent-managed (lives under `Docs/Agents/`). Pairs with +`Docs/AGENT_RULES_FEATURE_SPEC.md` (the behavior source of truth) and updates +its §5.5 "Reliability posture" once shipped. + +## 1. Problem + +Time-limit rules count usage by registering a chain of cumulative +`DeviceActivityEvent` thresholds (`minutes-1 … minutes-N`) over the rule's app +selection on an always-on, midnight-to-midnight repeating activity +(`MonitoringPlan.minuteEvents(forLimit:)`, `RuleScheduler.sync`). When a +threshold is crossed the monitor extension records the minute count and shields +at the budget (`LimitEnforcement.handleUsageMinutes`). + +`DeviceActivityEvent` thresholds are **not** a real-time, ordered, once-each +stream. iOS coalesces closely-spaced thresholds and flushes them when it next +wakes the monitor — often minutes to hours late, sometimes triggered by an +unrelated wake. Because the daily activity is `repeats: true`, every day reuses +the same event names, and a callback carries `minutes-k` but **no date**. So an +event Screen Time held overnight and flushed after midnight is +indistinguishable, by name, from a fresh one. (Corroborated by Apple Developer +Forums threads on `eventDidReachThreshold` batching/overcounting, and by this +project's device-research notes: the Usage counter "stalls at ~14/15m".) + +The existing mitigation is a magnitude guard +(`LimitEnforcement.handleUsageMinutes`): + +```swift +let minutesSinceMidnight = Int(now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) +guard minutes <= minutesSinceMidnight else { return } +``` + +It rejects a stale checkpoint only when its threshold exceeds today's elapsed +minutes — i.e. only in roughly the first `budget` minutes after midnight. Two +failure modes survive it: + +- **Scenario A — under-count.** Yesterday's modest usage (say 20m) flushes + mid-morning (elapsed = 480m). Every `minutes-1 … minutes-20` event passes the + guard and corrupts today's ledger with phantom usage, silently shrinking + today's real budget. +- **Scenario B — false block.** Yesterday maxed the budget; the `minutes-` + event flushes the next morning after `intervalDidStart` already cleared the + shield. `budget ≤ elapsed` passes the guard, the ledger records the full + budget, and apps the user never opened today are re-blocked all day. General + condition: `usedYesterday ≥ budget ∧ elapsedToday ≥ budget`. + +Aggravating: `recordMinutesUsed` runs **before** the +`isEnabled / kind / !isPaused / isScheduledToday` eligibility guards, so a stale +event corrupts today's ledger even for a rule that cannot be active today, and +the foreground `RuleEnforcer.refresh` later reads that corrupted value. + +## 2. Goals / non-goals + +**Goals** + +- Eliminate, or correct within one foreground refresh, the phantom blocks and + under-counts caused by cross-midnight stale threshold flushes. +- Provide an authoritative daily usage total for display and for the foreground + block decision, fixing the "stalls at ~14/15m" lag. +- Keep all reachable logic unit-testable from the app target; keep the + device-only surface (a new extension) as thin as possible. + +**Non-goals** + +- Fully eliminating background false blocks while the app is never opened. That + needs per-day event names (the previously-discussed "Option 2"), which we are + not doing here. With this design a residual background false block is + *corrected on the next foreground refresh*, not prevented in pure background. +- Changing time-limit user-facing semantics (budget, reset at next midnight, + Hard Mode, soft unblock). + +## 3. Architecture + +Two complementary parts: + +- **Part A (Option 3) — background hardening.** Shrinks the surface that can + corrupt the threshold-derived ledger at the source. Fully unit-tested. Ships + first and stands alone. +- **Part B (Option 1) — foreground authoritative reconciliation.** A + `DeviceActivityReport` extension computes the true daily total while the app + is foreground and writes it to the app group; the app prefers that + authoritative total for display and for the foreground block decision, + overriding any residual threshold corruption. + +`DeviceActivityReport` is a SwiftUI view whose data is computed in a separate +extension process **only while the view is rendered in the foreground app**. It +cannot feed the background monitor's block decision. Therefore Part A governs +the background; Part B governs the foreground and reconciles. A false block is +prevented in the common case (A) or cleared the moment the app is opened (B). + +## 4. Part A — background hardening + +### 4a. Record only for eligible rules + +In `LimitEnforcement.handleUsageMinutes`, move `ledger.recordMinutesUsed(...)` +**below** the eligibility guards (`isEnabled`, `kind == .timeLimit`, +`!isPaused`, `isScheduledToday`). A rule that cannot be active today no longer +accrues phantom usage today. (The daily activity runs every day regardless of +the rule's selected days, so today's events still arrive on an unscheduled day; +we simply stop recording them.) + +### 4b. Confirmed day-start gate + +Add `DayStartStore` in the app group: `ruleID → confirmed day-start Date` +(stored in `AppGroup.defaults`, same pattern as `RuleSnapshotStore`). + +- `handleDayStart` (fired by `intervalDidStart` for the daily activity): on a + genuine new-day transition only — `confirmedStart(ruleID) != startOfDay(now)` + — record `startOfDay(now)` as the confirmed start **and zero today's ledger + once**. Zeroing only on the transition is safe against a spurious mid-day + `intervalDidStart` re-fire (which would otherwise erase legitimate usage). +- `handleUsageMinutes`: after the magnitude guard, drop the event unless + `confirmedStart(ruleID) == startOfDay(now)`. This closes the *pre-boundary* + race — a stale flush that lands in the early morning before today's + `intervalDidStart` fires, in the window where the magnitude guard alone would + let it through (e.g. `intervalDidStart` delayed to 00:50, a `minutes-45` + residual arrives at 00:46: magnitude `45 ≤ 46` passes, but no confirmed start + for today exists yet → dropped). + +### 4c. Foreground safety net + +`RuleEnforcer.refresh` (foreground, runs on launch / rule change / every 30s) +**establishes a confirmed start for today if one is missing**, without zeroing +(to preserve any legitimate accrual). This bounds 4b's failure mode: if the +monitor's `intervalDidStart` is skipped for a day, the gate would otherwise +block all usage recording for that day; the safety net self-heals it the next +time the app is foregrounded. + +### 4d. Limits of Part A (documented, not fixed here) + +4b adds day-attribution only for the *pre-boundary* race. The *post-boundary* +Scenario B (interval fires, then a stale `minutes-` flush arrives) still +passes the magnitude guard and the confirmed-start gate, so a background false +block can still occur — it is cleared by Part B on the next foreground refresh. +4b also trades a small under-blocking risk (skipped `intervalDidStart` → no +recording until the app is next opened) for the under-count/false-block fix; +acceptable because the app is the source of truth whenever it runs, and Part B +re-derives the true total on foreground. Both are device-verification items. + +## 5. Part B — foreground authoritative reconciliation + +### 5a. Collapse the event chain + +`MonitoringPlan` returns a **single** `minutes-` block event for a time +limit instead of `minutes-1 … minutes-N`. Rename `minuteEvents(forLimit:)` → +`blockEvent(forLimit:)` (one-entry dictionary), keeping `minuteEventName` / +`minutes(fromEventName:)` for the name round-trip. `RuleScheduler.sync` calls +the new function. Background still blocks at the budget via that one event; the +cross-midnight stale surface shrinks to its minimum (only one event can ever +mis-fire). Live sub-budget progress now comes from the report (5c), not from the +per-minute chain — acceptable because the limited apps only accrue time while +OpenAppLock is *not* foreground, so "live" already meant "accurate as of when +you open the app." + +### 5b. Authoritative usage in the ledger + +Extend `RuleUsage` (tolerant `Codable`, optional fields so old blobs decode): + +```swift +var authoritativeMinutesUsed: Int? // true daily total from the report +var authoritativeAsOf: Date? // when the report computed it + +static let authoritativeFreshness: TimeInterval = 120 // tunable on device + +func effectiveMinutesUsed(asOf now: Date, + freshness: TimeInterval = RuleUsage.authoritativeFreshness) -> Int { + if let a = authoritativeMinutesUsed, let at = authoritativeAsOf, + abs(now.timeIntervalSince(at)) <= freshness { return a } + return minutesUsed +} +``` + +Add `UsageLedger.recordAuthoritativeMinutes(_:for:onDayContaining:asOf:)` (sets +the authoritative fields without disturbing the monotonic `minutesUsed`). + +One resolver serves both contexts correctly: + +- **Foreground:** the report keeps `authoritativeAsOf` fresh → authoritative + wins → fixes display lag and overrides residual threshold corruption. +- **Background:** authoritative is stale → falls back to `minutesUsed` (the + collapsed block event) → unchanged background behavior. + +### 5c. Consume effective minutes + +`limitReached` gains a `now` parameter and uses `effectiveMinutesUsed(asOf: now)` +in the time-limit branch (both `BlockingRule.limitReached` and +`RuleSnapshot.limitReached`; schedule/open-limit branches unchanged). The four +direct call sites already have `now` in scope: `BlockingRule.status` +(`RuleStatus.swift`), the three `LimitEnforcement` handlers, and +`UninstallProtectionPolicy.isActive`. `RulePolicy.shouldDenyAppRemoval` needs no +change — it routes through `rule.status(at:…)`, which already passes `now`. +`UsageDisplay.usagePhrase` gains a `now` parameter to compute effective minutes, +supplied by its only caller `BlockingRule.rowContext` (which already takes +`relativeTo now`). No new branch is needed in `RuleEnforcer`: once status uses +effective minutes, a fresh authoritative total below budget makes a rule's +status non-active, so the existing `clearShields(except:)` clears the phantom +shield automatically. + +### 5d. Report extension (`OpenAppLockReport`, device-only) + +New app-extension target added by mirroring the three existing extensions in +`project.pbxproj` (same app group + Family Controls entitlement; bundle id +`dev.bchen.OpenAppLock.Report`). Contents: + +- A `DeviceActivityReport.Context` value `.ruleUsage` and a + `DeviceActivityReportScene` whose `makeConfiguration(representing:)` reads the + rule snapshots, and for each enabled time-limit rule sums + `totalActivityDuration` over that rule's selection for the current day, then + calls `recordAuthoritativeMinutes`. The rendered view is empty/zero-size — we + use the scene only for its side effect. +- Attribution: the host filter covers the union of all time-limit rules' + selections over a `.daily` segment; the scene iterates segments → categories → + applications and, per rule, sums durations whose app/category token is in that + rule's selection. Overlapping rules each sum their own selection (independent + budgets). + +Host wiring: `MainView` embeds an invisible +`DeviceActivityReport(.ruleUsage, filter:)` so it renders whenever the app is +foreground; the filter is rebuilt from the current rules. The existing 30s +`RuleEnforcer.refresh` loop picks up the freshly-written authoritative totals +(snappier app-group key observation is possible later but not required for +correctness). + +## 6. Data model / API changes + +- `RuleUsage`: `+ authoritativeMinutesUsed: Int?`, `+ authoritativeAsOf: Date?`, + `+ effectiveMinutesUsed(asOf:freshness:)`, `+ static authoritativeFreshness`. +- `UsageLedger`: `+ recordAuthoritativeMinutes(_:for:onDayContaining:asOf:)`; + add `MockUsageLedger` seeding for the new fields. +- `MonitoringPlan`: `minuteEvents(forLimit:)` → `blockEvent(forLimit:)`. +- `LimitEnforcement`: reordered `handleUsageMinutes`; confirmed-start gate; + `handleDayStart` records confirmed start + zeroes once; new `DayStartStore` + dependency (injectable, app-group-backed, with a mock/fresh-defaults variant + for tests). +- `DayStartStore`: new file in `Shared/`. +- `limitReached(given:at:)` on `BlockingRule` and `RuleSnapshot`; the four + direct call sites updated (`RulePolicy` unchanged — routes via `status`). +- `UsageDisplay.usagePhrase(...,asOf:)` + `BlockingRule.rowContext` passes `now`. +- `RuleEnforcer.refresh`: confirmed-start safety net. +- New target `OpenAppLockReport` + host `DeviceActivityReport` view in `MainView`. + +## 7. Testing strategy (red/green TDD) + +Each change starts with a failing test (Swift Testing, mirroring +`SchedulingTests` patterns: `freshDefaults()`, `MockShieldController`, `date()`, +`utc`). + +Unit-tested (must be red first, then green): + +- **4a** — a disabled / paused / not-scheduled-today rule does not accrue + minutes from an in-range event (`LimitEnforcementTests`). +- **4b** — an in-range event before a confirmed day-start is dropped; after a + confirmed start it records; `handleDayStart` zeroes only on the day transition + and not on a same-day re-fire (`LimitEnforcementTests`, `DayStartStore` test). +- **4c** — `RuleEnforcer.refresh` establishes a confirmed start for today when + missing, without zeroing existing usage (`RuleEnforcerTests`). +- **5a** — `blockEvent(forLimit:)` returns one entry; `RuleScheduler` registers + one event; update the two affected `SchedulingTests`. +- **5b** — `effectiveMinutesUsed` prefers fresh authoritative, falls back when + stale; `RuleUsage` Codable round-trips the new fields and decodes legacy + blobs; `recordAuthoritativeMinutes` round-trips (`UsageTests`). +- **5c** — `limitReached(at:)` uses fresh authoritative; a fresh authoritative + below budget makes status non-active and clears the shield via + `RuleEnforcer.refresh` (`RuleStatusTests`, `RuleEnforcerTests`); `UsageDisplay` + shows the effective figure. + +Device-only (written + build-verified, **not** unit-tested; deferred +verification): the `OpenAppLockReport` scene + token-matching aggregation, the +invisible host report view, and the `project.pbxproj` target. The simulator +delivers no DeviceActivity data and does not render custom report extensions. + +## 8. Sequencing + +1. Part A (4a, 4b, 4c) — self-contained, shippable. +2. Part B shared logic (5a, 5b, 5c) — TDD. +3. Part B extension + pbxproj + host view (5d) — build-verified. +4. Update `Docs/AGENT_RULES_FEATURE_SPEC.md` §5.5 and AGENTS.md "Known gaps", + and the `openapplock-issue2-usage-counter` memory. + +Build and test via the Xcode MCP on the simulator (no raw `xcodebuild`). + +## 9. Risks + +- **pbxproj hand-edit** can corrupt the project; verify the project opens and + builds after the edit, before writing extension code. +- **Freshness window** (120s) is a guess; tune on device so foreground stays + fresh across the 30s refresh cadence without trusting a stale reading. +- **4b under-blocking** if `intervalDidStart` is skipped all day and the app is + never opened (bounded by 4c + Part B). Device-verification item. +- **Report attribution** for category/web selections is unverified on device; + start with application tokens and confirm category coverage on hardware. + +## 10. On-device verification checklist (deferred) + +- Time-limit usage accrues in the Usage section and reads the true total on app + open (no "stalls at 14/15m"). +- Blocks at the budget; a maxed-out day does **not** re-block unused apps the + next morning (or clears within one foreground refresh if it does). +- A modest prior day does not shrink today's budget (no Scenario A under-count). +- Collapsed single event still blocks in pure background. From 53d1715951e006230ecee79964df1f2579cefae4 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:19:15 -0400 Subject: [PATCH 02/13] docs: add time-limit counting hardening implementation plan Bite-sized red/green TDD plan: Part A (background hardening, tasks A1-A4) then Part B (collapse event + authoritative reconciliation, B1-B7), plus a docs/memory update (C1). Device-only tasks (report extension, pbxproj) are build-verified. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- .../Plans/TIME_LIMIT_COUNTING_HARDENING.md | 1093 +++++++++++++++++ 1 file changed, 1093 insertions(+) create mode 100644 Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md diff --git a/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md b/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md new file mode 100644 index 0000000..45457d8 --- /dev/null +++ b/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md @@ -0,0 +1,1093 @@ +# Time-Limit Counting Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make time-limit enforcement robust against batched/late Screen Time threshold events — stop phantom blocks and under-counts, and provide an authoritative foreground usage total. + +**Architecture:** Two complementary parts. Part A hardens the background path (record only for eligible rules; gate usage on a confirmed day-start) so stale cross-midnight flushes can't corrupt the ledger. Part B adds a `DeviceActivityReport` extension that computes the true daily total while the app is foreground and writes it to the app group; the app prefers that authoritative figure for display and the foreground block decision. See `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`. + +**Tech Stack:** Swift 6, SwiftUI, SwiftData, FamilyControls / DeviceActivity / ManagedSettings (Screen Time), Swift Testing. + +## Global Constraints + +- **Tests:** Swift Testing (`import Testing`, `@Test`, `#expect`), `@MainActor` suites. Reuse `date()`, `utc`, `makeInMemoryContext()` from `OpenAppLockTests/TestSupport.swift`; create isolated `UserDefaults` per test (`UserDefaults(suiteName: "…-\(UUID())")!`). +- **Build & test:** via the Xcode MCP only (`BuildProject`, `RunSomeTests`, `RunAllTests`) on a **simulator** destination. Never invoke raw `xcodebuild`. `plutil -lint` is allowed for plist/pbxproj syntax checks (read-only). +- **Style:** value types, `let` over `var`, immutability; small focused files; no `print()` (use `os.Logger` if logging needed). +- **App group:** `group.dev.bchen.OpenAppLock`. **Report bundle id:** `dev.bchen.OpenAppLock.Report`. **Entitlements:** `com.apple.developer.family-controls` + the app group, on every extension. +- **Authoritative freshness window:** `120` seconds (named constant, tunable on device). +- **Commits:** Conventional Commits. End every commit message with: + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U + ``` +- **Branch:** `feat/time-limit-counting-hardening` (already created). + +--- + +## Task A1: DayStartStore + +**Files:** +- Create: `Shared/DayStartStore.swift` +- Test: `OpenAppLockTests/SchedulingTests.swift` (add a suite) + +**Interfaces:** +- Produces: `final class DayStartStore { init(defaults: UserDefaults = AppGroup.defaults); func confirmedStart(for: UUID) -> Date?; func setConfirmedStart(_ : Date, for: UUID); func hasConfirmedStart(for: UUID, onDayContaining: Date, calendar: Calendar) -> Bool }` + +- [ ] **Step 1: Write the failing test** — append to `OpenAppLockTests/SchedulingTests.swift`: + +```swift +@MainActor +@Suite("Day-start store") +struct DayStartStoreTests { + private func makeStore() -> DayStartStore { + let name = "daystart-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return DayStartStore(defaults: defaults) + } + + @Test("Confirmed start round-trips and is day-scoped") + func roundTrip() { + let store = makeStore() + let id = UUID() + let monday = date(2025, 1, 6, 10, 0) + #expect(store.confirmedStart(for: id) == nil) + #expect(!store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + + store.setConfirmedStart(utc.startOfDay(for: monday), for: id) + #expect(store.confirmedStart(for: id) == utc.startOfDay(for: monday)) + #expect(store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + // A different day is not confirmed. + #expect(!store.hasConfirmedStart(for: id, onDayContaining: date(2025, 1, 7, 1, 0), calendar: utc)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** — Xcode MCP `RunSomeTests` for `OpenAppLockTests/DayStartStoreTests`. Expected: FAIL (`DayStartStore` undefined / won't compile). + +- [ ] **Step 3: Write minimal implementation** — create `Shared/DayStartStore.swift`: + +```swift +// +// DayStartStore.swift +// OpenAppLock +// + +import Foundation + +/// Per-rule "last confirmed daily-activity start", written when the monitor's +/// `intervalDidStart` fires (and defensively by the foreground enforcer). Lets +/// limit enforcement reject usage checkpoints that arrive before today's +/// interval boundary has been observed — i.e. yesterday's batched threshold +/// events flushed late across midnight. +final class DayStartStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + func confirmedStart(for ruleID: UUID) -> Date? { + defaults.object(forKey: key(ruleID)) as? Date + } + + func setConfirmedStart(_ dayStart: Date, for ruleID: UUID) { + defaults.set(dayStart, forKey: key(ruleID)) + } + + /// Whether the confirmed start equals the start of the day containing `date`. + func hasConfirmedStart( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> Bool { + confirmedStart(for: ruleID) == calendar.startOfDay(for: date) + } + + private func key(_ ruleID: UUID) -> String { + "dayStart/\(ruleID.uuidString)" + } +} +``` + +Then add `DayStartStore.swift` to the **OpenAppLock app** and **OpenAppLockMonitor** targets' membership (it is used by both). Mirror how `Shared/UsageLedger.swift` is referenced in `project.pbxproj` (add a `PBXFileReference` + `PBXBuildFile` entries in both targets' Sources phases). + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/DayStartStoreTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/DayStartStore.swift OpenAppLock.xcodeproj/project.pbxproj OpenAppLockTests/SchedulingTests.swift +git commit # message: "feat: add DayStartStore for confirmed daily-activity starts" + trailers +``` + +--- + +## Task A2: Record usage only for eligible rules (4a) + +**Files:** +- Modify: `Shared/LimitEnforcement.swift` (`handleUsageMinutes`, lines ~47–71) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`LimitEnforcementTests` suite) + +**Interfaces:** +- Consumes: existing `LimitEnforcement`, `MockShieldController`, `snapshot(...)` helper. +- Produces: no signature change. + +- [ ] **Step 1: Write the failing test** — in `LimitEnforcementTests`, first extend the `snapshot` helper to take days, then add a test. + +Change the helper signature from: +```swift + private func snapshot( + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil + ) -> RuleSnapshot { +``` +to add a `days` parameter: +```swift + private func snapshot( + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, + days: Set = Weekday.everyDay, pausedUntil: Date? = nil + ) -> RuleSnapshot { +``` +and use `dayNumbers: days.map(\.rawValue)` in the returned `RuleSnapshot` (replacing `Weekday.everyDay.map(\.rawValue)`). + +Add the test: +```swift + @Test("An ineligible rule does not accrue usage from a checkpoint") + func ineligibleRuleDoesNotAccrue() { + let (enforcement, _, ledger, store) = makeEnforcement() + // Weekday-only rule; a checkpoint arrives on a Saturday (not scheduled). + let snap = snapshot(kind: .timeLimit, days: Weekday.weekdays) + store.save([snap]) + let saturday = date(2025, 1, 11, 10, 0) // 2025-01-11 is a Saturday + + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: saturday, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: saturday, calendar: utc).minutesUsed == 0) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: FAIL — `ineligibleRuleDoesNotAccrue` records 20 because `recordMinutesUsed` runs before the eligibility guard. + +- [ ] **Step 3: Write minimal implementation** — in `Shared/LimitEnforcement.swift`, reorder `handleUsageMinutes` so the record happens after eligibility. Replace the body after the magnitude guard: + +```swift + let minutesSinceMidnight = Int( + now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) + guard minutes <= minutesSinceMidnight else { return } + + guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, + snapshot.kind == .timeLimit, + !snapshot.isPaused(at: now), + snapshot.isScheduledToday(at: now, calendar: calendar) + else { return } + ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) + let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) + if snapshot.limitReached(given: usage) { + shield(snapshot) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: PASS (all, including the pre-existing checkpoint tests). + +- [ ] **Step 5: Commit** + +```bash +git add Shared/LimitEnforcement.swift OpenAppLockTests/SchedulingTests.swift +git commit # "fix: record time-limit usage only for rules eligible today" + trailers +``` + +--- + +## Task A3: Confirmed day-start gate + zero-once (4b) + +**Files:** +- Modify: `Shared/LimitEnforcement.swift` (add `dayStarts` member; `handleDayStart`; gate in `handleUsageMinutes`) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`LimitEnforcementTests`) + +**Interfaces:** +- Consumes: `DayStartStore` (A1). +- Produces: `LimitEnforcement` gains member `var dayStarts = DayStartStore()`. + +- [ ] **Step 1: Write the failing tests.** + +First, update `makeEnforcement()` to inject an isolated `DayStartStore`: +```swift + private func makeEnforcement() -> (LimitEnforcement, MockShieldController, UsageLedger, RuleSnapshotStore) { + let shields = MockShieldController() + let ledger = UsageLedger(defaults: freshDefaults()) + let store = RuleSnapshotStore(defaults: freshDefaults()) + return ( + LimitEnforcement( + snapshots: store, ledger: ledger, shields: shields, + sessions: OpenSessionStore(defaults: freshDefaults()), + dayStarts: DayStartStore(defaults: freshDefaults())), + shields, ledger, store) + } +``` + +Next, the three existing checkpoint tests must establish a confirmed day-start first (the gate now requires it). Update them: + +In `usageCheckpointsShieldAtLimit`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) +``` +In `staleCrossMidnightCheckpointIgnored`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: earlyMorning, calendar: utc) +``` +In `freshCheckpointWithinElapsedHonoured`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: quarterToOne, calendar: utc) +``` + +Then add new tests: +```swift + @Test("A checkpoint before a confirmed day-start is dropped") + func checkpointBeforeConfirmedStartDropped() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + // No handleDayStart → no confirmed start for today. + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + #expect(shields.shieldedRuleIDs.isEmpty) + } + + @Test("Day start zeroes today's time-limit ledger once, only on a transition") + func dayStartZeroesOnceOnTransition() { + let (enforcement, _, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit) + store.save([snap]) + // A stale value sitting in today's key (e.g. a pre-boundary write). + ledger.setUsage( + RuleUsage(minutesUsed: 45), for: snap.id, onDayContaining: monday, calendar: utc) + + // First day-start: transition → zeroed. + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + + // A legitimate accrual after the transition... + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + + // ...survives a spurious same-day re-fire (no second zero). + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: FAIL to compile (`dayStarts:` arg doesn't exist yet) and/or new tests fail. + +- [ ] **Step 3: Write minimal implementation** — in `Shared/LimitEnforcement.swift`: + +Add the member after `var sessions = OpenSessionStore()`: +```swift + /// Confirmed daily-activity starts, used to reject pre-boundary stale flushes. + var dayStarts = DayStartStore() +``` + +Replace `handleDayStart` with: +```swift + func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) { + guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, + !snapshot.isPaused(at: now) + else { return } + confirmDayStart(ruleID: ruleID, kind: snapshot.kind, now: now, calendar: calendar) + let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) + switch snapshot.kind { + case .schedule: + break + case .openLimit: + if snapshot.isScheduledToday(at: now, calendar: calendar) { + shield(snapshot) + } else { + shields.clearShield(ruleID: ruleID) + } + case .timeLimit: + if snapshot.limitReached(given: usage, at: now), + snapshot.isScheduledToday(at: now, calendar: calendar) { + shield(snapshot) + } else { + shields.clearShield(ruleID: ruleID) + } + } + } + + /// Records today as the confirmed interval start for `ruleID`. On a genuine + /// new-day transition for a time-limit rule, zeroes today's ledger once so a + /// stale pre-boundary checkpoint cannot survive; a spurious same-day re-fire + /// must not erase legitimate usage. + private func confirmDayStart( + ruleID: UUID, kind: RuleKind, now: Date, calendar: Calendar + ) { + let today = calendar.startOfDay(for: now) + guard dayStarts.confirmedStart(for: ruleID) != today else { return } + dayStarts.setConfirmedStart(today, for: ruleID) + if kind == .timeLimit { + ledger.setUsage(RuleUsage(), for: ruleID, onDayContaining: now, calendar: calendar) + } + } +``` +(Note: `limitReached(given:at:)` gets its `at:` parameter in Task B4; until then call it as `limitReached(given: usage)`. If executing strictly in order, write `snapshot.limitReached(given: usage)` here and add `at: now` in B4.) + +Add the gate to `handleUsageMinutes`, immediately after the magnitude guard: +```swift + guard minutes <= minutesSinceMidnight else { return } + // Reject events that arrive before today's interval boundary has been + // observed — yesterday's batched checkpoints flushed across midnight. + guard dayStarts.hasConfirmedStart(for: ruleID, onDayContaining: now, calendar: calendar) + else { return } +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add Shared/LimitEnforcement.swift OpenAppLockTests/SchedulingTests.swift +git commit # "fix: gate time-limit usage on a confirmed day-start" + trailers +``` + +--- + +## Task A4: Foreground safety net (4c) + +**Files:** +- Modify: `OpenAppLock/Services/RuleEnforcer.swift` (init + `refresh`) +- Test: `OpenAppLockTests/RuleEnforcerTests.swift` + +**Interfaces:** +- Consumes: `DayStartStore` (A1). +- Produces: `RuleEnforcer.init(..., dayStarts: DayStartStore = DayStartStore())`. + +- [ ] **Step 1: Write the failing test** — add to `RuleEnforcerTests`: + +```swift + @Test("Refresh establishes today's confirmed day-start for a time-limit rule") + func refreshEstablishesConfirmedStart() { + let shields = MockShieldController() + let suite = "enforcer-daystart-\(UUID().uuidString)" + let dayStarts = DayStartStore(defaults: UserDefaults(suiteName: suite)!) + let enforcer = RuleEnforcer(shields: shields, dayStarts: dayStarts) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig()), days: Weekday.everyDay) + + #expect(dayStarts.confirmedStart(for: rule.id) == nil) + enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + #expect(dayStarts.confirmedStart(for: rule.id) == utc.startOfDay(for: mondayDuringWork)) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/RuleEnforcerTests`. Expected: FAIL to compile (`dayStarts:` arg doesn't exist). + +- [ ] **Step 3: Write minimal implementation** — in `OpenAppLock/Services/RuleEnforcer.swift`: + +Add a stored property and init parameter: +```swift + private let dayStarts: DayStartStore +``` +In `init`, add `dayStarts: DayStartStore = DayStartStore()` as the last parameter and assign `self.dayStarts = dayStarts`. + +In `refresh`, inside the `for rule in rules` loop, after the pause-expiry block and before computing `usage`: +```swift + // 4c safety net: a skipped monitor `intervalDidStart` would block + // usage recording all day; establish today's confirmed start from + // the foreground (no zeroing — preserve any legitimate accrual). + if rule.kind == .timeLimit, rule.isEnabled, + dayStarts.confirmedStart(for: rule.id) != calendar.startOfDay(for: now) { + dayStarts.setConfirmedStart(calendar.startOfDay(for: now), for: rule.id) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/RuleEnforcerTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Services/RuleEnforcer.swift OpenAppLockTests/RuleEnforcerTests.swift +git commit # "feat: establish confirmed day-start from the foreground enforcer" + trailers +``` + +--- + +## Task B1: Collapse the threshold chain to one block event (5a) + +**Files:** +- Modify: `Shared/MonitoringPlan.swift` (`minuteEvents(forLimit:)` → `blockEvent(forLimit:)`) +- Modify: `OpenAppLock/Services/RuleScheduler.swift` (call site, ~line 70) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`MonitoringPlanTests`, `RuleSchedulerTests`) + +**Interfaces:** +- Produces: `static func blockEvent(forLimit limitMinutes: Int) -> [String: Int]` (one entry). + +- [ ] **Step 1: Write the failing tests.** + +Replace `MonitoringPlanTests.minuteEvents` with: +```swift + @Test("A time limit registers a single block event at the budget") + func blockEvent() { + let events = MonitoringPlan.blockEvent(forLimit: 45) + #expect(events.count == 1) + #expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45) + #expect( + MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 45)) == 45) + #expect(MonitoringPlan.minutes(fromEventName: "nope") == nil) + } +``` + +In `RuleSchedulerTests.startsMonitoring`, change: +```swift + #expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes) +``` +to: +```swift + #expect(monitor.startedEvents[name]?.count == 1) + #expect(monitor.startedEvents[name]?[MonitoringPlan.minuteEventName(for: rule.dailyLimitMinutes)] == rule.dailyLimitMinutes) +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/MonitoringPlanTests` and `OpenAppLockTests/RuleSchedulerTests`. Expected: FAIL (`blockEvent` undefined). + +- [ ] **Step 3: Write minimal implementation.** + +In `Shared/MonitoringPlan.swift`, replace `minuteEvents(forLimit:)` with: +```swift + /// The single cumulative-usage checkpoint for a time-limit rule: one event + /// at the budget, used by the monitor as the background block trigger. Live + /// sub-budget progress comes from the DeviceActivityReport extension, not a + /// per-minute chain (Screen Time batches sub-budget thresholds unreliably). + static func blockEvent(forLimit limitMinutes: Int) -> [String: Int] { + let minutes = max(1, limitMinutes) + return [minuteEventName(for: minutes): minutes] + } +``` + +In `OpenAppLock/Services/RuleScheduler.swift`, change: +```swift + let events = + rule.kind == .timeLimit + ? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes) + : [:] +``` +to: +```swift + let events = + rule.kind == .timeLimit + ? MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes) + : [:] +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/MonitoringPlanTests` and `OpenAppLockTests/RuleSchedulerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/MonitoringPlan.swift OpenAppLock/Services/RuleScheduler.swift OpenAppLockTests/SchedulingTests.swift +git commit # "feat: collapse time-limit threshold chain to a single block event" + trailers +``` + +--- + +## Task B2: Authoritative usage fields + effective resolver (5b) + +**Files:** +- Modify: `Shared/UsageLedger.swift` (`RuleUsage`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageLedgerTests`) + +**Interfaces:** +- Produces: `RuleUsage.authoritativeMinutesUsed: Int?`, `RuleUsage.authoritativeAsOf: Date?`, `static RuleUsage.authoritativeFreshness: TimeInterval`, `func effectiveMinutesUsed(asOf: Date, freshness: TimeInterval) -> Int`. + +- [ ] **Step 1: Write the failing tests** — add to `UsageLedgerTests`: + +```swift + @Test("Effective minutes prefer a fresh authoritative reading, else fall back") + func effectiveMinutes() { + let now = date(2025, 1, 6, 10, 0) + var usage = RuleUsage(minutesUsed: 12) + // No authoritative reading → threshold count. + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + // Fresh authoritative → wins. + usage.authoritativeMinutesUsed = 20 + usage.authoritativeAsOf = now.addingTimeInterval(-30) + #expect(usage.effectiveMinutesUsed(asOf: now) == 20) + // Stale authoritative → threshold fallback. + usage.authoritativeAsOf = now.addingTimeInterval(-600) + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + } + + @Test("Usage round-trips authoritative fields and decodes legacy blobs") + func authoritativeCodable() throws { + var usage = RuleUsage(minutesUsed: 5, opensUsed: 2) + usage.authoritativeMinutesUsed = 30 + usage.authoritativeAsOf = date(2025, 1, 6, 10, 0) + let data = try JSONEncoder().encode(usage) + #expect(try JSONDecoder().decode(RuleUsage.self, from: data) == usage) + + // A blob written before the authoritative fields existed still decodes. + let legacy = Data(#"{"minutesUsed":7,"opensUsed":1}"#.utf8) + let decoded = try JSONDecoder().decode(RuleUsage.self, from: legacy) + #expect(decoded.minutesUsed == 7) + #expect(decoded.authoritativeMinutesUsed == nil) + #expect(decoded.authoritativeAsOf == nil) + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: FAIL (no such members). + +- [ ] **Step 3: Write minimal implementation** — in `Shared/UsageLedger.swift`, replace the `RuleUsage` struct: + +```swift +struct RuleUsage: Codable, Equatable { + var minutesUsed = 0 + var opensUsed = 0 + /// The true daily total written by the DeviceActivityReport extension while + /// the app is foreground; preferred over `minutesUsed` when fresh. + var authoritativeMinutesUsed: Int? + /// When the authoritative total was computed. + var authoritativeAsOf: Date? + + /// How long an authoritative reading is trusted before falling back to the + /// threshold count. Tunable on device. + static let authoritativeFreshness: TimeInterval = 120 + + /// The daily minutes to use for display and the block decision: the report's + /// authoritative total when fresh, else the threshold count. + func effectiveMinutesUsed( + asOf now: Date, freshness: TimeInterval = RuleUsage.authoritativeFreshness + ) -> Int { + if let authoritative = authoritativeMinutesUsed, let asOf = authoritativeAsOf, + abs(now.timeIntervalSince(asOf)) <= freshness { + return authoritative + } + return minutesUsed + } +} +``` +(Optional properties get an implicit `nil` default, so the existing memberwise calls `RuleUsage(minutesUsed:)` / `RuleUsage(minutesUsed:opensUsed:)` keep compiling, and synthesized `Decodable` treats them as `decodeIfPresent` so legacy blobs decode.) + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/UsageLedger.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: add authoritative usage fields and effective-minutes resolver" + trailers +``` + +--- + +## Task B3: UsageLedger.recordAuthoritativeMinutes (5b) + +**Files:** +- Modify: `Shared/UsageLedger.swift` (add method) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageLedgerTests`) + +**Interfaces:** +- Produces: `func recordAuthoritativeMinutes(_ minutes: Int, for: UUID, onDayContaining: Date, asOf: Date, calendar: Calendar)`. + +- [ ] **Step 1: Write the failing test** — add to `UsageLedgerTests`: + +```swift + @Test("Authoritative minutes overwrite without disturbing the threshold count") + func recordAuthoritative() { + let ledger = makeLedger() + let id = UUID() + ledger.recordMinutesUsed(40, for: id, onDayContaining: monday, calendar: utc) + + ledger.recordAuthoritativeMinutes( + 12, for: id, onDayContaining: monday, asOf: monday, calendar: utc) + let read = ledger.usage(for: id, onDayContaining: monday, calendar: utc) + #expect(read.minutesUsed == 40) // threshold untouched + #expect(read.authoritativeMinutesUsed == 12) // authoritative recorded + #expect(read.authoritativeAsOf == monday) + // Effective prefers the (fresh) authoritative figure. + #expect(read.effectiveMinutesUsed(asOf: monday) == 12) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: FAIL (no such method). + +- [ ] **Step 3: Write minimal implementation** — in `Shared/UsageLedger.swift`, add to `UsageLedger` (after `recordMinutesUsed`): + +```swift + /// Records the report's authoritative daily total without disturbing the + /// monotonic threshold count. + func recordAuthoritativeMinutes( + _ minutes: Int, for ruleID: UUID, onDayContaining date: Date, asOf: Date, + calendar: Calendar = .current + ) { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.authoritativeMinutesUsed = minutes + usage.authoritativeAsOf = asOf + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/UsageLedger.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: record authoritative usage minutes in the ledger" + trailers +``` + +--- + +## Task B4: limitReached uses effective minutes (5c) + +**Files:** +- Modify: `OpenAppLock/Logic/RuleStatus.swift` (`BlockingRule.limitReached`, `status`) +- Modify: `Shared/RuleSnapshot.swift` (`RuleSnapshot.limitReached`) +- Modify: `Shared/LimitEnforcement.swift` (3 call sites pass `now`) +- Modify: `Shared/UninstallProtectionPolicy.swift` (1 call site passes `now`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageStatusTests`, `UsageEnforcementTests`) + +**Interfaces:** +- Produces: `limitReached(given: RuleUsage, at now: Date = .now) -> Bool` on both `BlockingRule` and `RuleSnapshot`. + +- [ ] **Step 1: Write the failing tests** — add to `UsageStatusTests`: + +```swift + @Test("A fresh authoritative reading below budget keeps a rule inactive") + func freshAuthoritativeBelowBudgetInactive() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) // threshold says spent (phantom) + usage.authoritativeMinutesUsed = 5 // report says 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect(!rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } + + @Test("A fresh authoritative reading at budget blocks even if threshold lags") + func freshAuthoritativeAtBudgetBlocks() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 10) + usage.authoritativeMinutesUsed = 45 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect( + rule.status(at: mondayMorning, calendar: utc, usage: usage) + == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("A stale authoritative reading falls back to the threshold count") + func staleAuthoritativeUsesThreshold() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-600) // stale + #expect(rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } +``` + +Add to `UsageEnforcementTests`: +```swift + @Test("A fresh authoritative reading below budget clears a phantom block") + func freshAuthoritativeClearsPhantomBlock() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) + var usage = RuleUsage(minutesUsed: 45) // threshold phantom + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + ledger.usageByRule[rule.id] = usage + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs.isEmpty) // authoritative wins → not blocked + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageStatusTests` and `OpenAppLockTests/UsageEnforcementTests`. Expected: FAIL (status uses raw `minutesUsed`). + +- [ ] **Step 3: Write minimal implementation.** + +In `OpenAppLock/Logic/RuleStatus.swift`, change `limitReached`: +```swift + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { + switch configuration { + case .schedule: false + case .timeLimit(let config): usage.effectiveMinutesUsed(asOf: now) >= config.dailyLimitMinutes + case .openLimit(let config): usage.opensUsed >= config.maxOpens + } + } +``` +In the same file, in `status(...)`, change the limit branch call from `limitReached(given: usage)` to `limitReached(given: usage, at: now)`. + +In `Shared/RuleSnapshot.swift`, change `limitReached`: +```swift + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { + switch kind { + case .schedule: false + case .timeLimit: usage.effectiveMinutesUsed(asOf: now) >= dailyLimitMinutes + case .openLimit: usage.opensUsed >= maxOpens + } + } +``` + +In `Shared/LimitEnforcement.swift`, pass `now` at the three `snapshot.limitReached(given: usage)` calls (in `handleDayStart`, `handleUsageMinutes`, `handleOpenRequest`): `snapshot.limitReached(given: usage, at: now)`. + +In `Shared/UninstallProtectionPolicy.swift` line ~61, change `snapshot.limitReached(given: usage)` to `snapshot.limitReached(given: usage, at: now)`. + +- [ ] **Step 4: Run the full suite to verify pass + no regressions** — `RunSomeTests` for `OpenAppLockTests/UsageStatusTests`, `OpenAppLockTests/UsageEnforcementTests`, `OpenAppLockTests/LimitEnforcementTests`, `OpenAppLockTests/UninstallProtectionEnforcerTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Logic/RuleStatus.swift Shared/RuleSnapshot.swift Shared/LimitEnforcement.swift Shared/UninstallProtectionPolicy.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: use effective (authoritative-aware) minutes in limitReached" + trailers +``` + +--- + +## Task B5: Usage display uses effective minutes (5c) + +**Files:** +- Modify: `OpenAppLock/Logic/UsageDisplay.swift` (`usagePhrase`) +- Modify: `OpenAppLock/Logic/RuleStatus.swift` (`rowContext` passes `now` to `usagePhrase`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageDisplayTests`) + +**Interfaces:** +- Produces: `UsageDisplay.usagePhrase(for: BlockingRule, usage: RuleUsage, asOf now: Date) -> String`. + +- [ ] **Step 1: Write the failing tests.** + +Update the three direct callers in `UsageDisplayTests` to pass `asOf: now`: +```swift + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") +``` +(in `timeLimitStrings`), +```swift + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage, asOf: now) == "2 of 5 opens") +``` +(in `openLimitStrings`), and +```swift + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over, asOf: now) == "45m of 45m used") +``` +(in `overshootClamps`). + +Add a new test: +```swift + @Test("Usage phrase reflects a fresh authoritative reading") + func usagePhrasePrefersFreshAuthoritative() { + var usage = RuleUsage(minutesUsed: 5) + usage.authoritativeMinutesUsed = 18 + usage.authoritativeAsOf = now.addingTimeInterval(-10) + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageDisplayTests`. Expected: FAIL (signature mismatch). + +- [ ] **Step 3: Write minimal implementation.** + +In `OpenAppLock/Logic/UsageDisplay.swift`, change `usagePhrase`: +```swift + static func usagePhrase(for rule: BlockingRule, usage: RuleUsage, asOf now: Date) -> String { + switch rule.configuration { + case .schedule: + "" + case .timeLimit(let config): + "\(min(usage.effectiveMinutesUsed(asOf: now), config.dailyLimitMinutes))m of " + + "\(config.dailyLimitMinutes)m used" + case .openLimit(let config): + "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens" + } + } +``` + +In `OpenAppLock/Logic/RuleStatus.swift`, in `rowContext`, change: +```swift + ? UsageDisplay.usagePhrase(for: self, usage: usage) +``` +to: +```swift + ? UsageDisplay.usagePhrase(for: self, usage: usage, asOf: now) +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/UsageDisplayTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Logic/UsageDisplay.swift OpenAppLock/Logic/RuleStatus.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: show effective (authoritative-aware) minutes in usage strings" + trailers +``` + +--- + +## Task B5.5: Full regression checkpoint + +- [ ] **Step 1: Run the whole unit suite** — Xcode MCP `RunAllTests` (or `RunSomeTests` for `OpenAppLockTests`). Expected: PASS for every suite. Fix any regression before proceeding to the device-only tasks. No commit (verification only). + +--- + +## Task B6: OpenAppLockReport extension target (pbxproj hand-edit) — device-only + +> Cannot be unit-tested. The gate is: the project opens, `plutil -lint` passes, and the app + all four extensions build via the Xcode MCP. + +**Files:** +- Create: `OpenAppLockReport/Info.plist` +- Create: `OpenAppLockReport/OpenAppLockReport.entitlements` +- Modify: `OpenAppLock.xcodeproj/project.pbxproj` + +- [ ] **Step 1: Create the Info.plist** — `OpenAppLockReport/Info.plist`: + +```xml + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.report-extension + + + +``` + +- [ ] **Step 2: Create the entitlements** — `OpenAppLockReport/OpenAppLockReport.entitlements` (identical to `OpenAppLockMonitor.entitlements`): + +```xml + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + +``` + +- [ ] **Step 3: Add the target to project.pbxproj** — read the file and replicate every `OpenAppLockMonitor` object for a new `OpenAppLockReport` target, with fresh unique 24-hex IDs (the existing extensions use a synthetic `E40000000000000000000001`–`0003` scheme for their build-configuration lists; continue with `…0004` for the report's list and unique hex for the rest). Specifically duplicate and adapt: + - the `PBXNativeTarget` (productType `com.apple.product-type.app-extension`), its three build phases (Sources, Frameworks, Resources), and its `buildConfigurationList`; + - the `XCBuildConfiguration` Debug + Release, setting `PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report`, `INFOPLIST_FILE = OpenAppLockReport/Info.plist`, `CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements`, same `IPHONEOS_DEPLOYMENT_TARGET`/team/`SWIFT_VERSION`/`GENERATE_INFOPLIST_FILE` settings as the monitor, and `INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport`; + - the `.appex` `PBXFileReference` (product) and the group entries for the `OpenAppLockReport/` folder + its files; + - a `PBXBuildFile` + `PBXTargetDependency` so the app target **embeds** the new `.appex` (add it to the app's existing "Embed App Extensions"/Copy Files (Plug-ins) phase and to `dependencies`). + - the **Sources** build phase must list the report's own Swift files (Task B7) **and** the Shared files they need: `AppGroup.swift`, `RuleKind.swift`, `Weekday.swift`, `RuleSchedule.swift`, `RuleConfiguration.swift`, `RuleSnapshot.swift`, `UsageLedger.swift`, `ShieldController.swift` (for `AppSelectionCodec`), `DeviceActivityReportContext.swift`. (Mirror the Shared files already in the monitor target, plus these.) + +- [ ] **Step 4: Lint the project file** — Run: `plutil -lint OpenAppLock.xcodeproj/project.pbxproj`. Expected: `OK`. If not, the edit is malformed — fix before building. + +- [ ] **Step 5: Build** — Xcode MCP `BuildProject` (simulator destination). Expected: build **fails** with "missing file/symbol" only if Task B7 files don't exist yet — that is acceptable here; the gate for this task is that the project parses and the new target appears. If the report Swift files already exist, expect a clean build. (Practical ordering: do B7's file creation, then return here to build green.) + +- [ ] **Step 6: Commit** + +```bash +git add OpenAppLockReport/Info.plist OpenAppLockReport/OpenAppLockReport.entitlements OpenAppLock.xcodeproj/project.pbxproj +git commit # "build: add OpenAppLockReport DeviceActivityReport extension target" + trailers +``` + +--- + +## Task B7: Report extension code + host report view (5d) — device-only + +> Cannot be unit-tested (the simulator delivers no DeviceActivity data and does not render report extensions). Gate: the app + all four extensions build via the Xcode MCP. + +**Files:** +- Create: `Shared/DeviceActivityReportContext.swift` +- Create: `OpenAppLockReport/OpenAppLockReport.swift` +- Create: `OpenAppLockReport/RuleUsageReport.swift` +- Create: `OpenAppLockReport/RuleUsageReportWriter.swift` +- Modify: `OpenAppLock/Views/MainView.swift` +- Modify: `OpenAppLock.xcodeproj/project.pbxproj` (add the three report Swift files + `DeviceActivityReportContext.swift` to the report target; add `DeviceActivityReportContext.swift` to the app target) + +- [ ] **Step 1: Shared context** — `Shared/DeviceActivityReportContext.swift`: + +```swift +// +// DeviceActivityReportContext.swift +// OpenAppLock +// + +import DeviceActivity + +extension DeviceActivityReport.Context { + /// The report scene that recomputes authoritative daily usage for limit rules. + static let ruleUsage = Self("Rule Usage") +} +``` + +- [ ] **Step 2: Writer** — `OpenAppLockReport/RuleUsageReportWriter.swift`: + +```swift +// +// RuleUsageReportWriter.swift +// OpenAppLockReport +// + +import DeviceActivity +import FamilyControls +import Foundation + +/// Sums each enabled time-limit rule's true daily usage from Screen Time's own +/// totals and records it as the authoritative figure in the shared ledger. +struct RuleUsageReportWriter { + func write(from data: DeviceActivityResults, now: Date = Date()) async { + let snapshots = RuleSnapshotStore().load() + .filter { $0.kind == .timeLimit && $0.isEnabled } + guard !snapshots.isEmpty else { return } + let selections = snapshots.map { ($0, AppSelectionCodec.decode($0.selectionData)) } + + var secondsByRule: [UUID: Double] = [:] + for await segment in data.flatMap({ $0.activitySegments }) { + for await category in segment.categories { + for await app in category.applications { + guard let token = app.application.token else { continue } + let seconds = app.totalActivityDuration + for (snap, selection) in selections + where selection.applicationTokens.contains(token) { + secondsByRule[snap.id, default: 0] += seconds + } + } + } + } + + let ledger = UsageLedger() + for (snap, _) in selections { + let minutes = Int((secondsByRule[snap.id] ?? 0) / 60) + ledger.recordAuthoritativeMinutes( + minutes, for: snap.id, onDayContaining: now, asOf: now) + } + } +} +``` +(Note: category/web-domain selections are not attributed yet — applications only; confirm coverage on device, see spec §9.) + +- [ ] **Step 3: Scene** — `OpenAppLockReport/RuleUsageReport.swift`: + +```swift +// +// RuleUsageReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +/// Recomputes authoritative daily usage for time-limit rules as a side effect of +/// rendering. The view is intentionally empty — the app consumes the ledger +/// write, not the view. Runs only while the host app foregrounds a +/// `DeviceActivityReport(.ruleUsage, …)`. +struct RuleUsageReport: DeviceActivityReportScene { + let context: DeviceActivityReport.Context = .ruleUsage + let content: (Int) -> EmptyView = { _ in EmptyView() } + + func makeConfiguration( + representing data: DeviceActivityResults + ) async -> Int { + await RuleUsageReportWriter().write(from: data) + return 0 + } +} +``` + +- [ ] **Step 4: Extension entry point** — `OpenAppLockReport/OpenAppLockReport.swift`: + +```swift +// +// OpenAppLockReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +@main +struct OpenAppLockReport: DeviceActivityReportExtension { + var body: some DeviceActivityReportScene { + RuleUsageReport() + } +} +``` + +- [ ] **Step 5: Host the report in MainView** — in `OpenAppLock/Views/MainView.swift`, add imports `DeviceActivity`, `FamilyControls`, `ManagedSettings`, then attach a hidden report as a background on `layout`: + +```swift + layout + .background(ruleUsageReport) + .task { + await enforcementLoop() + } +``` +and add: +```swift + /// An invisible DeviceActivityReport so the report extension recomputes + /// authoritative usage whenever the app is foreground; the app reads the + /// resulting ledger writes on its 30 s refresh loop. + private var ruleUsageReport: some View { + DeviceActivityReport(.ruleUsage, filter: usageFilter) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + private var usageFilter: DeviceActivityFilter { + let calendar = Calendar.current + let interval = DateInterval(start: calendar.startOfDay(for: .now), end: .now) + var apps: Set = [] + var categories: Set = [] + var webDomains: Set = [] + for rule in rules where rule.kind == .timeLimit && rule.isEnabled { + let selection = AppSelectionCodec.decode(rule.appList?.selectionData) + apps.formUnion(selection.applicationTokens) + categories.formUnion(selection.categoryTokens) + webDomains.formUnion(selection.webDomainTokens) + } + return DeviceActivityFilter( + segment: .daily(during: interval), + users: .all, + devices: .init([.iPhone, .iPad]), + applications: apps, + categories: categories, + webDomains: webDomains) + } +``` + +- [ ] **Step 6: Wire files into targets** — in `project.pbxproj`: add the three `OpenAppLockReport/*.swift` files and `Shared/DeviceActivityReportContext.swift` to the **OpenAppLockReport** target's Sources phase; add `Shared/DeviceActivityReportContext.swift` to the **OpenAppLock** app target's Sources phase. + +- [ ] **Step 7: Lint + build** — Run `plutil -lint OpenAppLock.xcodeproj/project.pbxproj` (expect `OK`), then Xcode MCP `BuildProject` (simulator). Expected: clean build of the app + all four extensions. Resolve any "missing symbol in OpenAppLockReport" by adding the named Shared file to the report target's Sources phase. + +- [ ] **Step 8: Commit** + +```bash +git add Shared/DeviceActivityReportContext.swift OpenAppLockReport/ OpenAppLock/Views/MainView.swift OpenAppLock.xcodeproj/project.pbxproj +git commit # "feat: compute authoritative time-limit usage via DeviceActivityReport" + trailers +``` + +--- + +## Task C1: Update docs + memory + +**Files:** +- Modify: `Docs/AGENT_RULES_FEATURE_SPEC.md` (§5.5 Reliability posture) +- Modify: `AGENTS.md` (Known gaps / next steps) +- Modify: `~/.claude/projects/-Users-bchendev-Developer-OpenAppLock/memory/openapplock-issue2-usage-counter.md` (+ MEMORY.md pointer if status changes) + +- [ ] **Step 1: Update the feature spec §5.5** — describe the collapsed single block event, the confirmed-day-start gate, and the DeviceActivityReport authoritative reconciliation (foreground-only); note the residual background-false-block-until-foreground limit. Read the section first and edit in place to stay accurate. + +- [ ] **Step 2: Update AGENTS.md "Known gaps / next steps"** — replace the "stalls at ~14/15m" framing with the new design and the remaining device-verification items (report attribution for categories/web; 4b under-blocking if `intervalDidStart` is skipped; freshness-window tuning). Reference `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`. + +- [ ] **Step 3: Update the issue-2 memory** — note the hardening landed on `feat/time-limit-counting-hardening` (Part A unit-tested; Part B report extension pending on-device verification), so the file reflects current state. + +- [ ] **Step 4: Commit** (repo docs only; the memory file lives outside the repo and is saved separately) + +```bash +git add Docs/AGENT_RULES_FEATURE_SPEC.md AGENTS.md +git commit # "docs: record time-limit hardening in the feature spec and known gaps" + trailers +``` + +--- + +## Self-Review + +- **Spec coverage:** §4a→A2, §4b→A3, §4c→A4, §4d→documented in C1; §5a→B1, §5b→B2+B3, §5c→B4+B5, §5d→B6+B7; §7 test matrix→tasks' TDD steps; §8 sequencing→task order; §9 risks→B6/B7 gates + C1; §10 checklist→C1 / on-device follow-up. `DayStartStore` (§6) → A1. +- **Placeholder scan:** none — every code/test step contains full code; device-only tasks specify exact files, plist/entitlements contents, the pbxproj procedure, and a build gate. +- **Type consistency:** `DayStartStore` API (A1) matches A3/A4 use; `effectiveMinutesUsed(asOf:freshness:)` (B2) matches B4/B5; `recordAuthoritativeMinutes(_:for:onDayContaining:asOf:calendar:)` (B3) matches B7 writer; `blockEvent(forLimit:)` (B1) matches RuleScheduler; `limitReached(given:at:)` (B4) matches all four call sites; `.ruleUsage` context (B7) shared by extension + MainView. From bd56950f9cec86809a59e4af84b35c1b66ae6724 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:26:20 -0400 Subject: [PATCH 03/13] feat: add DayStartStore for confirmed daily-activity starts Per-rule app-group store of the last confirmed daily-activity interval start, so limit enforcement can reject usage checkpoints that arrive before today's boundary has been observed (stale cross-midnight flushes). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLockTests/SchedulingTests.swift | 26 ++++++++++++++++++ Shared/DayStartStore.swift | 38 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 Shared/DayStartStore.swift diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index c2130f2..3b979ff 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -654,3 +654,29 @@ struct ScheduleEnforcementTests { #expect(shields.shieldedRuleIDs.isEmpty) } } + +@MainActor +@Suite("Day-start store") +struct DayStartStoreTests { + private func makeStore() -> DayStartStore { + let name = "daystart-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return DayStartStore(defaults: defaults) + } + + @Test("Confirmed start round-trips and is day-scoped") + func roundTrip() { + let store = makeStore() + let id = UUID() + let monday = date(2025, 1, 6, 10, 0) + #expect(store.confirmedStart(for: id) == nil) + #expect(!store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + + store.setConfirmedStart(utc.startOfDay(for: monday), for: id) + #expect(store.confirmedStart(for: id) == utc.startOfDay(for: monday)) + #expect(store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + // A different day is not confirmed. + #expect(!store.hasConfirmedStart(for: id, onDayContaining: date(2025, 1, 7, 1, 0), calendar: utc)) + } +} diff --git a/Shared/DayStartStore.swift b/Shared/DayStartStore.swift new file mode 100644 index 0000000..bb923fc --- /dev/null +++ b/Shared/DayStartStore.swift @@ -0,0 +1,38 @@ +// +// DayStartStore.swift +// OpenAppLock +// + +import Foundation + +/// Per-rule "last confirmed daily-activity start", written when the monitor's +/// `intervalDidStart` fires (and defensively by the foreground enforcer). Lets +/// limit enforcement reject usage checkpoints that arrive before today's +/// interval boundary has been observed — i.e. yesterday's batched threshold +/// events flushed late across midnight. +final class DayStartStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + func confirmedStart(for ruleID: UUID) -> Date? { + defaults.object(forKey: key(ruleID)) as? Date + } + + func setConfirmedStart(_ dayStart: Date, for ruleID: UUID) { + defaults.set(dayStart, forKey: key(ruleID)) + } + + /// Whether the confirmed start equals the start of the day containing `date`. + func hasConfirmedStart( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> Bool { + confirmedStart(for: ruleID) == calendar.startOfDay(for: date) + } + + private func key(_ ruleID: UUID) -> String { + "dayStart/\(ruleID.uuidString)" + } +} From c06ebf34cee77fd15ff1c4631334069e6f5545f2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:28:00 -0400 Subject: [PATCH 04/13] fix: record time-limit usage only for rules eligible today Move recordMinutesUsed below the isEnabled/kind/\!paused/scheduledToday guards in handleUsageMinutes, so a stale or off-day threshold event can no longer corrupt today's ledger for a rule that can't be active today. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLockTests/SchedulingTests.swift | 19 +++++++++++++++++-- Shared/LimitEnforcement.swift | 4 +++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 3b979ff..dc37e83 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -394,17 +394,32 @@ struct LimitEnforcementTests { } private func snapshot( - kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, + days: Set = Weekday.everyDay, pausedUntil: Date? = nil ) -> RuleSnapshot { RuleSnapshot( id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true, hardMode: false, blockAdultContent: false, selectionModeRaw: "block", - selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue), + selectionData: Data([1]), dayNumbers: days.map(\.rawValue), startMinutes: 0, endMinutes: 0, dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil ) } + @Test("An ineligible rule does not accrue usage from a checkpoint") + func ineligibleRuleDoesNotAccrue() { + let (enforcement, _, ledger, store) = makeEnforcement() + // Weekday-only rule; a checkpoint arrives on a Saturday (not scheduled). + let snap = snapshot(kind: .timeLimit, days: Weekday.weekdays) + store.save([snap]) + let saturday = date(2025, 1, 11, 10, 0) // 2025-01-11 is a Saturday + + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: saturday, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: saturday, calendar: utc).minutesUsed == 0) + } + @Test("Day start shields open-limit rules so opens can be counted") func dayStartShieldsOpenLimit() { let (enforcement, shields, _, store) = makeEnforcement() diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index 7bd8922..7c23696 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -58,12 +58,14 @@ struct LimitEnforcement { now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) guard minutes <= minutesSinceMidnight else { return } - ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) + // Record only for a rule that can actually be active today, so a stale or + // irrelevant event can't corrupt today's ledger for a rule that isn't. guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, snapshot.kind == .timeLimit, !snapshot.isPaused(at: now), snapshot.isScheduledToday(at: now, calendar: calendar) else { return } + ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) if snapshot.limitReached(given: usage) { shield(snapshot) From 7ef8ba9cc36a8a5ac72db73edf6172eff9b99fc7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:31:18 -0400 Subject: [PATCH 05/13] fix: gate time-limit usage on a confirmed day-start handleDayStart now records today as the rule's confirmed daily-activity start (zeroing today's ledger once, only on a genuine new-day transition), and handleUsageMinutes drops any checkpoint that arrives before today's start is confirmed. Closes the pre-boundary race where a stale cross-midnight flush would otherwise pass the magnitude guard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLockTests/SchedulingTests.swift | 41 +++++++++++++++++++++++++- Shared/LimitEnforcement.swift | 22 ++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index dc37e83..11ec615 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -389,7 +389,8 @@ struct LimitEnforcementTests { return ( LimitEnforcement( snapshots: store, ledger: ledger, shields: shields, - sessions: OpenSessionStore(defaults: freshDefaults())), + sessions: OpenSessionStore(defaults: freshDefaults()), + dayStarts: DayStartStore(defaults: freshDefaults())), shields, ledger, store) } @@ -449,6 +450,7 @@ struct LimitEnforcementTests { let (enforcement, shields, ledger, store) = makeEnforcement() let snap = snapshot(kind: .timeLimit, limit: 45) store.save([snap]) + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) @@ -469,6 +471,7 @@ struct LimitEnforcementTests { // late across midnight. It must not be recorded as today's usage, and // must not re-shield apps the user hasn't touched today. let earlyMorning = date(2025, 1, 6, 0, 30) + enforcement.handleDayStart(ruleID: snap.id, now: earlyMorning, calendar: utc) enforcement.handleUsageMinutes(45, ruleID: snap.id, now: earlyMorning, calendar: utc) #expect( @@ -485,6 +488,7 @@ struct LimitEnforcementTests { // 00:45 — 45 minutes have elapsed, so a 45-minute checkpoint is // physically possible today and must be honoured (boundary case). let quarterToOne = date(2025, 1, 6, 0, 45) + enforcement.handleDayStart(ruleID: snap.id, now: quarterToOne, calendar: utc) enforcement.handleUsageMinutes(45, ruleID: snap.id, now: quarterToOne, calendar: utc) #expect( @@ -492,6 +496,41 @@ struct LimitEnforcementTests { #expect(shields.shieldedRuleIDs == [snap.id]) } + @Test("A checkpoint before a confirmed day-start is dropped") + func checkpointBeforeConfirmedStartDropped() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + // No handleDayStart → no confirmed start for today, so the event is a + // pre-boundary residual and must be dropped. + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + #expect(shields.shieldedRuleIDs.isEmpty) + } + + @Test("Day start zeroes today's time-limit ledger once, only on a transition") + func dayStartZeroesOnceOnTransition() { + let (enforcement, _, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit) + store.save([snap]) + // A stale value sitting in today's key (e.g. a pre-boundary write). + ledger.setUsage( + RuleUsage(minutesUsed: 45), for: snap.id, onDayContaining: monday, calendar: utc) + + // First day-start of the day: transition → zeroed. + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + + // A legitimate accrual after the transition... + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + + // ...survives a spurious same-day re-fire (no second zero). + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + } + @Test("An Open press spends one open and lifts the shield") func openRequestSpendsAndLifts() { let (enforcement, shields, ledger, store) = makeEnforcement() diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index 7c23696..faf03b5 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -15,6 +15,8 @@ struct LimitEnforcement { let shields: ShieldApplying /// Granted-open session bookkeeping shared with the foreground enforcer. var sessions = OpenSessionStore() + /// Confirmed daily-activity starts, used to reject pre-boundary stale flushes. + var dayStarts = DayStartStore() /// Midnight (or monitoring start): fresh budgets. Open-limit rules are /// proactively shielded on enabled days so the shield can count opens; @@ -23,6 +25,7 @@ struct LimitEnforcement { guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, !snapshot.isPaused(at: now) else { return } + confirmDayStart(ruleID: ruleID, kind: snapshot.kind, now: now, calendar: calendar) let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) switch snapshot.kind { case .schedule: @@ -43,6 +46,21 @@ struct LimitEnforcement { } } + /// Records today as the confirmed interval start for `ruleID`. On a genuine + /// new-day transition for a time-limit rule, zeroes today's ledger once so a + /// stale pre-boundary checkpoint cannot survive; a spurious same-day re-fire + /// must not erase legitimate usage. + private func confirmDayStart( + ruleID: UUID, kind: RuleKind, now: Date, calendar: Calendar + ) { + let today = calendar.startOfDay(for: now) + guard dayStarts.confirmedStart(for: ruleID) != today else { return } + dayStarts.setConfirmedStart(today, for: ruleID) + if kind == .timeLimit { + ledger.setUsage(RuleUsage(), for: ruleID, onDayContaining: now, calendar: calendar) + } + } + /// A cumulative usage checkpoint fired for a time-limit rule. func handleUsageMinutes( _ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current @@ -57,6 +75,10 @@ struct LimitEnforcement { let minutesSinceMidnight = Int( now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) guard minutes <= minutesSinceMidnight else { return } + // Reject events that arrive before today's interval boundary has been + // observed — yesterday's batched checkpoints flushed late across midnight. + guard dayStarts.hasConfirmedStart(for: ruleID, onDayContaining: now, calendar: calendar) + else { return } // Record only for a rule that can actually be active today, so a stale or // irrelevant event can't corrupt today's ledger for a rule that isn't. From c0417ca16f5ff8e34a15951107ebf2b927f2b609 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:33:06 -0400 Subject: [PATCH 06/13] feat: establish confirmed day-start from the foreground enforcer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuleEnforcer.refresh establishes today's confirmed daily-activity start for enabled time-limit rules when missing (without zeroing), so a skipped monitor intervalDidStart can't block usage recording for the whole day — it self-heals the next time the app is foregrounded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLock/Services/RuleEnforcer.swift | 14 +++++++++++++- OpenAppLockTests/RuleEnforcerTests.swift | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 6fbecb5..100f0a1 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -30,18 +30,23 @@ final class RuleEnforcer { /// App-wide settings (currently just Uninstall Protection) consulted on /// every refresh. private let settings: any AppSettingsReading + /// Confirmed daily-activity starts; the foreground establishes today's start + /// so a skipped monitor callback can't block usage recording all day. + private let dayStarts: DayStartStore init( shields: ShieldApplying, usage: UsageReading = UsageLedger(), scheduler: RuleScheduler? = nil, openSessions: OpenSessionReading = OpenSessionStore(), - settings: any AppSettingsReading = AppSettingsStore() + settings: any AppSettingsReading = AppSettingsStore(), + dayStarts: DayStartStore = DayStartStore() ) { self.shields = shields self.usageReader = usage self.scheduler = scheduler self.openSessions = openSessions self.settings = settings + self.dayStarts = dayStarts } /// The day's usage for a rule (nil for schedule rules, which don't track). @@ -68,6 +73,13 @@ final class RuleEnforcer { if let pausedUntil = rule.pausedUntil, pausedUntil <= now { rule.pausedUntil = nil } + // 4c safety net: a skipped monitor `intervalDidStart` would block + // usage recording all day; establish today's confirmed start from the + // foreground (no zeroing — preserve any legitimate accrual). + if rule.kind == .timeLimit, rule.isEnabled, + dayStarts.confirmedStart(for: rule.id) != calendar.startOfDay(for: now) { + dayStarts.setConfirmedStart(calendar.startOfDay(for: now), for: rule.id) + } let usage = usage(for: rule, at: now, calendar: calendar) let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive if isBlocking { blocking.insert(rule.id) } diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index 2d433d1..6a5afdf 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -27,6 +27,21 @@ struct RuleEnforcerTests { #expect(enforcer.blockingRuleIDs == [active.id]) } + @Test("Refresh establishes today's confirmed day-start for a time-limit rule") + func refreshEstablishesConfirmedStart() { + let shields = MockShieldController() + let suite = "enforcer-daystart-\(UUID().uuidString)" + let dayStarts = DayStartStore(defaults: UserDefaults(suiteName: suite)!) + let enforcer = RuleEnforcer(shields: shields, dayStarts: dayStarts) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig()), days: Weekday.everyDay) + + #expect(dayStarts.confirmedStart(for: rule.id) == nil) + enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + #expect(dayStarts.confirmedStart(for: rule.id) == utc.startOfDay(for: mondayDuringWork)) + } + @Test("Disabled rules are never shielded") func skipsDisabledRules() { let shields = MockShieldController() From 40815894d41b9ff8c854bdaf87b541bd8311178b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:34:47 -0400 Subject: [PATCH 07/13] feat: collapse time-limit threshold chain to a single block event Register only a single minutes- DeviceActivityEvent per time-limit rule instead of minutes-1..N. It serves as the background block trigger; live sub-budget progress now comes from the DeviceActivityReport extension. Shrinks the cross-midnight stale-flush surface to a single event. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLock/Services/RuleScheduler.swift | 2 +- OpenAppLockTests/SchedulingTests.swift | 17 ++++++++++------- Shared/MonitoringPlan.swift | 17 +++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index 9c76d94..68fe7f2 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -67,7 +67,7 @@ final class RuleScheduler { desiredNames.insert(name) let events = rule.kind == .timeLimit - ? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes) + ? MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes) : [:] let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|" + Self.selectionFingerprint(selectionData) diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 11ec615..e06ff57 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -114,13 +114,13 @@ struct MonitoringPlanTests { #expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil) } - @Test("Minute checkpoints cover every minute up to the budget") - func minuteEvents() { - let events = MonitoringPlan.minuteEvents(forLimit: 45) - #expect(events.count == 45) - #expect(events[MonitoringPlan.minuteEventName(for: 1)] == 1) + @Test("A time limit registers a single block event at the budget") + func blockEvent() { + let events = MonitoringPlan.blockEvent(forLimit: 45) + #expect(events.count == 1) #expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45) - #expect(MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 17)) == 17) + #expect( + MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 45)) == 45) #expect(MonitoringPlan.minutes(fromEventName: "nope") == nil) } } @@ -172,7 +172,10 @@ struct RuleSchedulerTests { let name = MonitoringPlan.dailyActivityName(for: rule.id) #expect(monitor.monitoredNames == [name]) - #expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes) + #expect(monitor.startedEvents[name]?.count == 1) + #expect( + monitor.startedEvents[name]?[MonitoringPlan.minuteEventName(for: rule.dailyLimitMinutes)] + == rule.dailyLimitMinutes) #expect(store.snapshot(for: rule.id) != nil) } diff --git a/Shared/MonitoringPlan.swift b/Shared/MonitoringPlan.swift index d17f39d..069dec4 100644 --- a/Shared/MonitoringPlan.swift +++ b/Shared/MonitoringPlan.swift @@ -71,15 +71,12 @@ enum MonitoringPlan { return Int(name.dropFirst(minutePrefix.count)) } - /// Cumulative-usage checkpoints for a time-limit rule: one event per - /// minute up to the budget so remaining time can be displayed live; the - /// final one doubles as the block trigger. (Budgets cap at 240 minutes, - /// comfortably inside DeviceActivity's event capacity.) - static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] { - Dictionary( - uniqueKeysWithValues: (1...max(1, limitMinutes)).map { - (minuteEventName(for: $0), $0) - } - ) + /// The single cumulative-usage checkpoint for a time-limit rule: one event + /// at the budget, used by the monitor as the background block trigger. Live + /// sub-budget progress comes from the DeviceActivityReport extension, not a + /// per-minute chain (Screen Time batches sub-budget thresholds unreliably). + static func blockEvent(forLimit limitMinutes: Int) -> [String: Int] { + let minutes = max(1, limitMinutes) + return [minuteEventName(for: minutes): minutes] } } From 1c05fc237ecdba6a4fb73f7b9aac6483935ca565 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:36:16 -0400 Subject: [PATCH 08/13] feat: add authoritative usage fields and effective-minutes resolver RuleUsage gains optional authoritativeMinutesUsed/authoritativeAsOf and effectiveMinutesUsed(asOf:freshness:), which prefers a fresh authoritative report total and otherwise falls back to the monotonic threshold count. Optionals stay memberwise-omittable and legacy blobs decode unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLockTests/UsageTests.swift | 31 +++++++++++++++++++++++++++++++ Shared/UsageLedger.swift | 21 +++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 4b837ff..3a0e62d 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -71,6 +71,37 @@ struct UsageLedgerTests { #expect(ledger.usage(for: first, onDayContaining: tuesday, calendar: utc) == RuleUsage()) #expect(ledger.usage(for: second, onDayContaining: monday, calendar: utc) == RuleUsage()) } + + @Test("Effective minutes prefer a fresh authoritative reading, else fall back") + func effectiveMinutes() { + let now = date(2025, 1, 6, 10, 0) + var usage = RuleUsage(minutesUsed: 12) + // No authoritative reading → threshold count. + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + // Fresh authoritative → wins. + usage.authoritativeMinutesUsed = 20 + usage.authoritativeAsOf = now.addingTimeInterval(-30) + #expect(usage.effectiveMinutesUsed(asOf: now) == 20) + // Stale authoritative → threshold fallback. + usage.authoritativeAsOf = now.addingTimeInterval(-600) + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + } + + @Test("Usage round-trips authoritative fields and decodes legacy blobs") + func authoritativeCodable() throws { + var usage = RuleUsage(minutesUsed: 5, opensUsed: 2) + usage.authoritativeMinutesUsed = 30 + usage.authoritativeAsOf = date(2025, 1, 6, 10, 0) + let data = try JSONEncoder().encode(usage) + #expect(try JSONDecoder().decode(RuleUsage.self, from: data) == usage) + + // A blob written before the authoritative fields existed still decodes. + let legacy = Data(#"{"minutesUsed":7,"opensUsed":1}"#.utf8) + let decoded = try JSONDecoder().decode(RuleUsage.self, from: legacy) + #expect(decoded.minutesUsed == 7) + #expect(decoded.authoritativeMinutesUsed == nil) + #expect(decoded.authoritativeAsOf == nil) + } } @MainActor diff --git a/Shared/UsageLedger.swift b/Shared/UsageLedger.swift index 1dd1a05..11b492a 100644 --- a/Shared/UsageLedger.swift +++ b/Shared/UsageLedger.swift @@ -11,6 +11,27 @@ import Foundation struct RuleUsage: Codable, Equatable { var minutesUsed = 0 var opensUsed = 0 + /// The true daily total written by the DeviceActivityReport extension while + /// the app is foreground; preferred over `minutesUsed` when fresh. + var authoritativeMinutesUsed: Int? + /// When the authoritative total was computed. + var authoritativeAsOf: Date? + + /// How long an authoritative reading is trusted before falling back to the + /// threshold count. Tunable on device. + static let authoritativeFreshness: TimeInterval = 120 + + /// The daily minutes to use for display and the block decision: the report's + /// authoritative total when fresh, else the threshold count. + func effectiveMinutesUsed( + asOf now: Date, freshness: TimeInterval = RuleUsage.authoritativeFreshness + ) -> Int { + if let authoritative = authoritativeMinutesUsed, let asOf = authoritativeAsOf, + abs(now.timeIntervalSince(asOf)) <= freshness { + return authoritative + } + return minutesUsed + } } /// Read access to per-rule, per-day usage. From e2551bd80f04cdfa38cf3b21cbc5776219dda8e0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:37:39 -0400 Subject: [PATCH 09/13] feat: record authoritative usage minutes in the ledger UsageLedger.recordAuthoritativeMinutes writes the report's true daily total and its timestamp without touching the monotonic threshold count, so the foreground can prefer it while the background block path keeps using threshold events. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLockTests/UsageTests.swift | 16 ++++++++++++++++ Shared/UsageLedger.swift | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 3a0e62d..6a58dbc 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -102,6 +102,22 @@ struct UsageLedgerTests { #expect(decoded.authoritativeMinutesUsed == nil) #expect(decoded.authoritativeAsOf == nil) } + + @Test("Authoritative minutes overwrite without disturbing the threshold count") + func recordAuthoritative() { + let ledger = makeLedger() + let id = UUID() + ledger.recordMinutesUsed(40, for: id, onDayContaining: monday, calendar: utc) + + ledger.recordAuthoritativeMinutes( + 12, for: id, onDayContaining: monday, asOf: monday, calendar: utc) + let read = ledger.usage(for: id, onDayContaining: monday, calendar: utc) + #expect(read.minutesUsed == 40) // threshold untouched + #expect(read.authoritativeMinutesUsed == 12) // authoritative recorded + #expect(read.authoritativeAsOf == monday) + // Effective prefers the (fresh) authoritative figure. + #expect(read.effectiveMinutesUsed(asOf: monday) == 12) + } } @MainActor diff --git a/Shared/UsageLedger.swift b/Shared/UsageLedger.swift index 11b492a..bdc3d41 100644 --- a/Shared/UsageLedger.swift +++ b/Shared/UsageLedger.swift @@ -87,6 +87,18 @@ final class UsageLedger: UsageReading { setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) } + /// Records the report's authoritative daily total without disturbing the + /// monotonic threshold count. + func recordAuthoritativeMinutes( + _ minutes: Int, for ruleID: UUID, onDayContaining date: Date, asOf: Date, + calendar: Calendar = .current + ) { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.authoritativeMinutesUsed = minutes + usage.authoritativeAsOf = asOf + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + } + @discardableResult func recordOpen( for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current From 764f00576b9020adc9a84b143f693e5881aeb85d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:39:57 -0400 Subject: [PATCH 10/13] feat: use effective (authoritative-aware) minutes in limitReached limitReached(given:at:) on BlockingRule and RuleSnapshot now compares the effective minutes (fresh authoritative total, else threshold) against the budget; callers in status, the LimitEnforcement handlers, and UninstallProtectionPolicy thread now. A fresh report total clears a phantom foreground block and blocks ahead of a lagging threshold. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLock/Logic/RuleStatus.swift | 6 ++-- OpenAppLockTests/UsageTests.swift | 48 ++++++++++++++++++++++++++ Shared/LimitEnforcement.swift | 6 ++-- Shared/RuleSnapshot.swift | 4 +-- Shared/UninstallProtectionPolicy.swift | 2 +- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 5b503b1..3aac12e 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -57,7 +57,7 @@ extension BlockingRule { guard kind == .schedule else { if let usage, isScheduledToday(at: now, calendar: calendar), - limitReached(given: usage), + limitReached(given: usage, at: now), let midnight = calendar.nextMidnight(after: now) { if let pausedUntil, pausedUntil > now { return .paused(until: min(pausedUntil, midnight)) @@ -120,10 +120,10 @@ extension BlockingRule { /// Whether the given usage exhausts this rule's daily budget. /// Always false for schedule rules — they block by the clock. - func limitReached(given usage: RuleUsage) -> Bool { + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { switch configuration { case .schedule: false - case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes + case .timeLimit(let config): usage.effectiveMinutesUsed(asOf: now) >= config.dailyLimitMinutes case .openLimit(let config): usage.opensUsed >= config.maxOpens } } diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 6a58dbc..af386b9 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -191,6 +191,35 @@ struct UsageStatusTests { !RulePolicy.canEditAppLists( rules: [rule], usageFor: { _ in usage }, at: mondayMorning, calendar: utc)) } + + @Test("A fresh authoritative reading below budget keeps a rule inactive") + func freshAuthoritativeBelowBudgetInactive() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) // threshold says spent (phantom) + usage.authoritativeMinutesUsed = 5 // report says 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect(!rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } + + @Test("A fresh authoritative reading at budget blocks even if threshold lags") + func freshAuthoritativeAtBudgetBlocks() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 10) + usage.authoritativeMinutesUsed = 45 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect( + rule.status(at: mondayMorning, calendar: utc, usage: usage) + == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("A stale authoritative reading falls back to the threshold count") + func staleAuthoritativeUsesThreshold() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-600) // stale + #expect(rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } } @MainActor @@ -253,6 +282,25 @@ struct UsageEnforcementTests { #expect(shields.shieldedRuleIDs.isEmpty) } + + @Test("A fresh authoritative reading below budget clears a phantom block") + func freshAuthoritativeClearsPhantomBlock() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) + var usage = RuleUsage(minutesUsed: 45) // threshold phantom + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + ledger.usageByRule[rule.id] = usage + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs.isEmpty) // authoritative wins → not blocked + } } @MainActor diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index faf03b5..73b01b7 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -37,7 +37,7 @@ struct LimitEnforcement { shields.clearShield(ruleID: ruleID) } case .timeLimit: - if snapshot.limitReached(given: usage), + if snapshot.limitReached(given: usage, at: now), snapshot.isScheduledToday(at: now, calendar: calendar) { shield(snapshot) } else { @@ -89,7 +89,7 @@ struct LimitEnforcement { else { return } ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) - if snapshot.limitReached(given: usage) { + if snapshot.limitReached(given: usage, at: now) { shield(snapshot) } } @@ -119,7 +119,7 @@ struct LimitEnforcement { !snapshot.isPaused(at: now) else { return false } let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) - guard !snapshot.limitReached(given: usage) else { return false } + guard !snapshot.limitReached(given: usage, at: now) else { return false } ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar) shields.clearShield(ruleID: ruleID) // Mark the session so neither enforcement path re-shields the app until diff --git a/Shared/RuleSnapshot.swift b/Shared/RuleSnapshot.swift index 680066c..c5d58e4 100644 --- a/Shared/RuleSnapshot.swift +++ b/Shared/RuleSnapshot.swift @@ -43,10 +43,10 @@ struct RuleSnapshot: Codable, Equatable { } /// Whether the given usage exhausts this rule's daily budget. - func limitReached(given usage: RuleUsage) -> Bool { + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { switch kind { case .schedule: false - case .timeLimit: usage.minutesUsed >= dailyLimitMinutes + case .timeLimit: usage.effectiveMinutesUsed(asOf: now) >= dailyLimitMinutes case .openLimit: usage.opensUsed >= maxOpens } } diff --git a/Shared/UninstallProtectionPolicy.swift b/Shared/UninstallProtectionPolicy.swift index 15c26bb..c10633e 100644 --- a/Shared/UninstallProtectionPolicy.swift +++ b/Shared/UninstallProtectionPolicy.swift @@ -58,7 +58,7 @@ enum UninstallProtectionPolicy { guard let usage, snapshot.isScheduledToday(at: now, calendar: calendar) else { return false } - return snapshot.limitReached(given: usage) + return snapshot.limitReached(given: usage, at: now) } } } From 87363c545b804f1d873ef19a3c14f2845c2be55b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:41:45 -0400 Subject: [PATCH 11/13] feat: show effective (authoritative-aware) minutes in usage strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usagePhrase(for:usage:asOf:) and rowContext's usedToday check now use effectiveMinutesUsed, so the Usage section reflects the report's true daily total when fresh — fixing the threshold-event display lag — and falls back to the threshold count otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLock/Logic/RuleStatus.swift | 4 ++-- OpenAppLock/Logic/UsageDisplay.swift | 4 ++-- OpenAppLockTests/UsageTests.swift | 14 +++++++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 3aac12e..2d81b61 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -102,9 +102,9 @@ extension BlockingRule { case .disabled, .dormant, .paused: return status.label(relativeTo: now) case .active, .upcoming: - let usedToday = usage.minutesUsed > 0 || usage.opensUsed > 0 + let usedToday = usage.effectiveMinutesUsed(asOf: now) > 0 || usage.opensUsed > 0 return usedToday - ? UsageDisplay.usagePhrase(for: self, usage: usage) + ? UsageDisplay.usagePhrase(for: self, usage: usage, asOf: now) : UsageDisplay.budgetPhrase(for: self) } } diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index 7b311e4..8189473 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -20,12 +20,12 @@ enum UsageDisplay { /// "18m of 45m used" / "2 of 5 opens". Empty for schedule rules, which have /// no usage budget. ("today" is implied — usage always covers the current day.) - static func usagePhrase(for rule: BlockingRule, usage: RuleUsage) -> String { + static func usagePhrase(for rule: BlockingRule, usage: RuleUsage, asOf now: Date) -> String { switch rule.configuration { case .schedule: "" case .timeLimit(let config): - "\(min(usage.minutesUsed, config.dailyLimitMinutes))m of " + "\(min(usage.effectiveMinutesUsed(asOf: now), config.dailyLimitMinutes))m of " + "\(config.dailyLimitMinutes)m used" case .openLimit(let config): "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens" diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index af386b9..ed6b7e3 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -320,13 +320,21 @@ struct UsageDisplayTests { @Test("Time-limit rows show minutes used of the budget") func timeLimitStrings() { let usage = RuleUsage(minutesUsed: 18) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage) == "18m of 45m used") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") + } + + @Test("Usage phrase reflects a fresh authoritative reading") + func usagePhrasePrefersFreshAuthoritative() { + var usage = RuleUsage(minutesUsed: 5) + usage.authoritativeMinutesUsed = 18 + usage.authoritativeAsOf = now.addingTimeInterval(-10) + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") } @Test("Open-limit rows show opens used of the budget") func openLimitStrings() { let usage = RuleUsage(opensUsed: 2) - #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage) == "2 of 5 opens") + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage, asOf: now) == "2 of 5 opens") } /// Limit context adapts: the daily budget while untouched, live usage once @@ -356,7 +364,7 @@ struct UsageDisplayTests { @Test("Overshoot clamps to the budget") func overshootClamps() { let over = RuleUsage(minutesUsed: 60) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over) == "45m of 45m used") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over, asOf: now) == "45m of 45m used") } @Test("Home subtitles prefix the rule kind so the type reads without the icon") From 6b492791009385b20f0e7ee384e009c5ca0ecce9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 20:00:06 -0400 Subject: [PATCH 12/13] feat: compute authoritative time-limit usage via DeviceActivityReport Add the OpenAppLockReport DeviceActivityReport extension (new app-extension target, hand-added to project.pbxproj mirroring the existing extensions). Its scene sums each enabled time-limit rule's true daily usage from Screen Time's per-application totals and records it as the authoritative figure in the shared ledger. MainView hosts an invisible DeviceActivityReport so the scene recomputes whenever the app is foreground; the 30s refresh loop consumes the result. Device-only: the simulator delivers no DeviceActivity data and does not render report extensions, so this is build-verified here and pending on-device verification (see spec section 10). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- OpenAppLock.xcodeproj/project.pbxproj | 139 ++++++++++++++++++ OpenAppLock/Views/MainView.swift | 39 +++++ OpenAppLockReport/Info.plist | 11 ++ .../OpenAppLockReport.entitlements | 12 ++ OpenAppLockReport/OpenAppLockReport.swift | 14 ++ OpenAppLockReport/RuleUsageReport.swift | 23 +++ OpenAppLockReport/RuleUsageReportWriter.swift | 43 ++++++ Shared/DeviceActivityReportContext.swift | 13 ++ 8 files changed, 294 insertions(+) create mode 100644 OpenAppLockReport/Info.plist create mode 100644 OpenAppLockReport/OpenAppLockReport.entitlements create mode 100644 OpenAppLockReport/OpenAppLockReport.swift create mode 100644 OpenAppLockReport/RuleUsageReport.swift create mode 100644 OpenAppLockReport/RuleUsageReportWriter.swift create mode 100644 Shared/DeviceActivityReportContext.swift diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index abefc5c..7f77c6a 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenAppLockMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F10000000000000000000004 /* OpenAppLockReport.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* OpenAppLockReport.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +49,13 @@ remoteGlobalIDString = C10000000000000000000003; remoteInfo = OpenAppLockShieldAction; }; + E20000000000000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = C10000000000000000000004; + remoteInfo = OpenAppLockReport; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -60,6 +68,7 @@ F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */, F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */, F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */, + F10000000000000000000004 /* OpenAppLockReport.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -73,6 +82,7 @@ B10000000000000000000001 /* OpenAppLockMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B10000000000000000000002 /* OpenAppLockShieldConfig.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldConfig.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000004 /* OpenAppLockReport.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockReport.appex; sourceTree = BUILT_PRODUCTS_DIR; }; E60000000000000000000001 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; E60000000000000000000002 /* CI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = CI.xcconfig; sourceTree = ""; }; E60000000000000000000003 /* DeveloperSettings.sample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DeveloperSettings.sample.xcconfig; sourceTree = ""; }; @@ -107,6 +117,13 @@ ); target = C10000000000000000000003 /* OpenAppLockShieldAction */; }; + AB00000000000000000000B4 /* Exceptions for "OpenAppLockReport" folder in "OpenAppLockReport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000004 /* OpenAppLockReport */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -157,6 +174,14 @@ path = OpenAppLockShieldAction; sourceTree = ""; }; + AA00000000000000000000A5 /* OpenAppLockReport */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB00000000000000000000B4 /* Exceptions for "OpenAppLockReport" folder in "OpenAppLockReport" target */, + ); + path = OpenAppLockReport; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -202,6 +227,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D12000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -213,6 +245,7 @@ AA00000000000000000000A2 /* OpenAppLockMonitor */, AA00000000000000000000A3 /* OpenAppLockShieldConfig */, AA00000000000000000000A4 /* OpenAppLockShieldAction */, + AA00000000000000000000A5 /* OpenAppLockReport */, 20A7EDE02E47B7CF0097608D /* OpenAppLockTests */, 20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */, E60000000000000000000010 /* Config */, @@ -229,6 +262,7 @@ B10000000000000000000001 /* OpenAppLockMonitor.appex */, B10000000000000000000002 /* OpenAppLockShieldConfig.appex */, B10000000000000000000003 /* OpenAppLockShieldAction.appex */, + B10000000000000000000004 /* OpenAppLockReport.appex */, ); name = Products; sourceTree = ""; @@ -261,6 +295,7 @@ E30000000000000000000001 /* PBXTargetDependency */, E30000000000000000000002 /* PBXTargetDependency */, E30000000000000000000003 /* PBXTargetDependency */, + E30000000000000000000004 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 20A7EDD02E47B7CD0097608D /* OpenAppLock */, @@ -388,6 +423,29 @@ productReference = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; productType = "com.apple.product-type.app-extension"; }; + C10000000000000000000004 /* OpenAppLockReport */ = { + isa = PBXNativeTarget; + buildConfigurationList = E40000000000000000000004 /* Build configuration list for PBXNativeTarget "OpenAppLockReport" */; + buildPhases = ( + D11000000000000000000004 /* Sources */, + D12000000000000000000004 /* Frameworks */, + D13000000000000000000004 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A5 /* OpenAppLockReport */, + ); + name = OpenAppLockReport; + packageProductDependencies = ( + ); + productName = OpenAppLockReport; + productReference = B10000000000000000000004 /* OpenAppLockReport.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -431,6 +489,7 @@ C10000000000000000000001 /* OpenAppLockMonitor */, C10000000000000000000002 /* OpenAppLockShieldConfig */, C10000000000000000000003 /* OpenAppLockShieldAction */, + C10000000000000000000004 /* OpenAppLockReport */, ); }; /* End PBXProject section */ @@ -478,6 +537,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D13000000000000000000004 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -523,6 +589,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D11000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -551,6 +624,11 @@ target = C10000000000000000000003 /* OpenAppLockShieldAction */; targetProxy = E20000000000000000000003 /* PBXContainerItemProxy */; }; + E30000000000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C10000000000000000000004 /* OpenAppLockReport */; + targetProxy = E20000000000000000000004 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1012,6 +1090,58 @@ }; name = Release; }; + E50000000000000000000041 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockReport/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E50000000000000000000042 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockReport/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1078,6 +1208,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E40000000000000000000004 /* Build configuration list for PBXNativeTarget "OpenAppLockReport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E50000000000000000000041 /* Debug */, + E50000000000000000000042 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 20A7EDC62E47B7CD0097608D /* Project object */; diff --git a/OpenAppLock/Views/MainView.swift b/OpenAppLock/Views/MainView.swift index 36afe01..7b57847 100644 --- a/OpenAppLock/Views/MainView.swift +++ b/OpenAppLock/Views/MainView.swift @@ -3,6 +3,9 @@ // OpenAppLock // +import DeviceActivity +import FamilyControls +import ManagedSettings import SwiftData import SwiftUI @@ -24,6 +27,7 @@ struct MainView: View { var body: some View { layout + .background(ruleUsageReport) .task { await enforcementLoop() } @@ -45,6 +49,41 @@ struct MainView: View { } } + // MARK: - Authoritative usage report + + /// An invisible `DeviceActivityReport` so the report extension recomputes + /// each time-limit rule's true daily usage whenever the app is foreground; + /// the 30 s refresh loop then reads the authoritative totals it writes. + private var ruleUsageReport: some View { + DeviceActivityReport(.ruleUsage, filter: usageFilter) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + /// Today's `.daily` filter over the union of all enabled time-limit rules' + /// selections — the data the report scene attributes back to each rule. + private var usageFilter: DeviceActivityFilter { + let calendar = Calendar.current + let interval = DateInterval(start: calendar.startOfDay(for: .now), end: .now) + var applications: Set = [] + var categories: Set = [] + var webDomains: Set = [] + for rule in rules where rule.kind == .timeLimit && rule.isEnabled { + let selection = AppSelectionCodec.decode(rule.appList?.selectionData) + applications.formUnion(selection.applicationTokens) + categories.formUnion(selection.categoryTokens) + webDomains.formUnion(selection.webDomainTokens) + } + return DeviceActivityFilter( + segment: .daily(during: interval), + users: .all, + devices: .init([.iPhone, .iPad]), + applications: applications, + categories: categories, + webDomains: webDomains) + } + // MARK: - Enforcement /// Changes whenever any rule's blocking-relevant state changes. diff --git a/OpenAppLockReport/Info.plist b/OpenAppLockReport/Info.plist new file mode 100644 index 0000000..afa8fd2 --- /dev/null +++ b/OpenAppLockReport/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.report-extension + + + diff --git a/OpenAppLockReport/OpenAppLockReport.entitlements b/OpenAppLockReport/OpenAppLockReport.entitlements new file mode 100644 index 0000000..9038cbd --- /dev/null +++ b/OpenAppLockReport/OpenAppLockReport.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + diff --git a/OpenAppLockReport/OpenAppLockReport.swift b/OpenAppLockReport/OpenAppLockReport.swift new file mode 100644 index 0000000..c7170aa --- /dev/null +++ b/OpenAppLockReport/OpenAppLockReport.swift @@ -0,0 +1,14 @@ +// +// OpenAppLockReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +@main +struct OpenAppLockReport: DeviceActivityReportExtension { + var body: some DeviceActivityReportScene { + RuleUsageReport() + } +} diff --git a/OpenAppLockReport/RuleUsageReport.swift b/OpenAppLockReport/RuleUsageReport.swift new file mode 100644 index 0000000..bc525d6 --- /dev/null +++ b/OpenAppLockReport/RuleUsageReport.swift @@ -0,0 +1,23 @@ +// +// RuleUsageReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +/// Recomputes authoritative daily usage for time-limit rules as a side effect of +/// rendering. The view is intentionally empty — the app consumes the ledger +/// write, not the view. Runs only while the host app foregrounds a +/// `DeviceActivityReport(.ruleUsage, …)`. +struct RuleUsageReport: DeviceActivityReportScene { + let context: DeviceActivityReport.Context = .ruleUsage + let content: (Int) -> EmptyView = { _ in EmptyView() } + + func makeConfiguration( + representing data: DeviceActivityResults + ) async -> Int { + await RuleUsageReportWriter().write(from: data) + return 0 + } +} diff --git a/OpenAppLockReport/RuleUsageReportWriter.swift b/OpenAppLockReport/RuleUsageReportWriter.swift new file mode 100644 index 0000000..a060991 --- /dev/null +++ b/OpenAppLockReport/RuleUsageReportWriter.swift @@ -0,0 +1,43 @@ +// +// RuleUsageReportWriter.swift +// OpenAppLockReport +// + +import DeviceActivity +import FamilyControls +import Foundation +import SwiftUI + +/// Sums each enabled time-limit rule's true daily usage from Screen Time's own +/// per-application totals and records it as the authoritative figure in the +/// shared ledger. Attribution is by application token; category/web-domain +/// selections are not yet attributed (see spec §9 — confirm on device). +struct RuleUsageReportWriter { + func write(from data: DeviceActivityResults, now: Date = Date()) async { + let snapshots = RuleSnapshotStore().load() + .filter { $0.kind == .timeLimit && $0.isEnabled } + guard !snapshots.isEmpty else { return } + let selections = snapshots.map { ($0, AppSelectionCodec.decode($0.selectionData)) } + + var secondsByRule: [UUID: Double] = [:] + for await segment in data.flatMap(\.activitySegments) { + for await category in segment.categories { + for await app in category.applications { + guard let token = app.application.token else { continue } + let seconds = app.totalActivityDuration + for (snapshot, selection) in selections + where selection.applicationTokens.contains(token) { + secondsByRule[snapshot.id, default: 0] += seconds + } + } + } + } + + let ledger = UsageLedger() + for (snapshot, _) in selections { + let minutes = Int((secondsByRule[snapshot.id] ?? 0) / 60) + ledger.recordAuthoritativeMinutes( + minutes, for: snapshot.id, onDayContaining: now, asOf: now) + } + } +} diff --git a/Shared/DeviceActivityReportContext.swift b/Shared/DeviceActivityReportContext.swift new file mode 100644 index 0000000..2fd6626 --- /dev/null +++ b/Shared/DeviceActivityReportContext.swift @@ -0,0 +1,13 @@ +// +// DeviceActivityReportContext.swift +// OpenAppLock +// + +import DeviceActivity +import SwiftUI + +extension DeviceActivityReport.Context { + /// The report scene that recomputes authoritative daily usage for limit + /// rules. Shared so the host app and the report extension name it the same. + static let ruleUsage = Self("Rule Usage") +} From a1d6eee6b98fc673cc65f6c6821e32195d98cfc2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 20:03:10 -0400 Subject: [PATCH 13/13] docs: record time-limit hardening in the feature spec and known gaps Update spec section 5.5 (single block event, confirmed-day-start gate, the OpenAppLockReport authoritative reconciliation and its foreground-only limit) and the AGENTS.md known gaps with the remaining device-verification items. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U --- AGENTS.md | 15 +++++++++++ Docs/AGENT_RULES_FEATURE_SPEC.md | 44 +++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index edf6f24..759a5e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,21 @@ Gotchas learned the hard way: 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. +- **Time-limit counting hardening** (see + `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`) is implemented but + device-verification is pending. Time limits now register a **single** + `minutes-` block event (not a per-minute chain); the monitor records + usage only for rules eligible today and only after a **confirmed** + daily-activity start (`DayStartStore`), dropping stale cross-midnight + flushes. The new **`OpenAppLockReport`** DeviceActivityReport extension + computes each rule's true daily total while the app is foreground and writes + it to `UsageLedger`; display and the foreground block decision prefer that + authoritative figure when fresh. Verify on device: the Usage counter shows + the true total on app open (no "stalls at ~14/15m" lag); a maxed-out day + does not re-block unused apps the next morning (or clears within one + foreground refresh); report attribution covers category/web-domain + selections (currently only application tokens are summed); and tune + `RuleUsage.authoritativeFreshness` (120s) so the foreground stays fresh. - **Schedule-rule background transitions** are now backed by DeviceActivity: `RuleScheduler` registers a repeating window activity per schedule rule (`sched-`, plus `sched2-` for midnight-crossing windows) and the diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md index 05ebd01..c809f23 100644 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ b/Docs/AGENT_RULES_FEATURE_SPEC.md @@ -440,16 +440,22 @@ Xh", "Xh left") is **derived**, not stored. ### 5.5 Background enforcement architecture (implemented) -- **App group** `group.dev.bchen.OpenAppLock` shares four stores between the +- **App group** `group.dev.bchen.OpenAppLock` shares five 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. + per-day minutes/opens, plus the authoritative daily minutes the + DeviceActivityReport extension writes), `OpenSessionStore` (per-rule expiry + of a granted "Open" session), `DayStartStore` (per-rule last confirmed + daily-activity start), 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. + (`rule-`); time-limit rules carry a **single** cumulative + usage-threshold event at the budget (`minutes-`) over the rule's + app list, used as the background block trigger. Live sub-budget progress + comes from the DeviceActivityReport extension, not a per-minute chain, + because Screen Time batches sub-budget thresholds unreliably (and reusing + per-minute names across days widened the cross-midnight stale surface). - **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, @@ -466,12 +472,15 @@ Xh", "Xh left") is **derived**, not stored. - **`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 + `minutes-` event records usage and shields at the budget — but only + for a rule eligible today (enabled, time-limit, un-paused, scheduled today) + and only once today's interval start has been **confirmed** + (`DayStartStore`). A checkpoint whose minute count exceeds the minutes + elapsed since local midnight, **or** that arrives before today's confirmed + start, is dropped as a stale cross-midnight flush that would otherwise + re-block unused apps. Interval start records the confirmed day-start and + zeroes today's time-limit ledger once on the new-day transition. 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 @@ -491,7 +500,18 @@ Xh", "Xh left") is **derived**, not stored. 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. + re-locks an app mid-session. For **time limits**, the `OpenAppLockReport` + DeviceActivityReport extension computes each rule's true daily total while + the app is foreground and writes it to `UsageLedger` as the authoritative + figure; display and the foreground block decision prefer it (when fresh) + over the threshold count, which fixes the sub-budget display lag and clears a + residual background false-block the moment the app is opened. `refresh` also + establishes today's confirmed day-start when the monitor's interval callback + was skipped, so the confirmed-start gate cannot silently suppress a whole + day's usage recording. A residual background false-block (a stale + `minutes-` flush delivered after the interval boundary) is **not** + prevented in pure background — it is corrected on the next foreground + refresh; fully preventing it would require per-day event names. - **`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