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) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
19
Shared/AppGroup.swift
Normal file
19
Shared/AppGroup.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// AppGroup.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The app group shared by the app and its Screen Time extensions. Usage
|
||||
/// tracking and rule snapshots live in its UserDefaults suite so the
|
||||
/// DeviceActivity monitor and shield extensions can read and write them.
|
||||
enum AppGroup {
|
||||
static let identifier = "group.dev.bchen.OpenAppLock"
|
||||
|
||||
/// Shared defaults; falls back to standard defaults when the group
|
||||
/// container is unavailable (e.g. entitlement not provisioned yet).
|
||||
static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: identifier) ?? .standard
|
||||
}
|
||||
}
|
||||
98
Shared/LimitEnforcement.swift
Normal file
98
Shared/LimitEnforcement.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// LimitEnforcement.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Shared reactions to Screen Time events for limit rules, driven by the
|
||||
/// snapshot store and usage ledger. The DeviceActivity monitor and shield
|
||||
/// extensions call these; keeping the logic here makes it unit-testable from
|
||||
/// the app target.
|
||||
struct LimitEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let ledger: UsageLedger
|
||||
let shields: ShieldApplying
|
||||
|
||||
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
||||
/// proactively shielded on enabled days so the shield can count opens;
|
||||
/// time-limit rules start the day unshielded.
|
||||
func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
!snapshot.isPaused(at: now)
|
||||
else { return }
|
||||
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
switch snapshot.kind {
|
||||
case .schedule:
|
||||
break
|
||||
case .openLimit:
|
||||
if snapshot.isScheduledToday(at: now, calendar: calendar) {
|
||||
shield(snapshot)
|
||||
} else {
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
}
|
||||
case .timeLimit:
|
||||
if snapshot.limitReached(given: usage),
|
||||
snapshot.isScheduledToday(at: now, calendar: calendar) {
|
||||
shield(snapshot)
|
||||
} else {
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cumulative usage checkpoint fired for a time-limit rule.
|
||||
func handleUsageMinutes(
|
||||
_ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) {
|
||||
ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .timeLimit,
|
||||
!snapshot.isPaused(at: now),
|
||||
snapshot.isScheduledToday(at: now, calendar: calendar)
|
||||
else { return }
|
||||
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
if snapshot.limitReached(given: usage) {
|
||||
shield(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall-clock session granted by an "Open" press ended; the shield
|
||||
/// returns so the next open costs another press.
|
||||
func handleOpenSessionEnded(
|
||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) {
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .openLimit,
|
||||
!snapshot.isPaused(at: now),
|
||||
snapshot.isScheduledToday(at: now, calendar: calendar)
|
||||
else { return }
|
||||
shield(snapshot)
|
||||
}
|
||||
|
||||
/// "Open" pressed on the shield. Spends one open and lifts the rule's
|
||||
/// shield when the budget allows; returns whether a session was granted.
|
||||
@discardableResult
|
||||
func handleOpenRequest(
|
||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .openLimit,
|
||||
!snapshot.isPaused(at: now)
|
||||
else { return false }
|
||||
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
guard !snapshot.limitReached(given: usage) else { return false }
|
||||
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
return true
|
||||
}
|
||||
|
||||
private func shield(_ snapshot: RuleSnapshot) {
|
||||
shields.applyShield(
|
||||
ruleID: snapshot.id,
|
||||
selectionData: snapshot.selectionData,
|
||||
mode: .block,
|
||||
blockAdultContent: snapshot.blockAdultContent
|
||||
)
|
||||
}
|
||||
}
|
||||
61
Shared/MonitoringPlan.swift
Normal file
61
Shared/MonitoringPlan.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MonitoringPlan.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Naming conventions and event layouts shared by the app (which starts
|
||||
/// DeviceActivity monitoring) and the monitor extension (which decodes what
|
||||
/// fired).
|
||||
enum MonitoringPlan {
|
||||
private static let dailyPrefix = "rule-"
|
||||
private static let sessionPrefix = "open-session-"
|
||||
private static let minutePrefix = "minutes-"
|
||||
|
||||
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
|
||||
/// minimum schedule interval, so an "open" lasts at most this long before
|
||||
/// the shield returns.
|
||||
static let openSessionMinutes = 15
|
||||
|
||||
/// The always-on, midnight-to-midnight activity tracking a rule's day.
|
||||
static func dailyActivityName(for ruleID: UUID) -> String {
|
||||
dailyPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
/// The one-shot activity timing a granted open.
|
||||
static func sessionActivityName(for ruleID: UUID) -> String {
|
||||
sessionPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func ruleID(fromDailyActivityName name: String) -> UUID? {
|
||||
guard name.hasPrefix(dailyPrefix) else { return nil }
|
||||
return UUID(uuidString: String(name.dropFirst(dailyPrefix.count)))
|
||||
}
|
||||
|
||||
static func ruleID(fromSessionActivityName name: String) -> UUID? {
|
||||
guard name.hasPrefix(sessionPrefix) else { return nil }
|
||||
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
|
||||
}
|
||||
|
||||
static func minuteEventName(for minutes: Int) -> String {
|
||||
minutePrefix + String(minutes)
|
||||
}
|
||||
|
||||
static func minutes(fromEventName name: String) -> Int? {
|
||||
guard name.hasPrefix(minutePrefix) else { return nil }
|
||||
return Int(name.dropFirst(minutePrefix.count))
|
||||
}
|
||||
|
||||
/// Cumulative-usage checkpoints for a time-limit rule: one event per
|
||||
/// minute up to the budget so remaining time can be displayed live; the
|
||||
/// final one doubles as the block trigger. (Budgets cap at 240 minutes,
|
||||
/// comfortably inside DeviceActivity's event capacity.)
|
||||
static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] {
|
||||
Dictionary(
|
||||
uniqueKeysWithValues: (1...max(1, limitMinutes)).map {
|
||||
(minuteEventName(for: $0), $0)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
64
Shared/RuleKind.swift
Normal file
64
Shared/RuleKind.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// RuleKind.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
|
||||
enum RuleKind: String, Codable, CaseIterable, Sendable {
|
||||
/// Block selected apps during a recurring time window.
|
||||
case schedule
|
||||
/// Block selected apps after a daily usage budget is spent.
|
||||
case timeLimit
|
||||
/// Block selected apps after a number of opens per day.
|
||||
case openLimit
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .schedule: "Schedule"
|
||||
case .timeLimit: "Time Limit"
|
||||
case .openLimit: "Open Limit"
|
||||
}
|
||||
}
|
||||
|
||||
var exampleText: String {
|
||||
switch self {
|
||||
case .schedule: "e.g. 9-5, Daily"
|
||||
case .timeLimit: "e.g. 45m/day"
|
||||
case .openLimit: "e.g. 5 opens/day"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .schedule: "calendar"
|
||||
case .timeLimit: "hourglass"
|
||||
case .openLimit: "lock.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
|
||||
var defaultRuleName: String {
|
||||
switch self {
|
||||
case .schedule: "In the Zone"
|
||||
case .timeLimit: "Time Keeper"
|
||||
case .openLimit: "Gate Keeper"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the rule's app selection is interpreted.
|
||||
enum SelectionMode: String, Codable, CaseIterable, Sendable {
|
||||
/// Block the selected apps; everything else stays available.
|
||||
case block
|
||||
/// Block everything except the selected apps.
|
||||
case allowOnly
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .block: "Block"
|
||||
case .allowOnly: "Allow Only"
|
||||
}
|
||||
}
|
||||
}
|
||||
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))"
|
||||
}
|
||||
}
|
||||
75
Shared/RuleSnapshot.swift
Normal file
75
Shared/RuleSnapshot.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// 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]
|
||||
var dailyLimitMinutes: Int
|
||||
var maxOpens: Int
|
||||
var pausedUntil: Date?
|
||||
|
||||
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
|
||||
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
139
Shared/ShieldController.swift
Normal file
139
Shared/ShieldController.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// ShieldController.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import ManagedSettings
|
||||
|
||||
/// Applies and clears app shields for rules. One implementation talks to
|
||||
/// ManagedSettings; the mock records calls for tests.
|
||||
protocol ShieldApplying: AnyObject {
|
||||
func applyShield(
|
||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||
)
|
||||
/// Clears the shield of a single rule (used by the extensions for day
|
||||
/// resets and granted opens).
|
||||
func clearShield(ruleID: UUID)
|
||||
/// Clears every shield except those for the given rule IDs. Covers rules
|
||||
/// that were deleted or expired while the app was not running.
|
||||
func clearShields(except activeRuleIDs: Set<UUID>)
|
||||
}
|
||||
|
||||
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
|
||||
/// are tracked in the shared app-group defaults (ManagedSettings cannot
|
||||
/// enumerate stores) so the app and extensions see one consistent set.
|
||||
final class ManagedSettingsShieldController: ShieldApplying {
|
||||
private static let trackedIDsKey = "shieldedRuleIDs"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func applyShield(
|
||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||
) {
|
||||
let store = store(for: ruleID)
|
||||
let selection = AppSelectionCodec.decode(selectionData)
|
||||
switch mode {
|
||||
case .block:
|
||||
store.shield.applications =
|
||||
selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
|
||||
store.shield.applicationCategories =
|
||||
selection.categoryTokens.isEmpty ? nil : .specific(selection.categoryTokens)
|
||||
store.shield.webDomains =
|
||||
selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens
|
||||
case .allowOnly:
|
||||
store.shield.applicationCategories = .all(except: selection.applicationTokens)
|
||||
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
|
||||
}
|
||||
// Screen Time's "Limit Adult Websites" filter for the rule's lifetime.
|
||||
store.webContent.blockedByFilter = blockAdultContent ? .auto() : nil
|
||||
track(ruleID: ruleID)
|
||||
}
|
||||
|
||||
func clearShield(ruleID: UUID) {
|
||||
store(for: ruleID).clearAllSettings()
|
||||
untrack(ruleID: ruleID)
|
||||
}
|
||||
|
||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
|
||||
clearShield(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
private func store(for ruleID: UUID) -> ManagedSettingsStore {
|
||||
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
|
||||
}
|
||||
|
||||
private var trackedIDs: Set<UUID> {
|
||||
Set((defaults.stringArray(forKey: Self.trackedIDsKey) ?? []).compactMap(UUID.init))
|
||||
}
|
||||
|
||||
private func track(ruleID: UUID) {
|
||||
let ids = trackedIDs.union([ruleID])
|
||||
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
|
||||
}
|
||||
|
||||
private func untrack(ruleID: UUID) {
|
||||
let ids = trackedIDs.subtracting([ruleID])
|
||||
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records shield operations without touching the system. Used by tests and
|
||||
/// UI-test launches.
|
||||
final class MockShieldController: ShieldApplying {
|
||||
private(set) var shieldedRuleIDs: Set<UUID> = []
|
||||
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
||||
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
|
||||
private(set) var appliedSelectionData: [UUID: Data?] = [:]
|
||||
|
||||
func applyShield(
|
||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||
) {
|
||||
shieldedRuleIDs.insert(ruleID)
|
||||
appliedModes[ruleID] = mode
|
||||
appliedAdultContentFlags[ruleID] = blockAdultContent
|
||||
appliedSelectionData[ruleID] = selectionData
|
||||
}
|
||||
|
||||
func clearShield(ruleID: UUID) {
|
||||
shieldedRuleIDs.remove(ruleID)
|
||||
appliedModes[ruleID] = nil
|
||||
appliedAdultContentFlags[ruleID] = nil
|
||||
appliedSelectionData[ruleID] = nil
|
||||
}
|
||||
|
||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
||||
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
||||
appliedAdultContentFlags = appliedAdultContentFlags.filter {
|
||||
activeRuleIDs.contains($0.key)
|
||||
}
|
||||
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes/decodes `FamilyActivitySelection` for persistence on the rule model.
|
||||
enum AppSelectionCodec {
|
||||
static func encode(_ selection: FamilyActivitySelection) -> Data? {
|
||||
try? JSONEncoder().encode(selection)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data?) -> FamilyActivitySelection {
|
||||
guard let data,
|
||||
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
|
||||
else { return FamilyActivitySelection() }
|
||||
return selection
|
||||
}
|
||||
|
||||
static func count(of selection: FamilyActivitySelection) -> Int {
|
||||
selection.applicationTokens.count
|
||||
+ selection.categoryTokens.count
|
||||
+ selection.webDomainTokens.count
|
||||
}
|
||||
}
|
||||
44
Shared/ShieldLookup.swift
Normal file
44
Shared/ShieldLookup.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// ShieldLookup.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import ManagedSettings
|
||||
|
||||
/// Matches shielded tokens back to the rule that shields them, so the shield
|
||||
/// UI can offer "Open" with the right counts for open-limit rules.
|
||||
enum ShieldLookup {
|
||||
static func openLimitSnapshot(
|
||||
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
|
||||
) -> RuleSnapshot? {
|
||||
snapshots.first { snapshot in
|
||||
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
|
||||
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||
.applicationTokens.contains(token)
|
||||
}
|
||||
}
|
||||
|
||||
static func openLimitSnapshot(
|
||||
containingCategory token: ActivityCategoryToken, in snapshots: [RuleSnapshot]
|
||||
) -> RuleSnapshot? {
|
||||
snapshots.first { snapshot in
|
||||
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
|
||||
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||
.categoryTokens.contains(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Any enabled rule whose selection includes the application (for naming
|
||||
/// plain blocked shields).
|
||||
static func snapshot(
|
||||
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
|
||||
) -> RuleSnapshot? {
|
||||
snapshots.first { snapshot in
|
||||
guard snapshot.isEnabled else { return false }
|
||||
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||
.applicationTokens.contains(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Shared/UsageLedger.swift
Normal file
93
Shared/UsageLedger.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// UsageLedger.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// What a limit rule has consumed on a given day. Written by the
|
||||
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
|
||||
/// read by the app for display and enforcement.
|
||||
struct RuleUsage: Codable, Equatable {
|
||||
var minutesUsed = 0
|
||||
var opensUsed = 0
|
||||
}
|
||||
|
||||
/// Read access to per-rule, per-day usage.
|
||||
protocol UsageReading: AnyObject {
|
||||
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
|
||||
}
|
||||
|
||||
extension UsageReading {
|
||||
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
|
||||
usage(for: ruleID, onDayContaining: date, calendar: .current)
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
|
||||
/// and rule. Old days are simply ignored; midnight needs no reset step.
|
||||
final class UsageLedger: UsageReading {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// "2026-06-12" — calendar-date key so budgets roll over at midnight.
|
||||
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let parts = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
|
||||
}
|
||||
|
||||
func usage(
|
||||
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
||||
) -> RuleUsage {
|
||||
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
|
||||
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
|
||||
else { return RuleUsage() }
|
||||
return usage
|
||||
}
|
||||
|
||||
func setUsage(
|
||||
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
|
||||
calendar: Calendar = .current
|
||||
) {
|
||||
guard let data = try? JSONEncoder().encode(usage) else { return }
|
||||
defaults.set(data, forKey: key(ruleID, date, calendar))
|
||||
}
|
||||
|
||||
/// Threshold events report cumulative totals, so minutes only move up.
|
||||
func recordMinutesUsed(
|
||||
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
|
||||
calendar: Calendar = .current
|
||||
) {
|
||||
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
||||
usage.minutesUsed = max(usage.minutesUsed, minutes)
|
||||
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordOpen(
|
||||
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
||||
) -> RuleUsage {
|
||||
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
||||
usage.opensUsed += 1
|
||||
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
||||
return usage
|
||||
}
|
||||
|
||||
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
|
||||
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Seedable in-memory usage for tests and UI-test scenarios.
|
||||
final class MockUsageLedger: UsageReading {
|
||||
var usageByRule: [UUID: RuleUsage] = [:]
|
||||
|
||||
func usage(
|
||||
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
|
||||
) -> RuleUsage {
|
||||
usageByRule[ruleID] ?? RuleUsage()
|
||||
}
|
||||
}
|
||||
65
Shared/Weekday.swift
Normal file
65
Shared/Weekday.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Weekday.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday … 7 = Saturday).
|
||||
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
|
||||
case sunday = 1
|
||||
case monday = 2
|
||||
case tuesday = 3
|
||||
case wednesday = 4
|
||||
case thursday = 5
|
||||
case friday = 6
|
||||
case saturday = 7
|
||||
|
||||
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
|
||||
static let weekends: Set<Weekday> = [.saturday, .sunday]
|
||||
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
|
||||
|
||||
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
|
||||
static let displayOrder: [Weekday] = [
|
||||
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
|
||||
]
|
||||
|
||||
/// Single-letter label for the circular day toggles.
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .sunday, .saturday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday, .thursday: "T"
|
||||
case .wednesday: "W"
|
||||
case .friday: "F"
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
|
||||
var abbreviation: String {
|
||||
switch self {
|
||||
case .sunday: "Sun"
|
||||
case .monday: "Mon"
|
||||
case .tuesday: "Tue"
|
||||
case .wednesday: "Wed"
|
||||
case .thursday: "Thu"
|
||||
case .friday: "Fri"
|
||||
case .saturday: "Sat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Set<Weekday> {
|
||||
/// Human-readable summary shown next to the day picker and in rule details:
|
||||
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
|
||||
var summary: String {
|
||||
if self == Weekday.everyDay { return "Every day" }
|
||||
if self == Weekday.weekdays { return "Weekdays" }
|
||||
if self == Weekday.weekends { return "Weekends" }
|
||||
if isEmpty { return "Never" }
|
||||
return Weekday.displayOrder
|
||||
.filter(contains)
|
||||
.map(\.abbreviation)
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user