feat: enforce time and open limits in the background via Screen Time extensions
- Shared/ layer compiled into the app and three new extension targets: rule snapshots in the app group, the usage ledger, monitoring-plan naming, and LimitEnforcement (shared, unit-tested event reactions) - RuleScheduler mirrors rules to the app group and reconciles DeviceActivity monitoring: one daily 00:00-23:59 activity per limit rule, with a cumulative usage-threshold event per budget minute for time limits; activities restart only when their configuration changes (a restart resets threshold accounting) - OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets, records usage minutes, shields at the budget, re-shields when a granted open session ends - OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times today' with an 'Open (Y left)' secondary button - OpenAppLockShieldAction: an Open press spends one open, lifts the rule's shield, and starts the ~15-minute one-shot session - extensions are classic NSExtension app extensions (the ExtensionKit product type expects an @main entry and made the app fail to install); shield-store tracking moved to app-group defaults so the app and extensions see one consistent set - maps iOS 26's new .approvedWithDataAccess authorization status to approved (it previously fell through @unknown default to notDetermined) - shares the OpenAppLock scheme (Xcode dropped the autocreated one when targets were added)
This commit is contained in:
89
Shared/RuleSchedule.swift
Normal file
89
Shared/RuleSchedule.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// RuleSchedule.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The recurring time window of a rule, independent of any persistence.
|
||||
///
|
||||
/// A window whose end is at or before its start crosses midnight: 22:00 → 06:00
|
||||
/// starts on an enabled day and ends the following morning. `start == end`
|
||||
/// means a full 24-hour window.
|
||||
struct RuleSchedule: Hashable, Sendable {
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var days: Set<Weekday>
|
||||
|
||||
var crossesMidnight: Bool { endMinutes <= startMinutes }
|
||||
|
||||
var durationMinutes: Int {
|
||||
crossesMidnight
|
||||
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
|
||||
: endMinutes - startMinutes
|
||||
}
|
||||
|
||||
/// The window containing `date`, if the schedule is active at that moment.
|
||||
///
|
||||
/// Checks today's window and, for midnight-crossing schedules, the window
|
||||
/// that started yesterday. The day a window *starts* on is the day that
|
||||
/// must be enabled.
|
||||
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
|
||||
for dayOffset in [0, -1] {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start <= date, date < window.end
|
||||
else { continue }
|
||||
return window
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
|
||||
activeWindow(containing: date, calendar: calendar) != nil
|
||||
}
|
||||
|
||||
/// The next moment the schedule will begin a window strictly after `date`.
|
||||
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
|
||||
guard !days.isEmpty else { return nil }
|
||||
for dayOffset in 0...7 {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start > date
|
||||
else { continue }
|
||||
return window.start
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The window starting on the given day, or nil when that weekday is not enabled.
|
||||
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
|
||||
let dayStart = calendar.startOfDay(for: day)
|
||||
guard
|
||||
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
|
||||
days.contains(weekday),
|
||||
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
|
||||
let end = calendar.date(
|
||||
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
|
||||
)
|
||||
else { return nil }
|
||||
return DateInterval(start: start, end: end)
|
||||
}
|
||||
|
||||
private static let minutesPerDay = 24 * 60
|
||||
}
|
||||
|
||||
extension RuleSchedule {
|
||||
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
|
||||
static func timeLabel(forMinutes minutes: Int) -> String {
|
||||
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
|
||||
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
/// "09:00 – 17:00" range label used by rule details and preset cards.
|
||||
var timeRangeLabel: String {
|
||||
"\(Self.timeLabel(forMinutes: startMinutes)) – \(Self.timeLabel(forMinutes: endMinutes))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user