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.
This commit is contained in:
2026-06-13 02:36:09 -04:00
parent 1652c2f410
commit 9092221d32
8 changed files with 577 additions and 38 deletions

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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:0017: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:0006: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)
}
}

View File

@@ -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)
}

View File

@@ -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"

View 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)
}
}
}

View File

@@ -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:0023: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:0023: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.