Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion OpenAppLock/Logic/RulePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions OpenAppLock/Logic/RuleStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions OpenAppLock/Services/NotificationScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
Expand Down
272 changes: 173 additions & 99 deletions OpenAppLock/Services/RuleEnforcer.swift

Large diffs are not rendered by default.

84 changes: 48 additions & 36 deletions OpenAppLock/Services/RuleScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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)]
Expand All @@ -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] {
Expand Down Expand Up @@ -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)] = [:]
Expand Down
6 changes: 4 additions & 2 deletions OpenAppLock/Views/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
Expand Down
4 changes: 2 additions & 2 deletions OpenAppLock/Views/Rules/RuleDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions OpenAppLock/Views/Settings/NotificationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) }
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion OpenAppLock/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
)
}
Expand Down
4 changes: 2 additions & 2 deletions OpenAppLockTests/AppListTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]))
}
Expand Down
Loading
Loading