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>
117 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|