Files
OpenAppLock/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Brendan Chen 443b37c5e1 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>
2026-06-12 21:06:08 -04:00

86 lines
3.5 KiB
Swift

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