Schedule (time-window) rules had no background enforcement: RuleScheduler.sync() skipped them, so their shields were applied only by RuleEnforcer.refresh() — the launch + 30s foreground loop. A window that began while the app was closed didn't engage until the user reopened the app, which is why scheduled blocks could land late or unevenly. RuleScheduler now registers a repeating DeviceActivitySchedule per enabled schedule rule's window (sched-<uuid>, plus sched2-<uuid> for windows that cross midnight, since DeviceActivity can't express an interval whose end precedes its start). The monitor extension routes these to a new ScheduleEnforcement.reconcile(), which recomputes the rule's live schedule state from its snapshot (RuleSchedule.isActive, honouring days, pause and the midnight-crossing rule) and applies or clears the shield to match — the same logic RuleEnforcer.refresh runs in the foreground, kept as the reconciliation safety net because interval callbacks are known to fire late or not at all. - RuleSnapshot gains startMinutes/endMinutes, with a tolerant decoder so snapshots written before these fields still load instead of failing the whole batch and blinding the extensions until the app reopens. - Window activities are fingerprinted on their interval alone, so changing days, mode or apps no longer needlessly restarts monitoring. On-device verification of the background transition is still pending (the simulator does not deliver DeviceActivity callbacks); covered by new unit tests across the scheduler, the snapshot codec and the new enforcement reactions. Co-Authored-By: Claude <noreply@anthropic.com>
115 lines
4.5 KiB
Swift
115 lines
4.5 KiB
Swift
//
|
|
// RuleSnapshot.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Codable mirror of a rule, written to the app group by the app whenever
|
|
/// rules change so the Screen Time extensions (which cannot open the
|
|
/// SwiftData store) know what to enforce.
|
|
struct RuleSnapshot: Codable, Equatable {
|
|
var id: UUID
|
|
var name: String
|
|
var kindRaw: String
|
|
var isEnabled: Bool
|
|
var hardMode: Bool
|
|
var blockAdultContent: Bool
|
|
var selectionModeRaw: String
|
|
var selectionData: Data?
|
|
var dayNumbers: [Int]
|
|
/// Schedule-window bounds, minutes from midnight (mirrors `BlockingRule`).
|
|
/// Only meaningful for `.schedule` rules; limit rules carry 0/0.
|
|
var startMinutes: Int
|
|
var endMinutes: Int
|
|
var dailyLimitMinutes: Int
|
|
var maxOpens: Int
|
|
var pausedUntil: Date?
|
|
|
|
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
|
|
var selectionMode: SelectionMode { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
|
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
|
|
|
|
/// The recurring time window this rule blocks, for schedule rules.
|
|
var schedule: RuleSchedule {
|
|
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
|
}
|
|
|
|
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
|
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
|
return false
|
|
}
|
|
return days.contains(weekday)
|
|
}
|
|
|
|
/// Whether the given usage exhausts this rule's daily budget.
|
|
func limitReached(given usage: RuleUsage) -> Bool {
|
|
switch kind {
|
|
case .schedule: false
|
|
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
|
|
case .openLimit: usage.opensUsed >= maxOpens
|
|
}
|
|
}
|
|
|
|
/// Whether the user unblocked this rule for the rest of the day.
|
|
func isPaused(at now: Date) -> Bool {
|
|
guard let pausedUntil else { return false }
|
|
return pausedUntil > now
|
|
}
|
|
}
|
|
|
|
extension RuleSnapshot {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, name, kindRaw, isEnabled, hardMode, blockAdultContent
|
|
case selectionModeRaw, selectionData, dayNumbers, startMinutes, endMinutes
|
|
case dailyLimitMinutes, maxOpens, pausedUntil
|
|
}
|
|
|
|
/// Decodes tolerantly so snapshots written before `startMinutes`/`endMinutes`
|
|
/// existed still load (defaulting the window to 0) instead of failing the
|
|
/// whole batch — which would blind the extensions until the app reopened.
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
kindRaw = try container.decode(String.self, forKey: .kindRaw)
|
|
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
|
|
hardMode = try container.decode(Bool.self, forKey: .hardMode)
|
|
blockAdultContent = try container.decode(Bool.self, forKey: .blockAdultContent)
|
|
selectionModeRaw = try container.decode(String.self, forKey: .selectionModeRaw)
|
|
selectionData = try container.decodeIfPresent(Data.self, forKey: .selectionData)
|
|
dayNumbers = try container.decode([Int].self, forKey: .dayNumbers)
|
|
startMinutes = try container.decodeIfPresent(Int.self, forKey: .startMinutes) ?? 0
|
|
endMinutes = try container.decodeIfPresent(Int.self, forKey: .endMinutes) ?? 0
|
|
dailyLimitMinutes = try container.decode(Int.self, forKey: .dailyLimitMinutes)
|
|
maxOpens = try container.decode(Int.self, forKey: .maxOpens)
|
|
pausedUntil = try container.decodeIfPresent(Date.self, forKey: .pausedUntil)
|
|
}
|
|
}
|
|
|
|
/// Persistence for the rule mirror in the shared app-group defaults.
|
|
final class RuleSnapshotStore {
|
|
private static let key = "ruleSnapshots"
|
|
private let defaults: UserDefaults
|
|
|
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
func save(_ snapshots: [RuleSnapshot]) {
|
|
guard let data = try? JSONEncoder().encode(snapshots) else { return }
|
|
defaults.set(data, forKey: Self.key)
|
|
}
|
|
|
|
func load() -> [RuleSnapshot] {
|
|
guard let data = defaults.data(forKey: Self.key),
|
|
let snapshots = try? JSONDecoder().decode([RuleSnapshot].self, from: data)
|
|
else { return [] }
|
|
return snapshots
|
|
}
|
|
|
|
func snapshot(for ruleID: UUID) -> RuleSnapshot? {
|
|
load().first { $0.id == ruleID }
|
|
}
|
|
}
|