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:
2026-06-12 21:06:08 -04:00
parent df6b7b689d
commit 443b37c5e1
29 changed files with 1686 additions and 24 deletions

View 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.ManagedSettingsUI.shield-configuration-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShieldConfigurationExtension</string>
</dict>
</dict>
</plist>

View 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>

View 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
)
}
}