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:
13
OpenAppLockShieldAction/Info.plist
Normal file
13
OpenAppLockShieldAction/Info.plist
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.ManagedSettings.shield-action-service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShieldActionExtension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal file
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.family-controls</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.bchen.OpenAppLock</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal file
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// ShieldActionExtension.swift
|
||||
// OpenAppLockShieldAction
|
||||
//
|
||||
|
||||
import DeviceActivity
|
||||
import Foundation
|
||||
import ManagedSettings
|
||||
|
||||
/// Handles shield button presses. The secondary "Open" button on an
|
||||
/// open-limit shield spends one open, lifts the rule's shield, and starts a
|
||||
/// one-shot DeviceActivity session after which the monitor extension
|
||||
/// re-shields.
|
||||
final class ShieldActionExtension: ShieldActionDelegate {
|
||||
override func handle(
|
||||
action: ShieldAction, for application: ApplicationToken,
|
||||
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||
) {
|
||||
switch action {
|
||||
case .primaryButtonPressed:
|
||||
completionHandler(.close)
|
||||
case .secondaryButtonPressed:
|
||||
completionHandler(handleOpenPress(applicationToken: application))
|
||||
case .firstSecondarySubmenuItemPressed, .secondSecondarySubmenuItemPressed,
|
||||
.thirdSecondarySubmenuItemPressed:
|
||||
// Our shields define no submenu items.
|
||||
completionHandler(.close)
|
||||
@unknown default:
|
||||
completionHandler(.close)
|
||||
}
|
||||
}
|
||||
|
||||
override func handle(
|
||||
action: ShieldAction, for webDomain: WebDomainToken,
|
||||
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||
) {
|
||||
completionHandler(.close)
|
||||
}
|
||||
|
||||
override func handle(
|
||||
action: ShieldAction, for category: ActivityCategoryToken,
|
||||
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||
) {
|
||||
switch action {
|
||||
case .secondaryButtonPressed:
|
||||
if let snapshot = ShieldLookup.openLimitSnapshot(
|
||||
containingCategory: category, in: RuleSnapshotStore().load()) {
|
||||
completionHandler(grantOpen(ruleID: snapshot.id))
|
||||
} else {
|
||||
completionHandler(.close)
|
||||
}
|
||||
default:
|
||||
completionHandler(.close)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOpenPress(applicationToken: ApplicationToken) -> ShieldActionResponse {
|
||||
guard
|
||||
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||
containingApplication: applicationToken, in: RuleSnapshotStore().load())
|
||||
else { return .close }
|
||||
return grantOpen(ruleID: snapshot.id)
|
||||
}
|
||||
|
||||
private func grantOpen(ruleID: UUID) -> ShieldActionResponse {
|
||||
let enforcement = LimitEnforcement(
|
||||
snapshots: RuleSnapshotStore(),
|
||||
ledger: UsageLedger(),
|
||||
shields: ManagedSettingsShieldController()
|
||||
)
|
||||
guard enforcement.handleOpenRequest(ruleID: ruleID) else {
|
||||
// Out of opens; keep the shield up (it re-renders with the
|
||||
// exhausted message on next presentation).
|
||||
return .defer
|
||||
}
|
||||
startOpenSession(ruleID: ruleID)
|
||||
return .none
|
||||
}
|
||||
|
||||
/// Times the granted open with a one-shot activity. One extra minute over
|
||||
/// the advertised session keeps the interval above DeviceActivity's
|
||||
/// 15-minute minimum.
|
||||
private func startOpenSession(ruleID: UUID) {
|
||||
let calendar = Calendar.current
|
||||
let now = Date.now
|
||||
guard
|
||||
let end = calendar.date(
|
||||
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
|
||||
else { return }
|
||||
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
|
||||
let schedule = DeviceActivitySchedule(
|
||||
intervalStart: calendar.dateComponents(components, from: now),
|
||||
intervalEnd: calendar.dateComponents(components, from: end),
|
||||
repeats: false
|
||||
)
|
||||
try? DeviceActivityCenter().startMonitoring(
|
||||
DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)),
|
||||
during: schedule
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user