From db81e13563e647528452f933942181d45b897aeb Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 8 Jul 2026 22:50:31 -0400 Subject: [PATCH] fix: run rule enforcement off the main thread to stop the rule-edit UI hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committing a rule or app-list edit could freeze the UI for tens of seconds. The enforcement refresh ran synchronously on the main actor, and DeviceActivityCenter.startMonitoring blocks ~31s per current-day time-limit activity (device-confirmed via timing instrumentation). An app-list selection change restarts both the block and warn activities — ~62s on the UI thread. Move all shield + DeviceActivity + uninstall I/O off the main thread: - Add `actor RuleEnforcementEngine`, which performs the I/O from Sendable `RuleSnapshotDTO`s and serializes overlapping refreshes (edit, 30s loop, scenePhase) so they can't race on DeviceActivityCenter. - `RuleEnforcer` stays @MainActor/@Observable but now does only the SwiftData @Model work (pause expiry, day-start, snapshotting) on main, awaits the engine, and publishes `blockingRuleIDs` back on main. refresh/pause/resume are now async; call sites wrap in Task. - `RuleScheduler.sync` and its plan helpers take `[RuleSnapshotDTO]` instead of `@Model` objects so they can run off-main. - Mark the pure logic / naming / service types nonisolated + Sendable (RulePolicy, RuleStatus, RuleActivation, RuleSchedule, Calendar+NextMidnight, MonitoringPlan, AppSelectionCodec, ShieldApplying, ActivityMonitoring, UsageReading, OpenSessionReading, RuleScheduler, stores). RulePolicy.pause / resume stay @MainActor since they mutate the @Model. AppSettingsStore is not sent off-main — its one Bool is read on main and passed in. Also switch NotificationScheduler to the async UNUserNotificationCenter.add, clearing a pre-existing warning. Behavior-preserving: all 404 tests pass, no concurrency warnings. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PoojuBiBZwpV5UqGuAcHNy --- OpenAppLock/Logic/RulePolicy.swift | 4 +- OpenAppLock/Logic/RuleStatus.swift | 4 +- .../Services/NotificationScheduler.swift | 9 +- OpenAppLock/Services/RuleEnforcer.swift | 272 +++++++++++------- OpenAppLock/Services/RuleScheduler.swift | 84 +++--- OpenAppLock/Views/MainView.swift | 6 +- OpenAppLock/Views/Rules/RuleDetailSheet.swift | 4 +- .../Settings/NotificationSettingsView.swift | 4 +- OpenAppLock/Views/Settings/SettingsView.swift | 2 +- OpenAppLockTests/AppListTests.swift | 4 +- OpenAppLockTests/RuleEnforcerTests.swift | 104 +++---- OpenAppLockTests/RuleSchedulerPlanTests.swift | 12 +- OpenAppLockTests/RuleSchedulerWarnTests.swift | 12 +- OpenAppLockTests/SchedulingTests.swift | 62 ++-- OpenAppLockTests/UsageTests.swift | 12 +- Shared/Enforcement/ShieldController.swift | 15 +- Shared/Models/Calendar+NextMidnight.swift | 2 +- Shared/Models/RuleActivation.swift | 4 +- Shared/Models/RuleSchedule.swift | 2 +- Shared/Platform/MonitoringPlan.swift | 2 +- Shared/Stores/OpenSessionStore.swift | 8 +- .../RuleSnapshotUserDefaultsStore.swift | 2 +- Shared/Stores/UsageLedger.swift | 12 +- 23 files changed, 370 insertions(+), 272 deletions(-) diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index 57b233e..aaefd4d 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -17,7 +17,7 @@ import Foundation /// /// Limit rules block on spent usage rather than the clock, so their gates /// take the day's `RuleUsageDTO`; passing nil treats them as not blocking. -enum RulePolicy { +nonisolated enum RulePolicy { /// True while the rule is actively blocking with Hard Mode on. static func isHardLocked( _ snapshot: RuleSnapshotDTO, usage: RuleUsageDTO? = nil, @@ -122,6 +122,7 @@ enum RulePolicy { /// back to active; the foreground and the background re-arm re-apply the /// shield). @discardableResult + @MainActor static func pause( _ rule: BlockingRule, usage: RuleUsageDTO? = nil, at now: Date = .now, calendar: Calendar = .current @@ -133,6 +134,7 @@ enum RulePolicy { } /// Ends a temporary pause immediately so the block re-engages now. + @MainActor static func resume(_ rule: BlockingRule) { rule.pausedUntil = nil } diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 3829b86..7b70e45 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -6,7 +6,7 @@ import Foundation /// The live state of a rule at a moment in time. Derived, never stored. -enum RuleStatus: Equatable, Sendable { +nonisolated enum RuleStatus: Equatable, Sendable { case disabled /// Enabled but no days selected, so it never fires. case dormant @@ -44,7 +44,7 @@ enum RuleStatus: Equatable, Sendable { } } -extension RuleSnapshotDTO { +nonisolated extension RuleSnapshotDTO { /// Live status of this rule, for the UI. Derived from the shared /// `activation` primitive, with the disabled / dormant distinctions the UI /// needs layered on top: schedule rules block by the clock; limit rules diff --git a/OpenAppLock/Services/NotificationScheduler.swift b/OpenAppLock/Services/NotificationScheduler.swift index f4b45d9..e48f531 100644 --- a/OpenAppLock/Services/NotificationScheduler.swift +++ b/OpenAppLock/Services/NotificationScheduler.swift @@ -38,12 +38,11 @@ nonisolated struct UserNotificationScheduler: LocalNotificationScheduling { content.sound = .default let trigger = UNCalendarNotificationTrigger( dateMatching: planned.dateComponents, repeats: true) - // Explicit completion handler selects the fire-and-forget overload - // (the bare `add(_:)` resolves to `async throws` in this async context). - center.add( + // Already inside the scheduler actor's async context (called + // fire-and-forget from RuleEnforcer), so await the async API directly. + try? await center.add( UNNotificationRequest( - identifier: planned.identifier, content: content, trigger: trigger), - withCompletionHandler: nil) + identifier: planned.identifier, content: content, trigger: trigger)) } } } diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index b4fc69e..99ccc9e 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -14,25 +14,30 @@ import Observation /// Background transitions (and usage tracking itself) belong to the /// DeviceActivity monitor extension; this keeps shields correct while the /// app runs. +/// +/// **Threading.** `refresh`/`pause`/`resume` do only the SwiftData-`@Model` +/// work (expiring pauses, confirming day-starts, snapshotting to +/// `RuleSnapshotDTO`) on the main actor, then hand the Sendable snapshots to +/// `RuleEnforcementEngine` — an actor that performs the shield and +/// DeviceActivity I/O **off** the main thread. This keeps the UI responsive: +/// `DeviceActivityCenter.startMonitoring` can block for tens of seconds, and it +/// must never do so on the main thread. Only `blockingRuleIDs` (the observed UI +/// state) is published back on the main actor. @Observable final class RuleEnforcer { private(set) var blockingRuleIDs: Set = [] - private let shields: ShieldApplying - /// Mirrors rules to the app group and keeps DeviceActivity monitoring in - /// step; nil in UI-test launches. - private let scheduler: RuleScheduler? + /// Performs the shield + DeviceActivity I/O off the main thread and + /// serializes overlapping refreshes (edit, 30 s loop, scenePhase). + private let engine: RuleEnforcementEngine /// Keeps the pre-scheduled "a schedule rule starts in 5 minutes" /// notifications in step with the rules; nil in UI-test launches (and when /// the feature isn't wired). Driven off the same refresh funnel. private let notificationScheduler: NotificationScheduler? /// Day-usage source consulted for limit rules; also exposed to views for - /// the Usage section. + /// the Usage section (read synchronously on the main actor). let usageReader: UsageReading - /// Granted-open sessions, so a proactively-gated open-limit rule is left - /// un-shielded while the user is inside a session they paid an open for. - private let openSessions: OpenSessionReading - /// App-wide settings (currently just Uninstall Protection) consulted on - /// every refresh. + /// App-wide settings (currently just Uninstall Protection). Read on the main + /// actor (it is `@Observable`, main-bound) and its value handed to the engine. 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. @@ -46,16 +51,17 @@ final class RuleEnforcer { settings: any AppSettingsReading = AppSettingsStore(), dayStarts: DayStartStore = DayStartStore() ) { - self.shields = shields self.usageReader = usage - self.scheduler = scheduler self.notificationScheduler = notificationScheduler - self.openSessions = openSessions self.settings = settings self.dayStarts = dayStarts + self.engine = RuleEnforcementEngine( + shields: shields, scheduler: scheduler, usage: usage, openSessions: openSessions) } /// The day's usage for a rule (nil for schedule rules, which don't track). + /// Synchronous main-actor accessor used by views; the engine reads usage + /// independently for its off-main computation. func usage( for snapshot: RuleSnapshotDTO, at now: Date = .now, calendar: Calendar = .current ) -> RuleUsageDTO? { @@ -66,6 +72,10 @@ final class RuleEnforcer { /// Recomputes shields from scratch. Call on launch, on any rule change, /// and periodically while the app is visible. Also expires stale pauses. /// + /// The main-actor part is only the `@Model` work (pause expiry, day-start + /// confirmation, snapshotting); the shield and DeviceActivity I/O runs on + /// `engine` off the main thread, and `blockingRuleIDs` is published back here. + /// /// **Overlapping rules — strictest enforcement wins.** Each rule shields its /// *own* `ManagedSettingsStore`, Screen Time unions shields across stores, /// and a rule only ever writes/clears its own store, so an app is blocked if @@ -80,80 +90,60 @@ final class RuleEnforcer { /// A rule is shielded when it is actively blocking (a schedule window is /// open, or a limit budget is spent) *or* when it is an open-limit rule /// that must gate its apps so opens can be counted. - func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) { + func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) async { let priorBlocking = blockingRuleIDs Diag.log(.enforcer, "refresh: \(rules.count) rules at \(LogTimestamp.string(from: now))") - var blocking: Set = [] - var shielded: Set = [] + // Main actor: the only work that touches the SwiftData `@Model` — expire + // stale pauses, confirm today's start, then flatten to Sendable snapshots. for rule in rules { - let outcome = evaluate(rule, at: now, calendar: calendar) - if outcome.isBlocking { blocking.insert(rule.id) } - if outcome.isShielded { shielded.insert(rule.id) } + expireStalePauseIfNeeded(rule, at: now) + confirmForegroundDayStartIfNeeded(rule, at: now, calendar: calendar) } - shields.clearShields(except: shielded) - commitBlockingSet(blocking, prior: priorBlocking, shieldedCount: shielded.count) - applyUninstallProtection(rules: rules, at: now, calendar: calendar) - scheduler?.sync(rules: rules, at: now, calendar: calendar) - syncStartingSoonNotifications(rules: rules) + let snapshots = rules.map(\.dto) + let uninstallProtectionEnabled = settings.uninstallProtectionEnabled + // Off the main thread: all shield + DeviceActivity I/O (the multi-second + // `startMonitoring` hang lives here). + let outcome = await engine.apply( + snapshots: snapshots, uninstallProtectionEnabled: uninstallProtectionEnabled, + at: now, calendar: calendar) + // Back on the main actor: publish the observed UI state. + commitBlockingSet(outcome.blocking, prior: priorBlocking, shieldedCount: outcome.shieldedCount) + syncStartingSoonNotifications(snapshots: snapshots) } /// Temporarily pauses the rule's current block: sets `pausedUntil` via /// `RulePolicy`, schedules the background re-arm, and refreshes so the - /// shield clears immediately. No-op (returns false) when the rule can't be - /// paused. `pausedUntil` is set before the refresh, so the scheduler's - /// reaping pass keeps the just-started re-arm. + /// shield clears. No-op (returns false) when the rule can't be paused. + /// `pausedUntil` is set before the refresh, so the scheduler's reaping pass + /// keeps the just-started re-arm. @discardableResult func pause( _ rule: BlockingRule, rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current - ) -> Bool { + ) async -> Bool { guard RulePolicy.pause( rule, usage: usage(for: rule.dto, at: now, calendar: calendar), at: now, calendar: calendar) else { return false } if let pausedUntil = rule.pausedUntil { - scheduler?.scheduleResumeReArm(for: rule.id, until: pausedUntil, now: now, calendar: calendar) + await engine.scheduleResumeReArm(for: rule.id, until: pausedUntil, now: now, calendar: calendar) } - refresh(rules: rules, at: now, calendar: calendar) + await refresh(rules: rules, at: now, calendar: calendar) return true } /// Ends a temporary pause now: clears `pausedUntil`, cancels the background - /// re-arm, and refreshes so the shield re-engages immediately. + /// re-arm, and refreshes so the shield re-engages. func resume( _ rule: BlockingRule, rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current - ) { + ) async { RulePolicy.resume(rule) - scheduler?.cancelResumeReArm(for: rule.id) - refresh(rules: rules, at: now, calendar: calendar) + await engine.cancelResumeReArm(for: rule.id) + await refresh(rules: rules, at: now, calendar: calendar) } - /// Runs one rule through the refresh pipeline — expire a stale pause, confirm - /// today's day-start, then decide whether it is actively blocking and whether - /// it should carry a shield (an active block, or an open-limit's proactive - /// gate), applying the shield as a side effect. Returns both facts so - /// `refresh` can accumulate the blocking and shielded sets. - private func evaluate( - _ rule: BlockingRule, at now: Date, calendar: Calendar - ) -> (isBlocking: Bool, isShielded: Bool) { - expireStalePauseIfNeeded(rule, at: now) - confirmForegroundDayStartIfNeeded(rule, at: now, calendar: calendar) - let snapshot = rule.dto - let usage = usage(for: snapshot, at: now, calendar: calendar) - let status = snapshot.status(at: now, calendar: calendar, usage: usage) - let isBlocking = status.isActive - logTimeLimitDecision(rule, usage: usage, isBlocking: isBlocking, at: now) - guard isBlocking || shouldGateOpenLimit(snapshot, at: now, calendar: calendar) else { - let rid = rule.id.uuidString.prefix(8) - Diag.log( - .enforcer, - "rule-\(rid) \(rule.kindRaw): not shielded (status=\(status) enabled=\(rule.isEnabled))") - return (isBlocking, false) - } - applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking) - return (isBlocking, true) - } + // MARK: - Main-actor model work /// Clears a pause that has elapsed so the rule re-arms once the pause lapses. private func expireStalePauseIfNeeded(_ rule: BlockingRule, at now: Date) { @@ -177,15 +167,135 @@ final class RuleEnforcer { Diag.log(.dayStart, "rule-\(rid): foreground confirmed today's start (safety net)") } + /// Publishes the new actively-blocking set and logs when it changes. "Blocked + /// Apps" lists only rules whose budget/window is spent — not the proactive + /// open-limit gate, which surfaces under "Usage" instead. + private func commitBlockingSet( + _ blocking: Set, prior: Set, shieldedCount: Int + ) { + blockingRuleIDs = blocking + guard prior != blocking else { return } + Diag.log( + .enforcer, .event, + "blocking set changed \(prior.count)->\(blocking.count); shielded=\(shieldedCount)") + } + + /// Re-syncs the "starting soon" notifications off the same refresh funnel. The + /// scheduler is an actor (overlapping fire-and-forget calls from the 30 s loop + /// serialize) and fingerprint-gated, so this is cheap when unchanged. + private func syncStartingSoonNotifications(snapshots: [RuleSnapshotDTO]) { + guard let notificationScheduler else { return } + let enabled = NotificationPreferences().scheduleStartEnabled + Task { await notificationScheduler.sync(snapshots: snapshots, enabled: enabled) } + } +} + +/// The result of one off-main enforcement pass, handed back to the main actor. +nonisolated struct EnforcementOutcome: Sendable { + let blocking: Set + let shieldedCount: Int +} + +/// Performs the shield and DeviceActivity I/O that a `refresh` triggers, off the +/// main thread. Being an `actor` serializes overlapping refreshes so concurrent +/// triggers (a rule edit, the 30 s loop, a scenePhase change) can't race on +/// `DeviceActivityCenter` or the stored monitoring fingerprints. It speaks only +/// Sendable `RuleSnapshotDTO`s — never the SwiftData `@Model` — so nothing +/// main-actor-bound crosses the boundary. See `RuleEnforcer` for the split. +actor RuleEnforcementEngine { + private let shields: any ShieldApplying + private let scheduler: RuleScheduler? + private let usageReader: any UsageReading + private let openSessions: any OpenSessionReading + + init( + shields: any ShieldApplying, scheduler: RuleScheduler?, + usage: any UsageReading, openSessions: any OpenSessionReading + ) { + self.shields = shields + self.scheduler = scheduler + self.usageReader = usage + self.openSessions = openSessions + } + + /// Recomputes and applies shields from the given snapshots, reconciles + /// DeviceActivity monitoring, and applies Uninstall Protection. Returns the + /// blocking set for the caller to publish. Runs on the actor's executor — + /// off the main thread. + func apply( + snapshots: [RuleSnapshotDTO], uninstallProtectionEnabled: Bool, + at now: Date, calendar: Calendar + ) -> EnforcementOutcome { + var blocking: Set = [] + var shielded: Set = [] + for snapshot in snapshots { + let outcome = evaluate(snapshot, at: now, calendar: calendar) + if outcome.isBlocking { blocking.insert(snapshot.id) } + if outcome.isShielded { shielded.insert(snapshot.id) } + } + shields.clearShields(except: shielded) + shields.setAppRemovalDenied( + RulePolicy.shouldDenyAppRemoval( + snapshots: snapshots, + enabled: uninstallProtectionEnabled, + usageFor: { usage(for: $0, at: now, calendar: calendar) }, + at: now, calendar: calendar)) + scheduler?.sync(snapshots: snapshots, at: now, calendar: calendar) + return EnforcementOutcome(blocking: blocking, shieldedCount: shielded.count) + } + + /// Starts the background re-arm that re-engages a rule's shield when its + /// temporary pause ends. Off-main so it never blocks the pause tap. + func scheduleResumeReArm(for ruleID: UUID, until pausedUntil: Date, now: Date, calendar: Calendar) { + scheduler?.scheduleResumeReArm(for: ruleID, until: pausedUntil, now: now, calendar: calendar) + } + + /// Cancels a rule's pending pause re-arm (on resume). + func cancelResumeReArm(for ruleID: UUID) { + scheduler?.cancelResumeReArm(for: ruleID) + } + + // MARK: - Per-rule evaluation (off main) + + /// The day's usage for a rule (nil for schedule rules, which don't track). + private func usage( + for snapshot: RuleSnapshotDTO, at now: Date, calendar: Calendar + ) -> RuleUsageDTO? { + guard snapshot.kind != .schedule else { return nil } + return usageReader.usage(for: snapshot.id, onDayContaining: now, calendar: calendar) + } + + /// Decides whether a rule is actively blocking and whether it should carry a + /// shield (an active block, or an open-limit's proactive gate), applying the + /// shield as a side effect. Returns both facts so `apply` can accumulate the + /// blocking and shielded sets. + private func evaluate( + _ snapshot: RuleSnapshotDTO, at now: Date, calendar: Calendar + ) -> (isBlocking: Bool, isShielded: Bool) { + let usage = usage(for: snapshot, at: now, calendar: calendar) + let status = snapshot.status(at: now, calendar: calendar, usage: usage) + let isBlocking = status.isActive + logTimeLimitDecision(snapshot, usage: usage, isBlocking: isBlocking, at: now) + guard isBlocking || shouldGateOpenLimit(snapshot, at: now, calendar: calendar) else { + let rid = snapshot.id.uuidString.prefix(8) + Diag.log( + .enforcer, + "rule-\(rid) \(snapshot.kindRaw): not shielded (status=\(status) enabled=\(snapshot.isEnabled))") + return (isBlocking, false) + } + applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking) + return (isBlocking, true) + } + /// Surfaces the time-limit block decision: the threshold count vs the budget. private func logTimeLimitDecision( - _ rule: BlockingRule, usage: RuleUsageDTO?, isBlocking: Bool, at now: Date + _ snapshot: RuleSnapshotDTO, usage: RuleUsageDTO?, isBlocking: Bool, at now: Date ) { - guard rule.kind == .timeLimit, let usage else { return } - let rid = rule.id.uuidString.prefix(8) + guard snapshot.kind == .timeLimit, let usage else { return } + let rid = snapshot.id.uuidString.prefix(8) Diag.log( .usage, - "timeLimit rule-\(rid) used=\(usage.minutesUsed)/\(rule.dailyLimitMinutes) blocking=\(isBlocking)") + "timeLimit rule-\(rid) used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) blocking=\(isBlocking)") } /// Records the rule's shield and writes it. Allow Only is a Schedule-only @@ -205,42 +315,6 @@ final class RuleEnforcer { ) } - /// Publishes the new actively-blocking set and logs when it changes. "Blocked - /// Apps" lists only rules whose budget/window is spent — not the proactive - /// open-limit gate, which surfaces under "Usage" instead. - private func commitBlockingSet( - _ blocking: Set, prior: Set, shieldedCount: Int - ) { - blockingRuleIDs = blocking - guard prior != blocking else { return } - Diag.log( - .enforcer, .event, - "blocking set changed \(prior.count)->\(blocking.count); shielded=\(shieldedCount)") - } - - /// Uninstall Protection: deny device app removal while the user has opted in - /// and any Hard Mode rule is actively blocking. - private func applyUninstallProtection( - rules: [BlockingRule], at now: Date, calendar: Calendar - ) { - shields.setAppRemovalDenied( - RulePolicy.shouldDenyAppRemoval( - snapshots: rules.map(\.dto), - enabled: settings.uninstallProtectionEnabled, - usageFor: { usage(for: $0, at: now, calendar: calendar) }, - at: now, calendar: calendar)) - } - - /// Re-syncs the "starting soon" notifications off the same refresh funnel. The - /// scheduler is an actor (overlapping fire-and-forget calls from the 30 s loop - /// serialize) and fingerprint-gated, so this is cheap when unchanged. - private func syncStartingSoonNotifications(rules: [BlockingRule]) { - guard let notificationScheduler else { return } - let snapshots = rules.map(\.dto) - let enabled = NotificationPreferences().scheduleStartEnabled - Task { await notificationScheduler.sync(snapshots: snapshots, enabled: enabled) } - } - /// Whether an open-limit rule should carry its proactive gate right now: /// enabled, scheduled today, not paused, and not inside a granted open /// session (which would otherwise be cut short). Mirrors diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index 600f0d9..3f561c6 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -9,7 +9,8 @@ import FamilyControls import Foundation /// Abstracts `DeviceActivityCenter` so scheduling can be unit-tested. -protocol ActivityMonitoring: AnyObject { +/// `nonisolated` + `Sendable` so `RuleScheduler.sync` can run off the main thread. +nonisolated protocol ActivityMonitoring: AnyObject, Sendable { /// Starts (or replaces) an always-on, midnight-to-midnight repeating /// activity. `eventMinutes` maps event names to cumulative usage /// thresholds (in minutes) over the rule's selection. @@ -43,7 +44,11 @@ protocol ActivityMonitoring: AnyObject { /// daily activity (time limits with one usage checkpoint per budget minute). /// Activities are only restarted when their configuration changes, which is /// the purpose of the fingerprint given to each propagated rule. -final class RuleScheduler { +/// +/// `nonisolated` + `Sendable` (no mutable stored state — fingerprints live in +/// `UserDefaults`) so its `sync` can run off the main thread from +/// `RuleEnforcementEngine`. +nonisolated final class RuleScheduler: @unchecked Sendable { private static let fingerprintsKey = "monitoringFingerprints" /// How many upcoming scheduled days a time-limit rule arms ahead (today/next @@ -88,35 +93,39 @@ final class RuleScheduler { let resetsThresholdAccountingOnRestart: Bool } - func sync(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) { - snapshotsUserDefaultsStore.save(rules.map(\.dto)) - Diag.log(.scheduler, "sync: \(rules.count) rules; mirrored snapshots") + /// Reconciles monitoring against the given rule snapshots. Takes + /// `RuleSnapshotDTO`s (not `@Model` `BlockingRule`s) so it can run off the + /// main thread — the caller snapshots on the main actor and hands the + /// Sendable values here. See `RuleEnforcementEngine`. + func sync(snapshots: [RuleSnapshotDTO], at now: Date = .now, calendar: Calendar = .current) { + snapshotsUserDefaultsStore.save(snapshots) + Diag.log(.scheduler, "sync: \(snapshots.count) rules; mirrored snapshots") var plans: [PlannedActivity] = [] - for rule in rules { + for snapshot in snapshots { // A rule must be enabled, have days, and have apps to be monitored. - guard rule.isEnabled, !rule.days.isEmpty, - let selectionData = rule.appList?.selectionData + guard snapshot.isEnabled, !snapshot.days.isEmpty, + let selectionData = snapshot.selectionData else { continue } - switch rule.kind { + switch snapshot.kind { case .timeLimit: // Self-dating per-day activities (block + opt-in warn): a stale // cross-midnight flush carries a prior day key and is dropped. plans.append( contentsOf: dayPlans( - for: rule, selectionData: selectionData, at: now, calendar: calendar)) + for: snapshot, selectionData: selectionData, at: now, calendar: calendar)) case .openLimit: // Open limits carry no usage events and have no stale-flush class; // they keep the single always-on repeating activity. - plans.append(limitPlan(for: rule, selectionData: selectionData)) + plans.append(limitPlan(for: snapshot, selectionData: selectionData)) case .schedule: - plans.append(contentsOf: schedulePlans(for: rule)) + plans.append(contentsOf: schedulePlans(for: snapshot)) } } reconcile(plans) - reapStalePauseActivities(rules: rules) + reapStalePauseActivities(snapshots: snapshots) } /// Starts the one-shot re-arm that re-engages `ruleID`'s shield when its @@ -150,8 +159,8 @@ final class RuleScheduler { /// (that would push its interval forward every refresh and it would never /// fire), and keeps re-arms for not-yet-cleared (still `pausedUntil`) rules /// so a natural expiry's background re-shield still fires. - private func reapStalePauseActivities(rules: [BlockingRule]) { - let pausedRuleIDs = Set(rules.filter { $0.pausedUntil != nil }.map(\.id)) + private func reapStalePauseActivities(snapshots: [RuleSnapshotDTO]) { + let pausedRuleIDs = Set(snapshots.filter { $0.pausedUntil != nil }.map(\.id)) let stale = monitor.monitoredNames.filter { name in guard let id = MonitoringPlan.ruleID(fromPauseActivityName: name) else { return false } return !pausedRuleIDs.contains(id) @@ -166,11 +175,11 @@ final class RuleScheduler { /// events, so a restart has no accrual to lose; it is still fingerprinted /// on kind, budget, and selection to detect the configuration changes that /// should restart the activity (e.g. an app-list swap). - func limitPlan(for rule: BlockingRule, selectionData: Data) -> PlannedActivity { - let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|" + func limitPlan(for snapshot: RuleSnapshotDTO, selectionData: Data) -> PlannedActivity { + let fingerprint = "\(snapshot.kindRaw)|\(snapshot.dailyLimitMinutes)|" + Self.selectionFingerprint(selectionData) return PlannedActivity( - name: MonitoringPlan.dailyActivityName(for: rule.id), + name: MonitoringPlan.dailyActivityName(for: snapshot.id), fingerprint: fingerprint, payload: .daily(selectionData: selectionData, eventMinutes: [:]), resetsThresholdAccountingOnRestart: false) @@ -183,31 +192,31 @@ final class RuleScheduler { /// it. The next day is armed before its midnight, preserving full-day capture /// without a monitor self-arm. func dayPlans( - for rule: BlockingRule, selectionData: Data, + for snapshot: RuleSnapshotDTO, selectionData: Data, at now: Date, calendar: Calendar = .current ) -> [PlannedActivity] { let selectionFP = Self.selectionFingerprint(selectionData) let nudgeOn = NotificationPreferences(defaults: defaults).timeLimitEndingEnabled - let warnEvents = MonitoringPlan.warnEvent(forLimit: rule.dailyLimitMinutes) + let warnEvents = MonitoringPlan.warnEvent(forLimit: snapshot.dailyLimitMinutes) var plans: [PlannedActivity] = [] for dayStart in ScheduledDayPlanner.upcomingScheduledDayStarts( - days: rule.days, from: now, count: Self.dayActivityHorizon, calendar: calendar) + days: snapshot.days, from: now, count: Self.dayActivityHorizon, calendar: calendar) { let dayKey = UsageLedger.dayKey(for: dayStart, calendar: calendar) let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart plans.append( PlannedActivity( - name: MonitoringPlan.dailyActivityName(for: rule.id, dayKey: dayKey), - fingerprint: "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|\(selectionFP)", + name: MonitoringPlan.dailyActivityName(for: snapshot.id, dayKey: dayKey), + fingerprint: "\(snapshot.kindRaw)|\(snapshot.dailyLimitMinutes)|\(selectionFP)", payload: .day( from: dayStart, to: dayEnd, selectionData: selectionData, - eventMinutes: MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes)), + eventMinutes: MonitoringPlan.blockEvent(forLimit: snapshot.dailyLimitMinutes)), resetsThresholdAccountingOnRestart: true)) if nudgeOn, let warnEvents { plans.append( PlannedActivity( - name: MonitoringPlan.warnActivityName(for: rule.id, dayKey: dayKey), - fingerprint: "tlwarn|\(rule.dailyLimitMinutes)|\(selectionFP)", + name: MonitoringPlan.warnActivityName(for: snapshot.id, dayKey: dayKey), + fingerprint: "tlwarn|\(snapshot.dailyLimitMinutes)|\(selectionFP)", payload: .day( from: dayStart, to: dayEnd, selectionData: selectionData, eventMinutes: warnEvents), @@ -221,9 +230,9 @@ final class RuleScheduler { /// crossing). A window encodes only its interval — days, mode and apps are /// read fresh by reconcile() at each callback — so it is fingerprinted on /// start/end alone. - func schedulePlans(for rule: BlockingRule) -> [PlannedActivity] { - let fingerprint = "schedule|\(rule.startMinutes)|\(rule.endMinutes)" - return scheduleWindows(for: rule).map { window in + func schedulePlans(for snapshot: RuleSnapshotDTO) -> [PlannedActivity] { + let fingerprint = "schedule|\(snapshot.startMinutes)|\(snapshot.endMinutes)" + return scheduleWindows(for: snapshot).map { window in PlannedActivity( name: window.name, fingerprint: fingerprint, @@ -346,12 +355,12 @@ final class RuleScheduler { /// map to one activity; midnight-crossing windows split into an evening half /// (to 23:59) and a morning half (from 00:00); a `start == end` window is /// treated as all-day. - private func scheduleWindows(for rule: BlockingRule) -> [(name: String, start: Int, end: Int)] { - let primary = MonitoringPlan.scheduleWindowName(for: rule.id) - let late = MonitoringPlan.scheduleWindowLateName(for: rule.id) + private func scheduleWindows(for snapshot: RuleSnapshotDTO) -> [(name: String, start: Int, end: Int)] { + let primary = MonitoringPlan.scheduleWindowName(for: snapshot.id) + let late = MonitoringPlan.scheduleWindowLateName(for: snapshot.id) let endOfDay = 24 * 60 - 1 - let start = rule.startMinutes - let end = rule.endMinutes + let start = snapshot.startMinutes + let end = snapshot.endMinutes if start < end { return [(name: primary, start: start, end: end)] @@ -374,7 +383,9 @@ final class RuleScheduler { /// Real DeviceActivity scheduling. Each daily activity repeats from midnight /// to 23:59 with usage-threshold events over the rule's selection. -final class DeviceActivityCenterMonitor: ActivityMonitoring { +/// `@unchecked Sendable`: wraps a single `DeviceActivityCenter`; its calls are +/// serialized by the enforcement actor and are safe off the main thread. +nonisolated final class DeviceActivityCenterMonitor: ActivityMonitoring, @unchecked Sendable { private let center = DeviceActivityCenter() var monitoredNames: [String] { @@ -466,7 +477,8 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring { } /// Records scheduling calls for tests. -final class MockActivityMonitor: ActivityMonitoring { +/// `@unchecked Sendable`: a test double; mutations are ordered behind the enforcer's `await`. +nonisolated final class MockActivityMonitor: ActivityMonitoring, @unchecked Sendable { private(set) var startedEvents: [String: [String: Int]] = [:] private(set) var startedWindows: [String: (start: Int, end: Int)] = [:] private(set) var startedOneShots: [String: (start: Date, end: Date)] = [:] diff --git a/OpenAppLock/Views/MainView.swift b/OpenAppLock/Views/MainView.swift index 52841ce..c29a38a 100644 --- a/OpenAppLock/Views/MainView.swift +++ b/OpenAppLock/Views/MainView.swift @@ -66,7 +66,9 @@ struct MainView: View { } private func refreshEnforcement() { - enforcer.refresh(rules: rules) + // Enforcement I/O runs off the main thread inside the enforcer; this + // fire-and-forget Task hands it the current rules and returns immediately. + Task { await enforcer.refresh(rules: rules) } } /// Keeps shields in sync while the app is open, so windows that begin or end @@ -77,7 +79,7 @@ struct MainView: View { // The 30 s foreground backstop. Logged each tick so coverage gaps // (when the app isn't open to run this) are visible as timeline holes. Diag.log(.lifecycle, "refresh trigger: foreground 30s loop") - enforcer.refresh(rules: allRules) + await enforcer.refresh(rules: allRules) try? await Task.sleep(for: .seconds(30)) } } diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index e3f9d55..f602513 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -94,7 +94,7 @@ struct RuleDetailSheet: View { titleVisibility: .visible ) { Button(CopyKey.ruleDetailPauseFor15MinutesAction.resource) { - enforcer.pause(rule, rules: rules) + Task { await enforcer.pause(rule, rules: rules) } pendingPause = false } } message: { @@ -228,7 +228,7 @@ struct RuleDetailSheet: View { if !isEditing { if dto.isPaused(at: now) { Button(CopyKey.ruleDetailResumeBlockingAction.resource) { - enforcer.resume(rule, rules: rules) + Task { await enforcer.resume(rule, rules: rules) } } .accessibilityIdentifier("resumeRuleButton") } else if RulePolicy.canPause(dto, usage: usage, at: now) { diff --git a/OpenAppLock/Views/Settings/NotificationSettingsView.swift b/OpenAppLock/Views/Settings/NotificationSettingsView.swift index 40d8ba2..0ceb49b 100644 --- a/OpenAppLock/Views/Settings/NotificationSettingsView.swift +++ b/OpenAppLock/Views/Settings/NotificationSettingsView.swift @@ -56,7 +56,7 @@ struct NotificationSettingsView: View { Button(CopyKey.notificationsAllowButton.resource) { Task { await authorization.request() - enforcer.refresh(rules: rules) + await enforcer.refresh(rules: rules) } } .accessibilityIdentifier("allowNotificationsButton") @@ -108,7 +108,7 @@ struct NotificationSettingsView: View { get: { settings[keyPath: keyPath] }, set: { newValue in settings[keyPath: keyPath] = newValue - enforcer.refresh(rules: rules) + Task { await enforcer.refresh(rules: rules) } }) } } diff --git a/OpenAppLock/Views/Settings/SettingsView.swift b/OpenAppLock/Views/Settings/SettingsView.swift index c7b45d8..cb0fe8e 100644 --- a/OpenAppLock/Views/Settings/SettingsView.swift +++ b/OpenAppLock/Views/Settings/SettingsView.swift @@ -150,7 +150,7 @@ struct SettingsView: View { guard !isUninstallProtectionLocked else { return } uninstallProtectionOn = newValue settings.uninstallProtectionEnabled = newValue - enforcer.refresh(rules: rules) + Task { await enforcer.refresh(rules: rules) } } ) } diff --git a/OpenAppLockTests/AppListTests.swift b/OpenAppLockTests/AppListTests.swift index bb07d97..110de02 100644 --- a/OpenAppLockTests/AppListTests.swift +++ b/OpenAppLockTests/AppListTests.swift @@ -232,7 +232,7 @@ struct AppListEnforcementTests { let mondayDuringWork = date(2025, 1, 6, 10, 0) @Test("The rule's app-list selection reaches the shield layer") - func forwardsAppListSelection() throws { + func forwardsAppListSelection() async throws { let context = try makeInMemoryContext() let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) @@ -242,7 +242,7 @@ struct AppListEnforcementTests { context.insert(rule) rule.appList = list - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3])) } diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index f9dfc06..95e187e 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -15,20 +15,20 @@ struct RuleEnforcerTests { let mondayEvening = date(2025, 1, 6, 19, 0) @Test("Active schedule rules are shielded; inactive ones are not") - func shieldsActiveRules() { + func shieldsActiveRules() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let active = BlockingRule(name: "Work Time") let weekendOnly = BlockingRule(name: "Weekend Zen", days: Weekday.weekends) - enforcer.refresh(rules: [active, weekendOnly], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [active, weekendOnly], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs == [active.id]) #expect(enforcer.blockingRuleIDs == [active.id]) } @Test("Refresh establishes today's confirmed day-start for a time-limit rule") - func refreshEstablishesConfirmedStart() { + func refreshEstablishesConfirmedStart() async { let shields = MockShieldController() let suite = "enforcer-daystart-\(UUID().uuidString)" let dayStarts = DayStartStore(defaults: UserDefaults(suiteName: suite)!) @@ -38,97 +38,97 @@ struct RuleEnforcerTests { configuration: .timeLimit(TimeLimitConfig()), days: Weekday.everyDay) #expect(dayStarts.confirmedStart(for: rule.id) == nil) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await 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() { + func skipsDisabledRules() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time", isEnabled: false) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Paused rules are not shielded") - func skipsPausedRules() { + func skipsPausedRules() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") RulePolicy.pause(rule, at: mondayDuringWork, calendar: utc) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Time-limit rules are not schedule-shielded") - func skipsTimeLimitRules() { + func skipsTimeLimitRules() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig())) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Shields are cleared when a window ends") - func clearsShieldAfterWindow() { + func clearsShieldAfterWindow() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs == [rule.id]) - enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) #expect(enforcer.blockingRuleIDs.isEmpty) } @Test("Shields are cleared when a rule is deleted") - func clearsShieldAfterDeletion() { + func clearsShieldAfterDeletion() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) - enforcer.refresh(rules: [], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Expired pauses are cleaned up during refresh") - func clearsExpiredPause() { + func clearsExpiredPause() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") rule.pausedUntil = date(2025, 1, 6, 9, 30) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(rule.pausedUntil == nil) #expect(shields.shieldedRuleIDs == [rule.id]) } @Test("The selection mode is forwarded to the shield layer") - func forwardsSelectionMode() { + func forwardsSelectionMode() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule( name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly))) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.appliedModes[rule.id] == .allowOnly) } @Test("Open-limit rules are proactively shielded while opens remain") - func proactivelyShieldsOpenLimit() { + func proactivelyShieldsOpenLimit() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -140,7 +140,7 @@ struct RuleEnforcerTests { // but its apps must still be gated so the next open can be counted. ledger.usageByRule[rule.id] = RuleUsageDTO(opensUsed: 2) - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs == [rule.id]) #expect(shields.appliedModes[rule.id] == .block) @@ -149,7 +149,7 @@ struct RuleEnforcerTests { } @Test("A granted open session is left un-shielded, not re-locked") - func respectsGrantedOpenSession() { + func respectsGrantedOpenSession() async { let shields = MockShieldController() let ledger = MockUsageLedger() let sessions = MockOpenSessionStore() @@ -163,40 +163,40 @@ struct RuleEnforcerTests { // sanctioned ~15-minute session short. sessions.activeRuleIDs = [rule.id] - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Pausing an active rule clears its shield and sets a 15-minute pause") - func pauseClearsShield() { + func pauseClearsShield() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs == [rule.id]) - let didPause = enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) + let didPause = await enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) #expect(didPause) #expect(rule.pausedUntil == date(2025, 1, 6, 10, 15)) #expect(shields.shieldedRuleIDs.isEmpty) } @Test("Resuming re-applies the shield and clears the pause") - func resumeReshields() { + func resumeReshields() async { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) let rule = BlockingRule(name: "Work Time") - enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) - enforcer.resume(rule, rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.resume(rule, rules: [rule], at: mondayDuringWork, calendar: utc) #expect(rule.pausedUntil == nil) #expect(shields.shieldedRuleIDs == [rule.id]) } @Test("Pausing schedules a background re-arm; resuming cancels it") - func pauseAndResumeManageReArm() { + func pauseAndResumeManageReArm() async { let shields = MockShieldController() let monitor = MockActivityMonitor() let suite = "enforcer-pause-\(UUID().uuidString)" @@ -207,10 +207,10 @@ struct RuleEnforcerTests { let rule = BlockingRule(name: "Work Time") let pauseName = MonitoringPlan.pauseActivityName(for: rule.id) - enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.pause(rule, rules: [rule], at: mondayDuringWork, calendar: utc) #expect(monitor.monitoredNames.contains(pauseName)) - enforcer.resume(rule, rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.resume(rule, rules: [rule], at: mondayDuringWork, calendar: utc) #expect(!monitor.monitoredNames.contains(pauseName)) } } @@ -239,7 +239,7 @@ struct OverlappingRuleEnforcementTests { } @Test("Every rule that should block applies its own shield") - func eachRuleShieldsIndependently() { + func eachRuleShieldsIndependently() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -247,14 +247,14 @@ struct OverlappingRuleEnforcementTests { let timeLimit = timeLimitRule() ledger.usageByRule[timeLimit.id] = RuleUsageDTO(minutesUsed: 45) // spent → blocking - enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc) // Neither cancels the other: both carry their own shield. #expect(shields.shieldedRuleIDs == [schedule.id, timeLimit.id]) } @Test("The first limit to be spent blocks, whatever the other's budget") - func firstSpentLimitBlocks() { + func firstSpentLimitBlocks() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -263,7 +263,7 @@ struct OverlappingRuleEnforcementTests { ledger.usageByRule[openLimit.id] = RuleUsageDTO(opensUsed: 1) // opens remain ledger.usageByRule[timeLimit.id] = RuleUsageDTO(minutesUsed: 45) // budget spent - enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc) // Time-limit blocks (spent) while the open-limit still gates its turnstile. #expect(shields.shieldedRuleIDs == [openLimit.id, timeLimit.id]) @@ -272,7 +272,7 @@ struct OverlappingRuleEnforcementTests { } @Test("A time limit blocks even during an open-limit's granted session") - func timeLimitBlocksDuringGrantedOpen() { + func timeLimitBlocksDuringGrantedOpen() async { let shields = MockShieldController() let ledger = MockUsageLedger() let sessions = MockOpenSessionStore() @@ -284,7 +284,7 @@ struct OverlappingRuleEnforcementTests { // The metered minutes during the open push the time limit over budget. ledger.usageByRule[timeLimit.id] = RuleUsageDTO(minutesUsed: 45) - enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc) // The open-limit stays lifted (its session is sanctioned), but the time // limit shields the app anyway — strictest wins. @@ -292,7 +292,7 @@ struct OverlappingRuleEnforcementTests { } @Test("Spent opens reset the next day: re-gated, not blocked") - func opensResetNextDay() { + func opensResetNextDay() async { let suite = "overlap-tests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -307,11 +307,11 @@ struct OverlappingRuleEnforcementTests { } // Yesterday: budget exhausted → blocking. - enforcer.refresh(rules: [rule], at: yesterday, calendar: utc) + await enforcer.refresh(rules: [rule], at: yesterday, calendar: utc) #expect(enforcer.blockingRuleIDs == [rule.id]) // Today: fresh budget → not blocking, but the turnstile is back up. - enforcer.refresh(rules: [rule], at: today, calendar: utc) + await enforcer.refresh(rules: [rule], at: today, calendar: utc) #expect(enforcer.blockingRuleIDs.isEmpty) #expect(shields.shieldedRuleIDs == [rule.id]) } @@ -330,54 +330,54 @@ struct UninstallProtectionEnforcementTests { } @Test("Disabled setting never denies app removal") - func disabledSettingNeverDenies() { + func disabledSettingNeverDenies() async { let shields = MockShieldController() let enforcer = RuleEnforcer( shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: false)) - enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc) #expect(!shields.appRemovalDenied) } @Test("Enabled setting denies removal while a hard rule is blocking") - func deniesDuringHardBlock() { + func deniesDuringHardBlock() async { let shields = MockShieldController() let enforcer = RuleEnforcer( shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true)) - enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc) #expect(shields.appRemovalDenied) } @Test("A soft rule does not deny removal even with the setting on") - func softRuleDoesNotDeny() { + func softRuleDoesNotDeny() async { let shields = MockShieldController() let enforcer = RuleEnforcer( shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true)) - enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc) #expect(!shields.appRemovalDenied) } @Test("Denial lifts once the hard window ends") - func liftsWhenWindowEnds() { + func liftsWhenWindowEnds() async { let shields = MockShieldController() let enforcer = RuleEnforcer( shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true)) let rule = hardRule() - enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) #expect(shields.appRemovalDenied) - enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc) #expect(!shields.appRemovalDenied) } @Test("clearShields(except:) does not disturb the app-removal denial") - func clearShieldsPreservesDenial() { + func clearShieldsPreservesDenial() async { let shields = MockShieldController() shields.setAppRemovalDenied(true) shields.clearShields(except: []) diff --git a/OpenAppLockTests/RuleSchedulerPlanTests.swift b/OpenAppLockTests/RuleSchedulerPlanTests.swift index a280249..f3243f8 100644 --- a/OpenAppLockTests/RuleSchedulerPlanTests.swift +++ b/OpenAppLockTests/RuleSchedulerPlanTests.swift @@ -72,7 +72,7 @@ struct RuleSchedulerPlanTests { let scheduler = makeScheduler() let rule = try limitRule(kind: .openLimit) - let plan = scheduler.limitPlan(for: rule, selectionData: Data([1])) + let plan = scheduler.limitPlan(for: rule.dto, selectionData: Data([1])) #expect(plan.resetsThresholdAccountingOnRestart == false) guard case let .daily(_, events) = plan.payload else { @@ -94,16 +94,16 @@ struct RuleSchedulerPlanTests { // fingerprint format against the 002ac19 regression. rule.dailyLimitMinutes = 45 - let fingerprint = scheduler.limitPlan(for: rule, selectionData: Data([1])).fingerprint + let fingerprint = scheduler.limitPlan(for: rule.dto, selectionData: Data([1])).fingerprint // Locked to the exact format so a per-process-unstable hash (the 002ac19 // regression) cannot creep back in unnoticed. #expect( fingerprint == "\(rule.kindRaw)|45|" + RuleScheduler.selectionFingerprint(Data([1]))) // Stable for an unchanged rule… - #expect(scheduler.limitPlan(for: rule, selectionData: Data([1])).fingerprint == fingerprint) + #expect(scheduler.limitPlan(for: rule.dto, selectionData: Data([1])).fingerprint == fingerprint) // …and different once the budget changes. rule.dailyLimitMinutes = 60 - #expect(scheduler.limitPlan(for: rule, selectionData: Data([1])).fingerprint != fingerprint) + #expect(scheduler.limitPlan(for: rule.dto, selectionData: Data([1])).fingerprint != fingerprint) } // MARK: schedulePlans @@ -113,7 +113,7 @@ struct RuleSchedulerPlanTests { let scheduler = makeScheduler() let rule = try scheduleRule(start: 9 * 60, end: 17 * 60) - let plans = scheduler.schedulePlans(for: rule) + let plans = scheduler.schedulePlans(for: rule.dto) #expect(plans.count == 1) let plan = try #require(plans.first) @@ -133,7 +133,7 @@ struct RuleSchedulerPlanTests { let scheduler = makeScheduler() let rule = try scheduleRule(start: 22 * 60, end: 6 * 60) - let plans = scheduler.schedulePlans(for: rule) + let plans = scheduler.schedulePlans(for: rule.dto) #expect(plans.count == 2) let boundsByName = Dictionary(uniqueKeysWithValues: plans.map { ($0.name, $0.payload) }) diff --git a/OpenAppLockTests/RuleSchedulerWarnTests.swift b/OpenAppLockTests/RuleSchedulerWarnTests.swift index fc79641..e20deb3 100644 --- a/OpenAppLockTests/RuleSchedulerWarnTests.swift +++ b/OpenAppLockTests/RuleSchedulerWarnTests.swift @@ -49,7 +49,7 @@ struct RuleSchedulerWarnTests { let rule = try timeLimitRule(limit: 60) let now = date(2025, 1, 6, 10, 0) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) let dayKey = UsageLedger.dayKey(for: date(2025, 1, 6), calendar: utc) let blockName = MonitoringPlan.dailyActivityName(for: rule.id, dayKey: dayKey) @@ -69,7 +69,7 @@ struct RuleSchedulerWarnTests { let rule = try timeLimitRule(limit: 60) let now = date(2025, 1, 6, 10, 0) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) let dayKey = UsageLedger.dayKey(for: date(2025, 1, 6), calendar: utc) #expect(monitor.monitoredNames.contains(MonitoringPlan.dailyActivityName(for: rule.id, dayKey: dayKey))) @@ -83,7 +83,7 @@ struct RuleSchedulerWarnTests { let rule = try timeLimitRule(limit: 5) let now = date(2025, 1, 6, 10, 0) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) let dayKey = UsageLedger.dayKey(for: date(2025, 1, 6), calendar: utc) #expect(!monitor.monitoredNames.contains(MonitoringPlan.warnActivityName(for: rule.id, dayKey: dayKey))) @@ -100,7 +100,7 @@ struct RuleSchedulerWarnTests { let warnName = MonitoringPlan.warnActivityName(for: rule.id, dayKey: dayKey) // Nudge off: only the two block activities (today + tomorrow) start. - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == 2) #expect(!monitor.monitoredNames.contains(warnName)) @@ -108,7 +108,7 @@ struct RuleSchedulerWarnTests { // starts); the block activities are NOT restarted. defaults.set(true, forKey: AppGroup.notificationsAuthorizedKey) defaults.set(true, forKey: AppGroup.notifyTimeLimitEndingKey) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == 4) #expect(monitor.monitoredNames.contains(blockName)) #expect(monitor.monitoredNames.contains(warnName)) @@ -116,7 +116,7 @@ struct RuleSchedulerWarnTests { // Turn it back off: warn activities are stopped, block still present and // never restarted (start count unchanged). defaults.set(false, forKey: AppGroup.notifyTimeLimitEndingKey) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == 4) #expect(monitor.monitoredNames.contains(blockName)) #expect(!monitor.monitoredNames.contains(warnName)) diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index fd40cd7..1db0b22 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -191,7 +191,7 @@ struct RuleSchedulerTests { let rule = try limitRule(kind: .timeLimit, name: "Time Keeper") let now = date(2025, 1, 6, 10, 0) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) let today = MonitoringPlan.dailyActivityName( for: rule.id, dayKey: UsageLedger.dayKey(for: date(2025, 1, 6), calendar: utc)) @@ -212,8 +212,8 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try limitRule(kind: .timeLimit, name: "Time Keeper") - scheduler.sync(rules: [rule], at: date(2025, 1, 6, 10, 0), calendar: utc) // arms 01-06, 01-07 - scheduler.sync(rules: [rule], at: date(2025, 1, 7, 10, 0), calendar: utc) // arms 01-07, 01-08 + scheduler.sync(snapshots: [rule.dto], at: date(2025, 1, 6, 10, 0), calendar: utc) // arms 01-06, 01-07 + scheduler.sync(snapshots: [rule.dto], at: date(2025, 1, 7, 10, 0), calendar: utc) // arms 01-07, 01-08 let jan6 = MonitoringPlan.dailyActivityName( for: rule.id, dayKey: UsageLedger.dayKey(for: date(2025, 1, 6), calendar: utc)) @@ -239,13 +239,13 @@ struct RuleSchedulerTests { eventMinutes: MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes)) let startsAfterSelfArm = monitor.startCallCount // 1 - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) // Today's activity is adopted (not restarted → its live count is kept); // only tomorrow's is newly armed. #expect(monitor.startCallCount == startsAfterSelfArm + 1) // A second sync also leaves today's alone (its fingerprint was recorded). - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == startsAfterSelfArm + 1) } @@ -254,7 +254,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try limitRule(kind: .openLimit, name: "Gate Keeper") - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true) } @@ -268,7 +268,7 @@ struct RuleSchedulerTests { context.insert(applessSchedule) context.insert(applessLimit) - scheduler.sync(rules: [applessSchedule, applessLimit]) + scheduler.sync(snapshots: [applessSchedule.dto, applessLimit.dto]) #expect(monitor.monitoredNames.isEmpty) } @@ -278,7 +278,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) #expect(monitor.monitoredNames == [primary]) @@ -293,7 +293,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "No Days", start: 9 * 60, end: 17 * 60, days: []) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.monitoredNames.isEmpty) } @@ -303,7 +303,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) let late = MonitoringPlan.scheduleWindowLateName(for: rule.id) @@ -320,7 +320,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Late", start: 22 * 60, end: 0) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) let late = MonitoringPlan.scheduleWindowLateName(for: rule.id) @@ -335,7 +335,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Always", start: 0, end: 0) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) #expect(monitor.monitoredNames == [primary]) @@ -347,7 +347,7 @@ struct RuleSchedulerTests { func dropsLateActivityWhenWindowStopsCrossing() throws { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) let late = MonitoringPlan.scheduleWindowLateName(for: rule.id) @@ -356,7 +356,7 @@ struct RuleSchedulerTests { // Now a normal daytime window — the post-midnight half must be stopped. rule.startMinutes = 9 * 60 rule.endMinutes = 17 * 60 - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.monitoredNames == [primary]) #expect(monitor.startedWindows[late] == nil) } @@ -367,13 +367,13 @@ struct RuleSchedulerTests { let rule = try scheduleRule( name: "Work Time", start: 9 * 60, end: 17 * 60, days: Weekday.weekdays) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.startCallCount == 1) // The window interval is unchanged, so the DeviceActivity activity that // only encodes start/end need not restart — reconcile() reads days fresh. rule.days = Weekday.everyDay - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.startCallCount == 1) } @@ -382,11 +382,11 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(!monitor.monitoredNames.isEmpty) rule.isEnabled = false - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.monitoredNames.isEmpty) } @@ -395,12 +395,12 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60) - scheduler.sync(rules: [rule]) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.startCallCount == 1) rule.endMinutes = 18 * 60 - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.startCallCount == 2) let primary = MonitoringPlan.scheduleWindowName(for: rule.id) #expect(monitor.startedWindows[primary]?.end == 18 * 60) @@ -410,15 +410,15 @@ struct RuleSchedulerTests { func stopsStaleMonitoring() throws { let (scheduler, monitor, _) = makeScheduler() let rule = try limitRule(kind: .timeLimit, name: "Time Keeper") - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) rule.isEnabled = false - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.monitoredNames.isEmpty) rule.isEnabled = true - scheduler.sync(rules: [rule]) - scheduler.sync(rules: []) + scheduler.sync(snapshots: [rule.dto]) + scheduler.sync(snapshots: []) #expect(monitor.monitoredNames.isEmpty) } @@ -428,12 +428,12 @@ struct RuleSchedulerTests { let rule = try limitRule(kind: .timeLimit, name: "Time Keeper") let now = date(2025, 1, 6, 10, 0) - scheduler.sync(rules: [rule], at: now, calendar: utc) - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == 2) // today + tomorrow, each started once rule.dailyLimitMinutes = 60 - scheduler.sync(rules: [rule], at: now, calendar: utc) + scheduler.sync(snapshots: [rule.dto], at: now, calendar: utc) #expect(monitor.startCallCount == 4) // both day activities restart on budget change } @@ -482,7 +482,7 @@ struct RuleSchedulerTests { now: date(2025, 1, 6, 10, 0), calendar: utc) #expect(monitor.monitoredNames.contains(pauseName)) - scheduler.sync(rules: [rule]) // rule.pausedUntil == nil → reaped + scheduler.sync(snapshots: [rule.dto]) // rule.pausedUntil == nil → reaped #expect(!monitor.monitoredNames.contains(pauseName)) } @@ -496,7 +496,7 @@ struct RuleSchedulerTests { for: rule.id, until: rule.pausedUntil!, now: date(2025, 1, 6, 10, 0), calendar: utc) - scheduler.sync(rules: [rule]) + scheduler.sync(snapshots: [rule.dto]) #expect(monitor.monitoredNames.contains(pauseName)) } @@ -511,7 +511,7 @@ struct RuleSchedulerTests { #expect(monitor.monitoredNames.contains(pauseName)) // The rule is gone from the sync set (deleted mid-pause) → reaped. - scheduler.sync(rules: []) + scheduler.sync(snapshots: []) #expect(!monitor.monitoredNames.contains(pauseName)) } } diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 9bb2075..acb7389 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -164,7 +164,7 @@ struct UsageEnforcementTests { let mondayMorning = date(2025, 1, 6, 10, 0) @Test("A spent time-limit rule is shielded in Block mode") - func shieldsSpentTimeLimit() { + func shieldsSpentTimeLimit() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -174,14 +174,14 @@ struct UsageEnforcementTests { days: Weekday.everyDay) ledger.usageByRule[rule.id] = RuleUsageDTO(minutesUsed: 45) - enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) #expect(shields.shieldedRuleIDs == [rule.id]) #expect(shields.appliedModes[rule.id] == .block) } @Test("A time-limit rule with budget left is not shielded") - func leavesUnspentTimeLimitAlone() { + func leavesUnspentTimeLimitAlone() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -191,7 +191,7 @@ struct UsageEnforcementTests { days: Weekday.everyDay) ledger.usageByRule[rule.id] = RuleUsageDTO(minutesUsed: 20) - enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) // Time limits let the OS meter usage on the unshielded app, so nothing // is shielded until the budget is spent. (Open limits differ — the @@ -201,7 +201,7 @@ struct UsageEnforcementTests { } @Test("An open-limit rule not scheduled today is not gated") - func leavesOffDayOpenLimitAlone() { + func leavesOffDayOpenLimitAlone() async { let shields = MockShieldController() let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) @@ -212,7 +212,7 @@ struct UsageEnforcementTests { ledger.usageByRule[rule.id] = RuleUsageDTO(opensUsed: 2) // mondayMorning is a weekday, so the weekend-only rule does not gate. - enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + await enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) #expect(shields.shieldedRuleIDs.isEmpty) } diff --git a/Shared/Enforcement/ShieldController.swift b/Shared/Enforcement/ShieldController.swift index 3bf85a3..8bbf7de 100644 --- a/Shared/Enforcement/ShieldController.swift +++ b/Shared/Enforcement/ShieldController.swift @@ -9,7 +9,11 @@ import ManagedSettings /// Applies and clears app shields for rules. One implementation talks to /// ManagedSettings; the mock records calls for tests. -protocol ShieldApplying: AnyObject { +/// +/// `nonisolated` + `Sendable` so the app's enforcement can call it off the main +/// thread (the shared `Shared/` code already runs nonisolated inside the +/// extensions); see `RuleEnforcementEngine`. +nonisolated protocol ShieldApplying: AnyObject, Sendable { func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) /// Clears the shield of a single rule (used by the extensions for day /// resets and granted opens). @@ -26,7 +30,7 @@ protocol ShieldApplying: AnyObject { /// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names /// are tracked in the shared app-group defaults (ManagedSettings cannot /// enumerate stores) so the app and extensions see one consistent set. -final class ManagedSettingsShieldController: ShieldApplying { +nonisolated final class ManagedSettingsShieldController: ShieldApplying, @unchecked Sendable { private static let trackedIDsKey = "shieldedRuleIDs" private let defaults: UserDefaults @@ -110,8 +114,9 @@ final class ManagedSettingsShieldController: ShieldApplying { } /// Records shield operations without touching the system. Used by tests and -/// UI-test launches. -final class MockShieldController: ShieldApplying { +/// UI-test launches. `@unchecked Sendable`: a test double whose mutations are +/// ordered behind the `await` in `RuleEnforcer.refresh` before assertions read them. +nonisolated final class MockShieldController: ShieldApplying, @unchecked Sendable { private(set) var shieldedRuleIDs: Set = [] private(set) var appliedModes: [UUID: SelectionMode] = [:] private(set) var appliedSelectionData: [UUID: Data?] = [:] @@ -145,7 +150,7 @@ final class MockShieldController: ShieldApplying { } /// Encodes/decodes `FamilyActivitySelection` for persistence on the rule model. -enum AppSelectionCodec { +nonisolated enum AppSelectionCodec { static func encode(_ selection: FamilyActivitySelection) -> Data? { try? JSONEncoder().encode(selection) } diff --git a/Shared/Models/Calendar+NextMidnight.swift b/Shared/Models/Calendar+NextMidnight.swift index 2b12c9a..821a721 100644 --- a/Shared/Models/Calendar+NextMidnight.swift +++ b/Shared/Models/Calendar+NextMidnight.swift @@ -5,7 +5,7 @@ import Foundation -extension Calendar { +nonisolated extension Calendar { /// The first instant of the day after the one containing `date` — the /// "Tomorrow" reset point for spent limit budgets. func nextMidnight(after date: Date) -> Date? { diff --git a/Shared/Models/RuleActivation.swift b/Shared/Models/RuleActivation.swift index 79108ea..eed231d 100644 --- a/Shared/Models/RuleActivation.swift +++ b/Shared/Models/RuleActivation.swift @@ -16,7 +16,7 @@ import Foundation /// press) rather than deriving "is it blocking now", so they compose the same /// sub-primitives (`RuleSchedule.isActive`, `isScheduledToday`, `limitReached`, /// `isPaused`) directly. Keep their blocking semantics in step with this type. -enum RuleActivation: Equatable, Sendable { +nonisolated enum RuleActivation: Equatable, Sendable { /// Not blocking now. `nextStart` is when the rule's next window begins, or /// nil when it never will (disabled, or no days selected). case inactive(nextStart: Date?) @@ -31,7 +31,7 @@ enum RuleActivation: Equatable, Sendable { } } -extension RuleSnapshotDTO { +nonisolated extension RuleSnapshotDTO { /// Whether this rule is blocking right now, and until/from when. Schedule /// rules block by the clock; limit rules block once the day's budget is spent /// on an enabled day (requires usage). A pause only surfaces when the rule diff --git a/Shared/Models/RuleSchedule.swift b/Shared/Models/RuleSchedule.swift index 82c4170..909fc73 100644 --- a/Shared/Models/RuleSchedule.swift +++ b/Shared/Models/RuleSchedule.swift @@ -10,7 +10,7 @@ import Foundation /// A window whose end is at or before its start crosses midnight: 22:00 → 06:00 /// starts on an enabled day and ends the following morning. `start == end` /// means a full 24-hour window. -struct RuleSchedule: Hashable, Sendable { +nonisolated struct RuleSchedule: Hashable, Sendable { var startMinutes: Int var endMinutes: Int var days: Set diff --git a/Shared/Platform/MonitoringPlan.swift b/Shared/Platform/MonitoringPlan.swift index 294e01b..6ebf391 100644 --- a/Shared/Platform/MonitoringPlan.swift +++ b/Shared/Platform/MonitoringPlan.swift @@ -8,7 +8,7 @@ import Foundation /// Naming conventions and event layouts shared by the app (which starts /// DeviceActivity monitoring) and the monitor extension (which decodes what /// fired). -enum MonitoringPlan { +nonisolated enum MonitoringPlan { private static let dailyPrefix = "rule-" private static let sessionPrefix = "open-session-" private static let minutePrefix = "minutes-" diff --git a/Shared/Stores/OpenSessionStore.swift b/Shared/Stores/OpenSessionStore.swift index 997d483..c343116 100644 --- a/Shared/Stores/OpenSessionStore.swift +++ b/Shared/Stores/OpenSessionStore.swift @@ -6,7 +6,8 @@ import Foundation /// Read access to in-progress granted "Open" sessions, keyed by rule. -protocol OpenSessionReading: AnyObject { +/// `nonisolated` + `Sendable` so the off-main enforcement engine can consult it. +nonisolated protocol OpenSessionReading: AnyObject, Sendable { /// Whether a granted open for `ruleID` is still running at `now`. func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool } @@ -17,7 +18,7 @@ protocol OpenSessionReading: AnyObject { /// lets the foreground enforcer leave that one rule un-shielded for the life of /// the session instead of re-locking the app mid-session. The monitor clears it /// when the session's one-shot activity ends. -final class OpenSessionStore: OpenSessionReading { +nonisolated final class OpenSessionStore: OpenSessionReading, @unchecked Sendable { private static let key = "openSessionExpiry" private let defaults: UserDefaults @@ -50,7 +51,8 @@ final class OpenSessionStore: OpenSessionReading { } /// In-memory granted sessions for tests and UI-test launches. -final class MockOpenSessionStore: OpenSessionReading { +/// `@unchecked Sendable`: a test double; mutations are ordered behind the enforcer's `await`. +nonisolated final class MockOpenSessionStore: OpenSessionReading, @unchecked Sendable { var activeRuleIDs: Set = [] func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool { diff --git a/Shared/Stores/RuleSnapshotUserDefaultsStore.swift b/Shared/Stores/RuleSnapshotUserDefaultsStore.swift index 956347d..4768781 100644 --- a/Shared/Stores/RuleSnapshotUserDefaultsStore.swift +++ b/Shared/Stores/RuleSnapshotUserDefaultsStore.swift @@ -8,7 +8,7 @@ import Foundation /// Persistence for the rule mirror in the shared app-group defaults. Stores /// `RuleSnapshotDTO`s written by the app and read back by the Screen Time /// extensions. -final class RuleSnapshotUserDefaultsStore { +nonisolated final class RuleSnapshotUserDefaultsStore: @unchecked Sendable { private static let key = "ruleSnapshots" private let defaults: UserDefaults diff --git a/Shared/Stores/UsageLedger.swift b/Shared/Stores/UsageLedger.swift index 1e2582f..c6bc123 100644 --- a/Shared/Stores/UsageLedger.swift +++ b/Shared/Stores/UsageLedger.swift @@ -5,12 +5,13 @@ import Foundation -/// Read access to per-rule, per-day usage. -protocol UsageReading: AnyObject { +/// Read access to per-rule, per-day usage. `nonisolated` + `Sendable` so the +/// off-main enforcement engine can read usage without hopping to the main actor. +nonisolated protocol UsageReading: AnyObject, Sendable { func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsageDTO } -extension UsageReading { +nonisolated extension UsageReading { func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsageDTO { usage(for: ruleID, onDayContaining: date, calendar: .current) } @@ -18,7 +19,7 @@ extension UsageReading { /// Usage bookkeeping in the shared app-group defaults, keyed by calendar day /// and rule. Old days are simply ignored; midnight needs no reset step. -final class UsageLedger: UsageReading { +nonisolated final class UsageLedger: UsageReading, @unchecked Sendable { private let defaults: UserDefaults init(defaults: UserDefaults = AppGroup.defaults) { @@ -80,7 +81,8 @@ final class UsageLedger: UsageReading { } /// Seedable in-memory usage for tests and UI-test scenarios. -final class MockUsageLedger: UsageReading { +/// `@unchecked Sendable`: a test double; mutations are ordered behind the enforcer's `await`. +nonisolated final class MockUsageLedger: UsageReading, @unchecked Sendable { var usageByRule: [UUID: RuleUsageDTO] = [:] func usage(