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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user