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:
45
Severed/Services/LaunchConfiguration.swift
Normal file
45
Severed/Services/LaunchConfiguration.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// LaunchConfiguration.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Launch-argument configuration used by UI tests: in-memory storage, mocked
|
||||
/// Screen Time authorization, forced onboarding state, and seeded scenarios.
|
||||
struct LaunchConfiguration: Equatable {
|
||||
enum SeedScenario: String {
|
||||
/// One actively blocking rule ("Work Time") and one upcoming rule ("Sleep").
|
||||
case standard
|
||||
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
|
||||
case hardModeActive = "hard-mode-active"
|
||||
}
|
||||
|
||||
var isUITesting = false
|
||||
/// Forces the onboarding-completed flag at launch. Nil leaves stored state alone.
|
||||
var onboardingCompleted: Bool?
|
||||
var seedScenario: SeedScenario?
|
||||
|
||||
static let uiTestingFlag = "-ui-testing"
|
||||
static let onboardingCompletedFlag = "-onboarding-completed"
|
||||
static let onboardingRequiredFlag = "-onboarding-required"
|
||||
static let seedScenarioPrefix = "-seed-scenario="
|
||||
|
||||
static func parse(arguments: [String]) -> LaunchConfiguration {
|
||||
var config = LaunchConfiguration()
|
||||
config.isUITesting = arguments.contains(uiTestingFlag)
|
||||
if arguments.contains(onboardingCompletedFlag) {
|
||||
config.onboardingCompleted = true
|
||||
} else if arguments.contains(onboardingRequiredFlag) {
|
||||
config.onboardingCompleted = false
|
||||
}
|
||||
if let seedArgument = arguments.first(where: { $0.hasPrefix(seedScenarioPrefix) }) {
|
||||
config.seedScenario = SeedScenario(
|
||||
rawValue: String(seedArgument.dropFirst(seedScenarioPrefix.count))
|
||||
)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
static let current = parse(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
43
Severed/Services/RuleEnforcer.swift
Normal file
43
Severed/Services/RuleEnforcer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
65
Severed/Services/SampleRules.swift
Normal file
65
Severed/Services/SampleRules.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// SampleRules.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Builds deterministic rules for UI-test scenarios, positioned relative to
|
||||
/// "now" so an active window is genuinely active whenever the test runs.
|
||||
enum SampleRules {
|
||||
static func seed(
|
||||
_ scenario: LaunchConfiguration.SeedScenario,
|
||||
into context: ModelContext,
|
||||
now: Date = .now,
|
||||
calendar: Calendar = .current
|
||||
) {
|
||||
switch scenario {
|
||||
case .standard:
|
||||
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar))
|
||||
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
|
||||
case .hardModeActive:
|
||||
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar))
|
||||
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
|
||||
}
|
||||
}
|
||||
|
||||
/// A schedule rule whose window started up to an hour ago and runs for
|
||||
/// several hours, clamped so it never crosses midnight accidentally.
|
||||
static func activeRule(
|
||||
named name: String, hardMode: Bool, now: Date, calendar: Calendar = .current
|
||||
) -> BlockingRule {
|
||||
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
|
||||
let start = max(0, nowMinutes - 60)
|
||||
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
|
||||
return BlockingRule(
|
||||
name: name,
|
||||
hardMode: hardMode,
|
||||
days: Weekday.everyDay,
|
||||
startMinutes: start,
|
||||
endMinutes: end
|
||||
)
|
||||
}
|
||||
|
||||
/// A schedule rule starting a couple of hours from now (possibly wrapping
|
||||
/// past midnight, which simply makes it start tomorrow).
|
||||
static func upcomingRule(
|
||||
named name: String, now: Date, calendar: Calendar = .current
|
||||
) -> BlockingRule {
|
||||
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
|
||||
let start = (nowMinutes + 2 * 60) % (24 * 60)
|
||||
let end = (start + 8 * 60) % (24 * 60)
|
||||
return BlockingRule(
|
||||
name: name,
|
||||
days: Weekday.everyDay,
|
||||
startMinutes: start,
|
||||
endMinutes: end
|
||||
)
|
||||
}
|
||||
|
||||
private static func minutesIntoDay(of date: Date, calendar: Calendar) -> Int {
|
||||
let components = calendar.dateComponents([.hour, .minute], from: date)
|
||||
return (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
||||
}
|
||||
}
|
||||
85
Severed/Services/ScreenTimeAuthorization.swift
Normal file
85
Severed/Services/ScreenTimeAuthorization.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// ScreenTimeAuthorization.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum ScreenTimeAuthorizationStatus: Equatable, Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case approved
|
||||
}
|
||||
|
||||
/// Abstracts FamilyControls authorization so views and tests never touch
|
||||
/// `AuthorizationCenter` directly.
|
||||
protocol AuthorizationProviding {
|
||||
var currentStatus: ScreenTimeAuthorizationStatus { get }
|
||||
func requestAuthorization() async throws
|
||||
}
|
||||
|
||||
/// Real Screen Time authorization via FamilyControls.
|
||||
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
|
||||
var currentStatus: ScreenTimeAuthorizationStatus {
|
||||
switch AuthorizationCenter.shared.authorizationStatus {
|
||||
case .approved: .approved
|
||||
case .denied: .denied
|
||||
case .notDetermined: .notDetermined
|
||||
@unknown default: .notDetermined
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization() async throws {
|
||||
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory provider for unit and UI tests.
|
||||
final class MockAuthorizationProvider: AuthorizationProviding {
|
||||
var status: ScreenTimeAuthorizationStatus
|
||||
var requestShouldFail: Bool
|
||||
|
||||
init(status: ScreenTimeAuthorizationStatus = .notDetermined, requestShouldFail: Bool = false) {
|
||||
self.status = status
|
||||
self.requestShouldFail = requestShouldFail
|
||||
}
|
||||
|
||||
var currentStatus: ScreenTimeAuthorizationStatus { status }
|
||||
|
||||
func requestAuthorization() async throws {
|
||||
if requestShouldFail {
|
||||
status = .denied
|
||||
throw FamilyControlsError.authorizationCanceled
|
||||
}
|
||||
status = .approved
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable authorization state for the UI.
|
||||
@Observable
|
||||
final class ScreenTimeAuthorization {
|
||||
private(set) var status: ScreenTimeAuthorizationStatus
|
||||
private(set) var lastRequestFailed = false
|
||||
private let provider: AuthorizationProviding
|
||||
|
||||
init(provider: AuthorizationProviding) {
|
||||
self.provider = provider
|
||||
self.status = provider.currentStatus
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
status = provider.currentStatus
|
||||
}
|
||||
|
||||
func request() async {
|
||||
do {
|
||||
try await provider.requestAuthorization()
|
||||
lastRequestFailed = false
|
||||
} catch {
|
||||
lastRequestFailed = true
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
108
Severed/Services/ShieldController.swift
Normal file
108
Severed/Services/ShieldController.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// ShieldController.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import ManagedSettings
|
||||
|
||||
/// Applies and clears app shields for rules. One implementation talks to
|
||||
/// ManagedSettings; the mock records calls for tests.
|
||||
protocol ShieldApplying: AnyObject {
|
||||
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode)
|
||||
/// Clears every shield except those for the given rule IDs. Covers rules
|
||||
/// that were deleted or expired while the app was not running.
|
||||
func clearShields(except activeRuleIDs: Set<UUID>)
|
||||
}
|
||||
|
||||
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
|
||||
/// are tracked in UserDefaults because ManagedSettings cannot enumerate them.
|
||||
final class ManagedSettingsShieldController: ShieldApplying {
|
||||
private static let trackedIDsKey = "shieldedRuleIDs"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
|
||||
let store = store(for: ruleID)
|
||||
let selection = AppSelectionCodec.decode(selectionData)
|
||||
switch mode {
|
||||
case .block:
|
||||
store.shield.applications =
|
||||
selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
|
||||
store.shield.applicationCategories =
|
||||
selection.categoryTokens.isEmpty ? nil : .specific(selection.categoryTokens)
|
||||
store.shield.webDomains =
|
||||
selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens
|
||||
case .allowOnly:
|
||||
store.shield.applicationCategories = .all(except: selection.applicationTokens)
|
||||
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
|
||||
}
|
||||
track(ruleID: ruleID)
|
||||
}
|
||||
|
||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
|
||||
store(for: ruleID).clearAllSettings()
|
||||
untrack(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
private func store(for ruleID: UUID) -> ManagedSettingsStore {
|
||||
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
|
||||
}
|
||||
|
||||
private var trackedIDs: Set<UUID> {
|
||||
Set((defaults.stringArray(forKey: Self.trackedIDsKey) ?? []).compactMap(UUID.init))
|
||||
}
|
||||
|
||||
private func track(ruleID: UUID) {
|
||||
let ids = trackedIDs.union([ruleID])
|
||||
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
|
||||
}
|
||||
|
||||
private func untrack(ruleID: UUID) {
|
||||
let ids = trackedIDs.subtracting([ruleID])
|
||||
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records shield operations without touching the system. Used by tests and
|
||||
/// UI-test launches.
|
||||
final class MockShieldController: ShieldApplying {
|
||||
private(set) var shieldedRuleIDs: Set<UUID> = []
|
||||
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
||||
|
||||
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
|
||||
shieldedRuleIDs.insert(ruleID)
|
||||
appliedModes[ruleID] = mode
|
||||
}
|
||||
|
||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
||||
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes/decodes `FamilyActivitySelection` for persistence on the rule model.
|
||||
enum AppSelectionCodec {
|
||||
static func encode(_ selection: FamilyActivitySelection) -> Data? {
|
||||
try? JSONEncoder().encode(selection)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data?) -> FamilyActivitySelection {
|
||||
guard let data,
|
||||
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
|
||||
else { return FamilyActivitySelection() }
|
||||
return selection
|
||||
}
|
||||
|
||||
static func count(of selection: FamilyActivitySelection) -> Int {
|
||||
selection.applicationTokens.count
|
||||
+ selection.categoryTokens.count
|
||||
+ selection.webDomainTokens.count
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user