feat: clone Opal's rules feature with hard block enforcement

Rebuild the app around recurring screen-time blocking rules modeled on
Opal's My Apps tab:

- Onboarding with FamilyControls Screen Time authorization
- Apps home with Blocked Apps tiles and live rule cards
- New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery
- Rule editors: time windows (incl. overnight), day picker, app selection
  via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit
- Hard Mode: active hard rules cannot be edited, disabled, deleted, or
  unblocked until their window ends; soft rules pause until next window
- Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer
- 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg
  harness for in-memory storage, mocked authorization, seeded scenarios
- docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording

Known gap: background window transitions and time/open-limit thresholds
need a DeviceActivityMonitor extension; shields currently sync while the
app is running.
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent c760aa696f
commit 3aac2004d2
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,43 @@
//
// RuleEnforcer.swift
// Severed
//
import Foundation
import Observation
/// Turns the current set of rules into shield state: schedule rules with an
/// active, un-paused window are shielded; everything else is cleared.
///
/// Time-limit and open-limit rules need a DeviceActivity monitor extension to
/// react to usage thresholds in the background; they are persisted and shown
/// in the UI but not yet enforced here.
@Observable
final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = []
private let shields: ShieldApplying
init(shields: ShieldApplying) {
self.shields = shields
}
/// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses.
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
var active: Set<UUID> = []
for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
guard rule.kind == .schedule,
rule.status(at: now, calendar: calendar).isActive
else { continue }
active.insert(rule.id)
shields.applyShield(
ruleID: rule.id, selectionData: rule.selectionData, mode: rule.selectionMode
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
}
}