feat: enforce schedule rules in the background via DeviceActivity windows
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>
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -140,9 +140,14 @@ Gotchas learned the hard way:
|
||||
limits accrue in the Usage section and block at the budget; open-limit
|
||||
apps shield immediately with an "Open (N left)" button; an open lasts
|
||||
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
|
||||
- **Schedule-rule background transitions** still rely on the app running
|
||||
(launch / foreground 30s loop); schedule rules have no DeviceActivity
|
||||
monitoring yet — only limit rules do.
|
||||
- **Schedule-rule background transitions** are now backed by DeviceActivity:
|
||||
`RuleScheduler` registers a repeating window activity per schedule rule
|
||||
(`sched-<uuid>`, plus `sched2-<uuid>` for midnight-crossing windows) and the
|
||||
monitor extension recomputes + applies/clears the shield on interval
|
||||
start/end. The foreground 30s loop remains as the reconciliation safety net
|
||||
because interval callbacks are unreliable. On-device verification of the
|
||||
background transition is still pending (the simulator does not deliver
|
||||
DeviceActivity callbacks).
|
||||
- `FamilyActivityPicker` shows few apps on the simulator; fine on device.
|
||||
- `FamilyActivityPicker` **silently ignores selections** (binding never
|
||||
updates, rows still show checkmarks) unless real FamilyControls
|
||||
|
||||
@@ -15,6 +15,13 @@ protocol ActivityMonitoring: AnyObject {
|
||||
func startDailyMonitoring(
|
||||
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
||||
) throws
|
||||
/// Starts (or replaces) a repeating window activity spanning
|
||||
/// `intervalStartMinutes`…`intervalEndMinutes` (minutes from midnight),
|
||||
/// carrying no events — used to wake the monitor at a schedule rule's
|
||||
/// window edges so its shield engages in the background.
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws
|
||||
func stopMonitoring(names: [String])
|
||||
var monitoredNames: [String] { get }
|
||||
}
|
||||
@@ -47,32 +54,50 @@ final class RuleScheduler {
|
||||
var fingerprints = storedFingerprints
|
||||
var desiredNames: Set<String> = []
|
||||
|
||||
for rule in rules where rule.kind != .schedule {
|
||||
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue }
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
desiredNames.insert(name)
|
||||
|
||||
let events =
|
||||
rule.kind == .timeLimit
|
||||
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
|
||||
: [:]
|
||||
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
|
||||
+ "\(selectionData.hashValue)"
|
||||
guard fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
|
||||
for rule in rules {
|
||||
// A rule must be enabled, have days, and have apps to be monitored.
|
||||
guard rule.isEnabled, !rule.days.isEmpty,
|
||||
let selectionData = rule.appList?.selectionData
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try monitor.startDailyMonitoring(
|
||||
name: name, selectionData: selectionData, eventMinutes: events)
|
||||
fingerprints[name] = fingerprint
|
||||
} catch {
|
||||
// Monitoring is best-effort here; the next sync retries.
|
||||
// (Throws on the simulator and when authorization is missing.)
|
||||
switch rule.kind {
|
||||
case .timeLimit, .openLimit:
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
desiredNames.insert(name)
|
||||
let events =
|
||||
rule.kind == .timeLimit
|
||||
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
|
||||
: [:]
|
||||
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
|
||||
+ "\(selectionData.hashValue)"
|
||||
guard needsRestart(name, fingerprint, in: fingerprints) else { continue }
|
||||
start(name: name) {
|
||||
try monitor.startDailyMonitoring(
|
||||
name: name, selectionData: selectionData, eventMinutes: events)
|
||||
} onStarted: { fingerprints[name] = fingerprint }
|
||||
|
||||
case .schedule:
|
||||
// A window activity encodes only its interval (no events, no
|
||||
// selection); days, mode and apps are read fresh by reconcile()
|
||||
// at each callback, so only a start/end change needs a restart.
|
||||
let fingerprint = "schedule|\(rule.startMinutes)|\(rule.endMinutes)"
|
||||
for window in scheduleWindows(for: rule) {
|
||||
desiredNames.insert(window.name)
|
||||
guard needsRestart(window.name, fingerprint, in: fingerprints) else { continue }
|
||||
start(name: window.name) {
|
||||
try monitor.startWindowMonitoring(
|
||||
name: window.name,
|
||||
intervalStartMinutes: window.start,
|
||||
intervalEndMinutes: window.end)
|
||||
} onStarted: { fingerprints[window.name] = fingerprint }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stale = monitor.monitoredNames.filter {
|
||||
MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil && !desiredNames.contains($0)
|
||||
(MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil
|
||||
|| MonitoringPlan.ruleID(fromScheduleWindowName: $0) != nil)
|
||||
&& !desiredNames.contains($0)
|
||||
}
|
||||
if !stale.isEmpty {
|
||||
monitor.stopMonitoring(names: stale)
|
||||
@@ -83,6 +108,50 @@ final class RuleScheduler {
|
||||
storedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
/// Whether `name` should be (re)started: its configuration changed, or the
|
||||
/// system isn't actually monitoring it (e.g. a prior start threw).
|
||||
private func needsRestart(
|
||||
_ name: String, _ fingerprint: String, in fingerprints: [String: String]
|
||||
) -> Bool {
|
||||
fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
|
||||
}
|
||||
|
||||
/// Runs a best-effort `startMonitoring` call. Monitoring throws on the
|
||||
/// simulator, when authorization is missing, and when the activity cap or
|
||||
/// minimum interval is exceeded; the next sync retries.
|
||||
private func start(name: String, _ body: () throws -> Void, onStarted: () -> Void) {
|
||||
do {
|
||||
try body()
|
||||
onStarted()
|
||||
} catch {
|
||||
// Best-effort; the foreground reconciliation loop is the safety net.
|
||||
}
|
||||
}
|
||||
|
||||
/// The DeviceActivity window activities for a schedule rule. Normal windows
|
||||
/// 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)
|
||||
let endOfDay = 24 * 60 - 1
|
||||
let start = rule.startMinutes
|
||||
let end = rule.endMinutes
|
||||
|
||||
if start < end {
|
||||
return [(name: primary, start: start, end: end)]
|
||||
}
|
||||
if start == end {
|
||||
return [(name: primary, start: 0, end: endOfDay)]
|
||||
}
|
||||
var windows = [(name: primary, start: start, end: endOfDay)]
|
||||
if end > 0 {
|
||||
windows.append((name: late, start: 0, end: end))
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
private var storedFingerprints: [String: String] {
|
||||
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
|
||||
set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
|
||||
@@ -101,6 +170,8 @@ extension RuleSnapshot {
|
||||
selectionModeRaw: rule.selectionModeRaw,
|
||||
selectionData: rule.appList?.selectionData,
|
||||
dayNumbers: rule.dayNumbers,
|
||||
startMinutes: rule.startMinutes,
|
||||
endMinutes: rule.endMinutes,
|
||||
dailyLimitMinutes: rule.dailyLimitMinutes,
|
||||
maxOpens: rule.maxOpens,
|
||||
pausedUntil: rule.pausedUntil
|
||||
@@ -142,6 +213,19 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
||||
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
|
||||
}
|
||||
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws {
|
||||
let schedule = DeviceActivitySchedule(
|
||||
intervalStart: DateComponents(
|
||||
hour: intervalStartMinutes / 60, minute: intervalStartMinutes % 60),
|
||||
intervalEnd: DateComponents(
|
||||
hour: intervalEndMinutes / 60, minute: intervalEndMinutes % 60),
|
||||
repeats: true
|
||||
)
|
||||
try center.startMonitoring(DeviceActivityName(name), during: schedule)
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
center.stopMonitoring(names.map { DeviceActivityName($0) })
|
||||
}
|
||||
@@ -150,6 +234,7 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
||||
/// Records scheduling calls for tests.
|
||||
final class MockActivityMonitor: ActivityMonitoring {
|
||||
private(set) var startedEvents: [String: [String: Int]] = [:]
|
||||
private(set) var startedWindows: [String: (start: Int, end: Int)] = [:]
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var monitoredNames: [String] = []
|
||||
|
||||
@@ -163,10 +248,21 @@ final class MockActivityMonitor: ActivityMonitoring {
|
||||
}
|
||||
}
|
||||
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws {
|
||||
startCallCount += 1
|
||||
startedWindows[name] = (intervalStartMinutes, intervalEndMinutes)
|
||||
if !monitoredNames.contains(name) {
|
||||
monitoredNames.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
monitoredNames.removeAll(where: names.contains)
|
||||
for name in names {
|
||||
startedEvents[name] = nil
|
||||
startedWindows[name] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,21 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
|
||||
)
|
||||
}
|
||||
|
||||
private var scheduleEnforcement: ScheduleEnforcement {
|
||||
ScheduleEnforcement(
|
||||
snapshots: RuleSnapshotStore(),
|
||||
shields: ManagedSettingsShieldController()
|
||||
)
|
||||
}
|
||||
|
||||
override func intervalDidStart(for activity: DeviceActivityName) {
|
||||
super.intervalDidStart(for: activity)
|
||||
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
|
||||
enforcement.handleDayStart(ruleID: ruleID)
|
||||
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
|
||||
// A schedule window opened: shield it (the recompute honours days,
|
||||
// pause and the midnight-crossing rule).
|
||||
scheduleEnforcement.reconcile(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +42,11 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
|
||||
if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) {
|
||||
enforcement.handleOpenSessionEnded(ruleID: ruleID)
|
||||
DeviceActivityCenter().stopMonitoring([activity])
|
||||
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
|
||||
// A schedule window closed (or its evening half ended at 23:59):
|
||||
// recompute so a still-active window stays shielded and a finished
|
||||
// one clears.
|
||||
scheduleEnforcement.reconcile(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,14 @@ struct RuleSnapshotTests {
|
||||
let snapshot = RuleSnapshot(
|
||||
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: [2, 3], dailyLimitMinutes: 45, maxOpens: 5,
|
||||
pausedUntil: nil
|
||||
selectionData: Data([1]), dayNumbers: [2, 3], startMinutes: 540, endMinutes: 1020,
|
||||
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: nil
|
||||
)
|
||||
store.save([snapshot])
|
||||
#expect(store.load() == [snapshot])
|
||||
#expect(store.snapshot(for: snapshot.id) == snapshot)
|
||||
#expect(store.snapshot(for: snapshot.id)?.startMinutes == 540)
|
||||
#expect(store.snapshot(for: snapshot.id)?.endMinutes == 1020)
|
||||
#expect(store.snapshot(for: UUID()) == nil)
|
||||
}
|
||||
|
||||
@@ -50,6 +52,29 @@ struct RuleSnapshotTests {
|
||||
#expect(snapshot.selectionData == Data([9]))
|
||||
#expect(snapshot.days == Weekday.weekends)
|
||||
#expect(snapshot.maxOpens == 3)
|
||||
#expect(snapshot.startMinutes == rule.startMinutes)
|
||||
#expect(snapshot.endMinutes == rule.endMinutes)
|
||||
}
|
||||
|
||||
@Test("Snapshots saved before the window fields decode with a zeroed window")
|
||||
func decodesLegacySnapshotWithoutWindowFields() throws {
|
||||
// A blob shaped like a pre-schedule-window snapshot: no start/endMinutes.
|
||||
let id = UUID()
|
||||
let legacy = """
|
||||
[{"id":"\(id.uuidString)","name":"Old","kindRaw":"timeLimit","isEnabled":true,\
|
||||
"hardMode":false,"blockAdultContent":false,"selectionModeRaw":"block",\
|
||||
"dayNumbers":[2,3],"dailyLimitMinutes":45,"maxOpens":5}]
|
||||
"""
|
||||
let defaults = freshDefaults()
|
||||
defaults.set(Data(legacy.utf8), forKey: "ruleSnapshots")
|
||||
let store = RuleSnapshotStore(defaults: defaults)
|
||||
|
||||
let loaded = store.load()
|
||||
#expect(loaded.count == 1)
|
||||
#expect(loaded.first?.id == id)
|
||||
#expect(loaded.first?.startMinutes == 0)
|
||||
#expect(loaded.first?.endMinutes == 0)
|
||||
#expect(loaded.first?.dailyLimitMinutes == 45)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +96,22 @@ struct MonitoringPlanTests {
|
||||
fromSessionActivityName: MonitoringPlan.dailyActivityName(for: id)) == nil)
|
||||
}
|
||||
|
||||
@Test("Schedule-window activity names round-trip rule IDs")
|
||||
func scheduleWindowNameRoundTrip() {
|
||||
let id = UUID()
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: id)
|
||||
#expect(primary != late)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: primary) == id)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: late) == id)
|
||||
// Daily / session / garbage names are not schedule-window names.
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: MonitoringPlan.dailyActivityName(for: id)) == nil)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: "garbage") == nil)
|
||||
// Schedule-window names are not mistaken for daily activities.
|
||||
#expect(MonitoringPlan.ruleID(fromDailyActivityName: primary) == nil)
|
||||
#expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil)
|
||||
}
|
||||
|
||||
@Test("Minute checkpoints cover every minute up to the budget")
|
||||
func minuteEvents() {
|
||||
let events = MonitoringPlan.minuteEvents(forLimit: 45)
|
||||
@@ -101,6 +142,22 @@ struct RuleSchedulerTests {
|
||||
return rule
|
||||
}
|
||||
|
||||
private func scheduleRule(
|
||||
name: String, start: Int, end: Int, days: Set<Weekday> = Weekday.everyDay,
|
||||
withApps: Bool = true
|
||||
) throws -> BlockingRule {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(
|
||||
name: name, kind: .schedule, days: days, startMinutes: start, endMinutes: end)
|
||||
context.insert(rule)
|
||||
if withApps {
|
||||
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
|
||||
context.insert(list)
|
||||
rule.appList = list
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
@Test("Enabled limit rules with apps start daily monitoring")
|
||||
func startsMonitoring() throws {
|
||||
let (scheduler, monitor, store) = makeScheduler()
|
||||
@@ -124,20 +181,153 @@ struct RuleSchedulerTests {
|
||||
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("Schedule rules and app-less limit rules are not monitored")
|
||||
@Test("App-less rules (schedule or limit) are not monitored")
|
||||
func skipsUnmonitorable() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let context = try makeInMemoryContext()
|
||||
let scheduleRule = BlockingRule(name: "Work Time")
|
||||
let appless = BlockingRule(name: "Empty", kind: .timeLimit)
|
||||
context.insert(scheduleRule)
|
||||
context.insert(appless)
|
||||
let applessSchedule = BlockingRule(name: "Work Time")
|
||||
let applessLimit = BlockingRule(name: "Empty", kind: .timeLimit)
|
||||
context.insert(applessSchedule)
|
||||
context.insert(applessLimit)
|
||||
|
||||
scheduler.sync(rules: [scheduleRule, appless])
|
||||
scheduler.sync(rules: [applessSchedule, applessLimit])
|
||||
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A non-crossing schedule rule registers one window activity")
|
||||
func schedulesNonCrossingWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 9 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 17 * 60)
|
||||
// A schedule window carries no usage-threshold events.
|
||||
#expect(monitor.startedEvents[primary] == nil)
|
||||
}
|
||||
|
||||
@Test("A dayless schedule rule is not monitored")
|
||||
func skipsDaylessSchedule() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "No Days", start: 9 * 60, end: 17 * 60, days: [])
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A midnight-crossing schedule rule registers two window activities")
|
||||
func schedulesCrossingWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(Set(monitor.monitoredNames) == [primary, late])
|
||||
// The evening half runs to the end of the day; the morning half from midnight.
|
||||
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
#expect(monitor.startedWindows[late]?.start == 0)
|
||||
#expect(monitor.startedWindows[late]?.end == 6 * 60)
|
||||
}
|
||||
|
||||
@Test("A window ending exactly at midnight registers a single evening activity")
|
||||
func scheduleWindowEndingAtMidnight() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Late", start: 22 * 60, end: 0)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
#expect(monitor.startedWindows[late] == nil)
|
||||
}
|
||||
|
||||
@Test("A 24-hour window registers a single all-day activity")
|
||||
func scheduleFullDayWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Always", start: 0, end: 0)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 0)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
}
|
||||
|
||||
@Test("Switching a window from crossing to non-crossing stops the morning half")
|
||||
func dropsLateActivityWhenWindowStopsCrossing() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(Set(monitor.monitoredNames) == [primary, late])
|
||||
|
||||
// Now a normal daytime window — the post-midnight half must be stopped.
|
||||
rule.startMinutes = 9 * 60
|
||||
rule.endMinutes = 17 * 60
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[late] == nil)
|
||||
}
|
||||
|
||||
@Test("Changing only the days does not restart the window activity")
|
||||
func keepsWindowWhenOnlyDaysChange() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(
|
||||
name: "Work Time", start: 9 * 60, end: 17 * 60, days: Weekday.weekdays)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
#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])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("Disabling a schedule rule stops its window monitoring")
|
||||
func stopsScheduleWindowWhenDisabled() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(!monitor.monitoredNames.isEmpty)
|
||||
|
||||
rule.isEnabled = false
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Changing a schedule window restarts monitoring")
|
||||
func restartsOnWindowChange() throws {
|
||||
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])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
|
||||
rule.endMinutes = 18 * 60
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 2)
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.startedWindows[primary]?.end == 18 * 60)
|
||||
}
|
||||
|
||||
@Test("Monitoring stops when a rule is disabled or removed")
|
||||
func stopsStaleMonitoring() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
@@ -188,6 +378,7 @@ struct LimitEnforcementTests {
|
||||
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
|
||||
startMinutes: 0, endMinutes: 0,
|
||||
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
|
||||
)
|
||||
}
|
||||
@@ -277,3 +468,111 @@ struct LimitEnforcementTests {
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Schedule-window enforcement reactions")
|
||||
struct ScheduleEnforcementTests {
|
||||
/// Monday 2025-01-06 10:00 UTC sits inside a 09:00–17:00 weekday window.
|
||||
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||
|
||||
private func makeEnforcement() -> (ScheduleEnforcement, MockShieldController, RuleSnapshotStore) {
|
||||
let shields = MockShieldController()
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
return (ScheduleEnforcement(snapshots: store, shields: shields), shields, store)
|
||||
}
|
||||
|
||||
private func snapshot(
|
||||
start: Int = 9 * 60, end: Int = 17 * 60, days: Set<Weekday> = Weekday.everyDay,
|
||||
isEnabled: Bool = true, mode: SelectionMode = .block, pausedUntil: Date? = nil
|
||||
) -> RuleSnapshot {
|
||||
RuleSnapshot(
|
||||
id: UUID(), name: "Work Time", kindRaw: RuleKind.schedule.rawValue,
|
||||
isEnabled: isEnabled, hardMode: false, blockAdultContent: false,
|
||||
selectionModeRaw: mode.rawValue, selectionData: Data([1]),
|
||||
dayNumbers: days.map(\.rawValue), startMinutes: start, endMinutes: end,
|
||||
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: pausedUntil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Reconcile shields a rule whose window is active now")
|
||||
func shieldsActiveWindow() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot()
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("Reconcile clears a rule whose window is over")
|
||||
func clearsInactiveWindow() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot()
|
||||
store.save([snap])
|
||||
shields.applyShield(
|
||||
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayEvening, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Reconcile forwards the rule's selection mode")
|
||||
func forwardsSelectionMode() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(mode: .allowOnly)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.appliedModes[snap.id] == .allowOnly)
|
||||
}
|
||||
|
||||
@Test("A disabled schedule rule is never shielded")
|
||||
func skipsDisabled() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(isEnabled: false)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A paused (unblocked) schedule rule is left clear")
|
||||
func skipsPaused() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(pausedUntil: date(2025, 1, 6, 17, 0))
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A midnight-crossing window is active in the early morning")
|
||||
func shieldsCrossingWindowAfterMidnight() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
// 22:00–06:00 every day; 02:00 belongs to the window that started the
|
||||
// previous evening (so the previous day must be enabled — it is).
|
||||
let snap = snapshot(start: 22 * 60, end: 6 * 60)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 6, 2, 0), calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("A weekday window is inactive on the weekend")
|
||||
func clearsOnDisabledDay() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(days: Weekday.weekdays)
|
||||
store.save([snap])
|
||||
// 2025-01-11 is a Saturday.
|
||||
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 11, 10, 0), calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ enum MonitoringPlan {
|
||||
private static let dailyPrefix = "rule-"
|
||||
private static let sessionPrefix = "open-session-"
|
||||
private static let minutePrefix = "minutes-"
|
||||
private static let scheduleWindowPrefix = "sched-"
|
||||
private static let scheduleWindowLatePrefix = "sched2-"
|
||||
|
||||
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
|
||||
/// minimum schedule interval, so an "open" lasts at most this long before
|
||||
@@ -38,6 +40,28 @@ enum MonitoringPlan {
|
||||
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
|
||||
}
|
||||
|
||||
/// The primary window activity for a schedule rule. A second
|
||||
/// (`scheduleWindowLateName`) covers the post-midnight half of a window
|
||||
/// that crosses midnight, since DeviceActivity can't express an interval
|
||||
/// whose end is earlier than its start.
|
||||
static func scheduleWindowName(for ruleID: UUID) -> String {
|
||||
scheduleWindowPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func scheduleWindowLateName(for ruleID: UUID) -> String {
|
||||
scheduleWindowLatePrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func ruleID(fromScheduleWindowName name: String) -> UUID? {
|
||||
if name.hasPrefix(scheduleWindowLatePrefix) {
|
||||
return UUID(uuidString: String(name.dropFirst(scheduleWindowLatePrefix.count)))
|
||||
}
|
||||
if name.hasPrefix(scheduleWindowPrefix) {
|
||||
return UUID(uuidString: String(name.dropFirst(scheduleWindowPrefix.count)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func minuteEventName(for minutes: Int) -> String {
|
||||
minutePrefix + String(minutes)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,23 @@ struct RuleSnapshot: Codable, Equatable {
|
||||
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
|
||||
@@ -48,6 +58,35 @@ struct RuleSnapshot: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
37
Shared/ScheduleEnforcement.swift
Normal file
37
Shared/ScheduleEnforcement.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// ScheduleEnforcement.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Background reaction for schedule (time-window) rules. The monitor extension
|
||||
/// calls `reconcile` at each window boundary; it recomputes the rule's live
|
||||
/// schedule state from its snapshot and applies or clears the shield to match.
|
||||
///
|
||||
/// This intentionally mirrors what `RuleEnforcer.refresh` does for schedule
|
||||
/// rules in the foreground, so the background and foreground paths never
|
||||
/// disagree. Recomputing (rather than blindly shielding on start / clearing on
|
||||
/// end) also makes the two activities of a midnight-crossing window — and any
|
||||
/// late or duplicated interval callback — converge on the correct state.
|
||||
struct ScheduleEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let shields: ShieldApplying
|
||||
|
||||
func reconcile(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.kind == .schedule else {
|
||||
return
|
||||
}
|
||||
if snapshot.isEnabled, !snapshot.isPaused(at: now),
|
||||
snapshot.schedule.isActive(at: now, calendar: calendar) {
|
||||
shields.applyShield(
|
||||
ruleID: snapshot.id,
|
||||
selectionData: snapshot.selectionData,
|
||||
mode: snapshot.selectionMode,
|
||||
blockAdultContent: snapshot.blockAdultContent
|
||||
)
|
||||
} else {
|
||||
shields.clearShield(ruleID: snapshot.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,16 +378,39 @@ enum SelectionMode: String, Codable { case block, allowOnly }
|
||||
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
|
||||
per-day minutes/opens), and the shield-store tracking list.
|
||||
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
|
||||
enabled limit rules: one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
enabled rules:
|
||||
- **Limit rules** — one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
- **Schedule rules** — one (or, for windows that cross midnight, two)
|
||||
repeating window activit(ies) per rule matching the rule's
|
||||
`From…To` window (`sched-<uuid>` and, for the post-midnight half,
|
||||
`sched2-<uuid>`). These carry no events; they exist purely to wake the
|
||||
monitor at the window edges so shields engage **in the background even
|
||||
when the app is closed**. A window that ends exactly at midnight, or is
|
||||
shorter than DeviceActivity's 15-minute minimum interval, may fail to
|
||||
register (`intervalTooShort`) and falls back to the foreground loop.
|
||||
Activities restart only when their configuration changes, because a
|
||||
restart resets threshold accounting.
|
||||
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
|
||||
= midnight reset (open-limit rules re-shield so opens can be counted;
|
||||
time-limit shields clear for the fresh budget); each `minutes-<k>` event
|
||||
records usage and shields at the budget; a finished `open-session-<uuid>`
|
||||
one-shot re-shields after a granted open.
|
||||
= midnight reset for limit rules (open-limit rules re-shield so opens can
|
||||
be counted; time-limit shields clear for the fresh budget); each
|
||||
`minutes-<k>` event records usage and shields at the budget; a finished
|
||||
`open-session-<uuid>` one-shot re-shields after a granted open. For
|
||||
schedule-window activities (`sched-`/`sched2-`), **both** interval start
|
||||
and interval end **recompute** the rule's live schedule state from its
|
||||
snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the
|
||||
midnight-crossing rule) and apply or clear the shield accordingly — the
|
||||
same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths
|
||||
agree.
|
||||
- **Reliability posture** — DeviceActivity interval callbacks are
|
||||
"first device use after the boundary", are known to fire late or be
|
||||
skipped (device asleep, OS regressions on iOS 17/18/26), and a shield
|
||||
written over an app the user already has open may not visibly engage until
|
||||
that app is relaunched (a long-standing Screen Time platform limitation).
|
||||
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
|
||||
(launch + 30 s foreground loop) is retained as the reconciliation safety
|
||||
net and is the source of truth whenever the app runs.
|
||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
||||
button; other shields show the blocking rule's name.
|
||||
|
||||
Reference in New Issue
Block a user