Files
OpenAppLock/OpenAppLock/Services/RuleEnforcer.swift
Brendan Chen 32b1694e0a feat: re-skin to native three-tab UI and add Uninstall Protection
Replace the custom themed presentation with the bare native iOS design
language across a three-tab TabView (Home / Rules / Settings):

- MainTabView hosts the enforcement lifecycle (refresh loop, rule-change
  and scene-active reconcile) so it runs regardless of the selected tab
- HomeView: "Currently Blocking" + "Usage" sections (replaces AppsHomeView)
- RulesListView: rules grouped into Schedule / Time Limit / Open Limit
- SettingsView: Uninstall Protection toggle + Manage App Lists
- AppListLibraryView / ManageAppListsView: shared App List management
- AppSettings: app-group-backed settings store

Uninstall Protection: while enabled and any Hard Mode rule is actively
blocking, deny device app-removal via a dedicated ManagedSettingsStore
(RulePolicy.shouldDenyAppRemoval -> RuleEnforcer -> ShieldController).

Slim AppListPickerSheet to the picker role; drop AppsHomeView and the
custom Theme/HoldToCommitButton/RuleCardView chrome. Update spec section 6
and the unit/UI test suites accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:19:24 -04:00

117 lines
5.1 KiB
Swift

//
// RuleEnforcer.swift
// OpenAppLock
//
import Foundation
import Observation
/// Turns the current set of rules into shield state: schedule rules with an
/// active, un-paused window are shielded, and limit rules whose daily budget
/// is spent (per the usage ledger) are shielded until midnight; everything
/// else is cleared.
///
/// Background transitions (and usage tracking itself) belong to the
/// DeviceActivity monitor extension; this keeps shields correct while the
/// app runs.
@Observable
final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = []
private let shields: ShieldApplying
/// Mirrors rules to the app group and keeps DeviceActivity monitoring in
/// step; nil in UI-test launches.
private let scheduler: RuleScheduler?
/// Day-usage source consulted for limit rules; also exposed to views for
/// the Usage section.
let usageReader: UsageReading
/// Granted-open sessions, so a proactively-gated open-limit rule is left
/// un-shielded while the user is inside a session they paid an open for.
private let openSessions: OpenSessionReading
/// App-wide settings (currently just Uninstall Protection) consulted on
/// every refresh.
private let settings: any AppSettingsReading
init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore(),
settings: any AppSettingsReading = AppSettingsStore()
) {
self.shields = shields
self.usageReader = usage
self.scheduler = scheduler
self.openSessions = openSessions
self.settings = settings
}
/// The day's usage for a rule (nil for schedule rules, which don't track).
func usage(
for rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> RuleUsage? {
guard rule.kind != .schedule else { return nil }
return usageReader.usage(for: rule.id, onDayContaining: now, calendar: calendar)
}
/// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses.
///
/// Each rule shields its *own* `ManagedSettingsStore`, and Screen Time
/// unions shields across stores, so overlapping rules enforce strictly:
/// an app is blocked if *any* covering rule blocks it (see spec §4.8). A
/// rule is shielded when it is actively blocking (a schedule window is
/// open, or a limit budget is spent) *or* when it is an open-limit rule
/// that must gate its apps so opens can be counted.
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
var blocking: Set<UUID> = []
var shielded: Set<UUID> = []
for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
let usage = usage(for: rule, at: now, calendar: calendar)
let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
if isBlocking { blocking.insert(rule.id) }
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
continue
}
shielded.insert(rule.id)
shields.applyShield(
ruleID: rule.id,
selectionData: rule.appList?.selectionData,
// Allow Only and Block Adult Content are Schedule-only options;
// the model already forces .block / false on limit rules, so we
// can forward the rule's values directly.
mode: rule.selectionMode,
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: shielded)
// "Blocked Apps" lists only rules whose budget/window is spent not the
// proactive open-limit gate, which surfaces under "Usage" instead.
blockingRuleIDs = blocking
// Uninstall Protection: deny device app removal while the user has opted
// in and any Hard Mode rule is actively blocking.
shields.setAppRemovalDenied(
RulePolicy.shouldDenyAppRemoval(
rules: rules,
enabled: settings.uninstallProtectionEnabled,
usageFor: { usage(for: $0, at: now, calendar: calendar) },
at: now, calendar: calendar))
scheduler?.sync(rules: rules, at: now)
}
/// Whether an open-limit rule should carry its proactive gate right now:
/// enabled, scheduled today, not unblocked, and not inside a granted open
/// session (which would otherwise be cut short). Mirrors
/// `LimitEnforcement.handleDayStart` so the foreground and background agree.
private func shouldGateOpenLimit(
_ rule: BlockingRule, at now: Date, calendar: Calendar
) -> Bool {
rule.kind == .openLimit
&& rule.isEnabled
&& rule.pausedUntil == nil
&& rule.isScheduledToday(at: now, calendar: calendar)
&& !openSessions.hasActiveSession(for: rule.id, at: now)
}
}