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:
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal file
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// ShieldConfigurationExtension.swift
|
||||
// OpenAppLockShieldConfig
|
||||
//
|
||||
|
||||
import ManagedSettings
|
||||
import ManagedSettingsUI
|
||||
import UIKit
|
||||
|
||||
/// Customizes the system shield. Apps under an open-limit rule show how many
|
||||
/// opens were used and offer an "Open" secondary button while opens remain;
|
||||
/// everything else gets a plain blocked shield named after its rule.
|
||||
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
||||
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
||||
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
||||
return configuration(forApplicationToken: token)
|
||||
}
|
||||
|
||||
override func configuration(
|
||||
shielding application: Application, in category: ActivityCategory
|
||||
) -> ShieldConfiguration {
|
||||
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
||||
return configuration(forApplicationToken: token)
|
||||
}
|
||||
|
||||
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
||||
blockedConfiguration(ruleName: nil)
|
||||
}
|
||||
|
||||
override func configuration(
|
||||
shielding webDomain: WebDomain, in category: ActivityCategory
|
||||
) -> ShieldConfiguration {
|
||||
blockedConfiguration(ruleName: nil)
|
||||
}
|
||||
|
||||
private func configuration(
|
||||
forApplicationToken token: ApplicationToken
|
||||
) -> ShieldConfiguration {
|
||||
let snapshots = RuleSnapshotStore().load()
|
||||
guard
|
||||
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||
containingApplication: token, in: snapshots)
|
||||
else {
|
||||
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
|
||||
return blockedConfiguration(ruleName: name)
|
||||
}
|
||||
|
||||
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
|
||||
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
|
||||
guard remaining > 0 else {
|
||||
return ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
title: .init(text: snapshot.name, color: .label),
|
||||
subtitle: .init(
|
||||
text: "No opens left today — the block lifts tomorrow.",
|
||||
color: .secondaryLabel),
|
||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||
primaryButtonBackgroundColor: .systemBlue
|
||||
)
|
||||
}
|
||||
return ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
title: .init(text: snapshot.name, color: .label),
|
||||
subtitle: .init(
|
||||
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
|
||||
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
|
||||
color: .secondaryLabel),
|
||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||
primaryButtonBackgroundColor: .systemBlue,
|
||||
secondaryButtonLabel: .init(
|
||||
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
|
||||
color: .systemBlue)
|
||||
)
|
||||
}
|
||||
|
||||
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
|
||||
ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
title: .init(text: ruleName ?? "Blocked", color: .label),
|
||||
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
|
||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||
primaryButtonBackgroundColor: .systemBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user