From e9ae08b595ad96a609a0ac76e8da44c54d9e2d0a Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sun, 14 Jun 2026 18:27:27 -0400 Subject: [PATCH] fix: enforce uninstall protection in background and lock its toggle during hard sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in Uninstall Protection: 1. Background gap — app-removal denial was only recomputed on the foreground path (RuleEnforcer.refresh), so a Hard Mode window that started or ended while the app was closed left denyAppRemoval out of sync (an escape hatch on start; stuck-on after end). 2. The Settings toggle could be turned off mid-block, defeating the feature. Changes: - Add AppGroup.uninstallProtectionKey so the extensions can read the opt-in; point AppSettingsStore at it. - Add snapshot-based UninstallProtectionPolicy (mirrors RulePolicy's active/hard-locked semantics, including scheduled-today for limit rules; a parity unit test guards against drift) and UninstallProtectionEnforcer. Call reconcile() from the DeviceActivity monitor (interval start/end, usage threshold) and ShieldAction (after a granted open) so denial tracks hard blocks even while the app is closed. - Lock the Settings toggle while any Hard Mode rule is actively blocking: the switch is replaced by a red lock (mirrors Home's "Currently Blocking" rows) via RulePolicy.canToggleUninstallProtection, with an explanatory notice; guard the binding setter as defense in depth. - Update RULES_FEATURE_SPEC §6/§6.1; add unit + UI tests (218 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenAppLock/Logic/RulePolicy.swift | 10 ++ OpenAppLock/Services/AppSettings.swift | 4 +- OpenAppLock/Views/Settings/SettingsView.swift | 43 +++++- .../DeviceActivityMonitorExtension.swift | 13 ++ .../ShieldActionExtension.swift | 6 + OpenAppLockTests/RulePolicyTests.swift | 146 ++++++++++++++++++ .../UninstallProtectionEnforcerTests.swift | 87 +++++++++++ OpenAppLockUITests/SettingsUITests.swift | 15 ++ Shared/AppGroup.swift | 5 + Shared/UninstallProtectionEnforcer.swift | 31 ++++ Shared/UninstallProtectionPolicy.swift | 64 ++++++++ docs/RULES_FEATURE_SPEC.md | 41 +++-- 12 files changed, 447 insertions(+), 18 deletions(-) create mode 100644 OpenAppLockTests/UninstallProtectionEnforcerTests.swift create mode 100644 Shared/UninstallProtectionEnforcer.swift create mode 100644 Shared/UninstallProtectionPolicy.swift diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index 6b6c989..2ef1840 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -81,6 +81,16 @@ enum RulePolicy { !isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar) } + /// Whether the Uninstall Protection toggle may be changed right now. It is + /// locked while any hard-mode rule is actively blocking — turning it off + /// mid-block would be an escape hatch, the very thing it exists to prevent. + static func canToggleUninstallProtection( + rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + !isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar) + } + /// Whether the device's app removal should be denied right now: the user has /// turned on Uninstall Protection *and* a hard block is currently in force. /// Engaging this while a hard rule blocks makes the block harder to escape diff --git a/OpenAppLock/Services/AppSettings.swift b/OpenAppLock/Services/AppSettings.swift index e8be113..cf3284f 100644 --- a/OpenAppLock/Services/AppSettings.swift +++ b/OpenAppLock/Services/AppSettings.swift @@ -20,7 +20,9 @@ protocol AppSettingsReading: AnyObject { /// Observable so the Settings screen's toggle stays in sync. @Observable final class AppSettingsStore: AppSettingsReading { - static let uninstallProtectionKey = "uninstallProtectionEnabled" + /// The single source of truth for the key is `AppGroup`, so the app and the + /// extensions read and write the same defaults entry. + static let uninstallProtectionKey = AppGroup.uninstallProtectionKey @ObservationIgnored private let defaults: UserDefaults diff --git a/OpenAppLock/Views/Settings/SettingsView.swift b/OpenAppLock/Views/Settings/SettingsView.swift index 482e9f5..5336b53 100644 --- a/OpenAppLock/Views/Settings/SettingsView.swift +++ b/OpenAppLock/Views/Settings/SettingsView.swift @@ -20,15 +20,36 @@ struct SettingsView: View { NavigationStack { List { Section { - Toggle("Uninstall Protection", isOn: uninstallProtectionBinding) - .accessibilityIdentifier("uninstallProtectionToggle") + if isUninstallProtectionLocked { + // Mirror the Home "Currently Blocking" hard-row treatment: + // the control is replaced by a red lock so the setting + // can't be turned off mid-block (its whole purpose). + HStack { + Text("Uninstall Protection") + Spacer() + Image(systemName: "lock.fill") + .foregroundStyle(.red) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("uninstallProtectionLockIcon") + } else { + Toggle("Uninstall Protection", isOn: uninstallProtectionBinding) + .accessibilityIdentifier("uninstallProtectionToggle") + } } header: { Text("Protection").textCase(nil) } footer: { - Text( - "While on, apps can't be deleted from this device whenever a " - + "Hard Mode rule is actively blocking — so the block can't be " - + "removed by uninstalling.") + if isUninstallProtectionLocked { + Text( + "Locked while a Hard Mode rule is actively blocking — Uninstall " + + "Protection can't be changed until the block ends.") + .accessibilityIdentifier("uninstallProtectionLockedNotice") + } else { + Text( + "While on, apps can't be deleted from this device whenever a " + + "Hard Mode rule is actively blocking — so the block can't be " + + "removed by uninstalling.") + } } Section { NavigationLink { @@ -48,12 +69,22 @@ struct SettingsView: View { } } + /// True while any Hard Mode rule is actively blocking, which locks the + /// toggle (it must not be turned off while a hard block is in force). + private var isUninstallProtectionLocked: Bool { + !RulePolicy.canToggleUninstallProtection( + rules: rules, usageFor: { enforcer.usage(for: $0) }) + } + /// Drives the toggle's visual state from `@State` while persisting and /// re-enforcing on every change — so protection engages/lifts immediately. private var uninstallProtectionBinding: Binding { Binding( get: { uninstallProtectionOn }, set: { newValue in + // Defense in depth: the locked row shows no toggle, but never + // let a write through while a hard block is in force. + guard !isUninstallProtectionLocked else { return } uninstallProtectionOn = newValue settings.uninstallProtectionEnabled = newValue enforcer.refresh(rules: rules) diff --git a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift index dd75917..4925a16 100644 --- a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift +++ b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift @@ -26,6 +26,16 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { ) } + /// Re-evaluates Uninstall Protection from the snapshots + opt-in after each + /// callback, so app-removal denial tracks hard-mode blocks even while the + /// app is closed. + private var uninstallProtection: UninstallProtectionEnforcer { + UninstallProtectionEnforcer( + snapshots: RuleSnapshotStore(), + shields: ManagedSettingsShieldController() + ) + } + override func intervalDidStart(for activity: DeviceActivityName) { super.intervalDidStart(for: activity) if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) { @@ -35,6 +45,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { // pause and the midnight-crossing rule). scheduleEnforcement.reconcile(ruleID: ruleID) } + uninstallProtection.reconcile() } override func intervalDidEnd(for activity: DeviceActivityName) { @@ -48,6 +59,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { // one clears. scheduleEnforcement.reconcile(ruleID: ruleID) } + uninstallProtection.reconcile() } override func eventDidReachThreshold( @@ -58,5 +70,6 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { let minutes = MonitoringPlan.minutes(fromEventName: event.rawValue) else { return } enforcement.handleUsageMinutes(minutes, ruleID: ruleID) + uninstallProtection.reconcile() } } diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift index cdac491..46a610a 100644 --- a/OpenAppLockShieldAction/ShieldActionExtension.swift +++ b/OpenAppLockShieldAction/ShieldActionExtension.swift @@ -74,6 +74,12 @@ final class ShieldActionExtension: ShieldActionDelegate { return .defer } startOpenSession(ruleID: ruleID) + // Keep Uninstall Protection in step with the (possibly changed) blocking + // state now that an open was spent. + UninstallProtectionEnforcer( + snapshots: RuleSnapshotStore(), + shields: ManagedSettingsShieldController() + ).reconcile() return .none } diff --git a/OpenAppLockTests/RulePolicyTests.swift b/OpenAppLockTests/RulePolicyTests.swift index 86d42d1..f4b136f 100644 --- a/OpenAppLockTests/RulePolicyTests.swift +++ b/OpenAppLockTests/RulePolicyTests.swift @@ -135,4 +135,150 @@ struct UninstallProtectionPolicyTests { rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) }, at: mondayDuringWork, calendar: utc)) } + + @Test("The toggle is locked while a hard rule is actively blocking") + func toggleLockedDuringHardBlock() { + let hard = scheduleRule(hardMode: true) + // Actively blocking → locked. + #expect( + !RulePolicy.canToggleUninstallProtection( + rules: [hard], at: mondayDuringWork, calendar: utc)) + // Outside its window → editable again. + #expect( + RulePolicy.canToggleUninstallProtection( + rules: [hard], at: mondayEvening, calendar: utc)) + } + + @Test("The toggle stays editable when only a soft rule is blocking") + func toggleEditableWithSoftRule() { + let soft = scheduleRule(hardMode: false) + #expect( + RulePolicy.canToggleUninstallProtection( + rules: [soft], at: mondayDuringWork, calendar: utc)) + } + + @Test("A spent hard-mode limit rule locks the toggle") + func toggleLockedByHardLimit() { + let rule = hardLimitRule() + #expect( + !RulePolicy.canToggleUninstallProtection( + rules: [rule], usageFor: { _ in RuleUsage(minutesUsed: 45) }, + at: mondayDuringWork, calendar: utc)) + #expect( + RulePolicy.canToggleUninstallProtection( + rules: [rule], usageFor: { _ in RuleUsage(minutesUsed: 10) }, + at: mondayDuringWork, calendar: utc)) + } +} + +/// The snapshot-based mirror of the uninstall-protection policy, used by the +/// background (extension) enforcement path. It must agree with `RulePolicy` — +/// the parity test below is the anti-drift guard. +@MainActor +@Suite("Uninstall protection policy (snapshot)") +struct UninstallProtectionSnapshotPolicyTests { + let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00 + let mondayEvening = date(2025, 1, 6, 19, 0) // outside it + + func scheduleRule(hardMode: Bool) -> BlockingRule { + BlockingRule(name: "Work Time", hardMode: hardMode) + } + + /// A hard time-limit blocking every day, so it is scheduled on the Monday + /// the test dates fall on. + func hardLimitRule() -> BlockingRule { + BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + hardMode: true, + days: Weekday.everyDay) + } + + /// A hard time-limit scheduled only on Tuesdays — never active on the test + /// Mondays even when its budget is spent. + func tuesdayHardLimitRule() -> BlockingRule { + BlockingRule( + name: "Tuesday Only", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + hardMode: true, + days: [.tuesday]) + } + + @Test("App removal denied only with the setting on AND a hard snapshot active") + func deniedOnlyWhenEnabledAndHardLocked() { + let snap = RuleSnapshot(rule: scheduleRule(hardMode: true)) + #expect( + !UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: false, at: mondayDuringWork, calendar: utc)) + #expect( + UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, at: mondayDuringWork, calendar: utc)) + #expect( + !UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, at: mondayEvening, calendar: utc)) + } + + @Test("A soft snapshot never triggers denial") + func softRuleNeverDenies() { + let snap = RuleSnapshot(rule: scheduleRule(hardMode: false)) + #expect( + !UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, at: mondayDuringWork, calendar: utc)) + } + + @Test("A spent hard-mode limit snapshot denies; unspent does not") + func spentHardLimitDenies() { + let snap = RuleSnapshot(rule: hardLimitRule()) + #expect( + UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 45) }, + at: mondayDuringWork, calendar: utc)) + #expect( + !UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) }, + at: mondayDuringWork, calendar: utc)) + } + + @Test("A spent hard limit not scheduled today does not deny") + func spentButNotScheduledTodayDoesNotDeny() { + // Guards the WIP bug: omitting the scheduled-today check would wrongly + // deny removal for a Tuesday-only rule on a Monday. + let snap = RuleSnapshot(rule: tuesdayHardLimitRule()) + #expect( + !UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 99) }, + at: mondayDuringWork, calendar: utc)) + } + + @Test("Snapshot policy agrees with RulePolicy across representative cases") + func parityWithRulePolicy() { + let disabled = BlockingRule(name: "Off", isEnabled: false, hardMode: true) + let cases: [(BlockingRule, RuleUsage?)] = [ + (scheduleRule(hardMode: true), nil), + (scheduleRule(hardMode: false), nil), + (hardLimitRule(), RuleUsage(minutesUsed: 45)), + (hardLimitRule(), RuleUsage(minutesUsed: 10)), + (tuesdayHardLimitRule(), RuleUsage(minutesUsed: 99)), + (disabled, nil), + ] + for (rule, usage) in cases { + let snap = RuleSnapshot(rule: rule) + for moment in [mondayDuringWork, mondayEvening] { + #expect( + UninstallProtectionPolicy.isHardLocked( + snap, usage: usage, at: moment, calendar: utc) + == RulePolicy.isHardLocked( + rule, usage: usage, at: moment, calendar: utc)) + for enabled in [true, false] { + #expect( + UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: [snap], enabled: enabled, usageFor: { _ in usage }, + at: moment, calendar: utc) + == RulePolicy.shouldDenyAppRemoval( + rules: [rule], enabled: enabled, usageFor: { _ in usage }, + at: moment, calendar: utc)) + } + } + } + } } diff --git a/OpenAppLockTests/UninstallProtectionEnforcerTests.swift b/OpenAppLockTests/UninstallProtectionEnforcerTests.swift new file mode 100644 index 0000000..fe2a33d --- /dev/null +++ b/OpenAppLockTests/UninstallProtectionEnforcerTests.swift @@ -0,0 +1,87 @@ +// +// UninstallProtectionEnforcerTests.swift +// OpenAppLockTests +// + +import Foundation +import Testing + +@testable import OpenAppLock + +/// The background (extension) path that recomputes app-removal denial from the +/// shared snapshots + the persisted opt-in, mirroring `RuleEnforcer.refresh`'s +/// foreground decision. +@MainActor +@Suite("Uninstall protection background enforcer") +struct UninstallProtectionEnforcerTests { + let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00 + let mondayEvening = date(2025, 1, 6, 19, 0) // outside it + + private func freshDefaults() -> UserDefaults { + let name = "uninstall-enforcer-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + /// Wires an enforcer whose stores all read from one isolated defaults suite, + /// pre-seeded with the given snapshots and opt-in flag. + private func makeEnforcer( + snapshots: [RuleSnapshot], enabled: Bool, shields: MockShieldController + ) -> UninstallProtectionEnforcer { + let defaults = freshDefaults() + defaults.set(enabled, forKey: AppGroup.uninstallProtectionKey) + let store = RuleSnapshotStore(defaults: defaults) + store.save(snapshots) + return UninstallProtectionEnforcer( + snapshots: store, shields: shields, + ledger: UsageLedger(defaults: defaults), defaults: defaults) + } + + private func hardSchedule() -> RuleSnapshot { + RuleSnapshot(rule: BlockingRule(name: "Locked In", hardMode: true)) + } + + @Test("Denies removal when opted in and a hard rule is actively blocking") + func deniesWhenEnabledAndHardActive() { + let shields = MockShieldController() + let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: true, shields: shields) + + enforcer.reconcile(at: mondayDuringWork, calendar: utc) + + #expect(shields.appRemovalDenied) + } + + @Test("Does not deny when the opt-in is off") + func doesNotDenyWhenDisabled() { + let shields = MockShieldController() + let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: false, shields: shields) + + enforcer.reconcile(at: mondayDuringWork, calendar: utc) + + #expect(!shields.appRemovalDenied) + } + + @Test("A soft rule never denies, even opted in") + func doesNotDenyForSoftRule() { + let shields = MockShieldController() + let soft = RuleSnapshot(rule: BlockingRule(name: "Work Time")) + let enforcer = makeEnforcer(snapshots: [soft], enabled: true, shields: shields) + + enforcer.reconcile(at: mondayDuringWork, calendar: utc) + + #expect(!shields.appRemovalDenied) + } + + @Test("Denial lifts once the hard window ends") + func liftsWhenWindowEnds() { + let shields = MockShieldController() + let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: true, shields: shields) + + enforcer.reconcile(at: mondayDuringWork, calendar: utc) + #expect(shields.appRemovalDenied) + + enforcer.reconcile(at: mondayEvening, calendar: utc) + #expect(!shields.appRemovalDenied) + } +} diff --git a/OpenAppLockUITests/SettingsUITests.swift b/OpenAppLockUITests/SettingsUITests.swift index b2c136d..3987853 100644 --- a/OpenAppLockUITests/SettingsUITests.swift +++ b/OpenAppLockUITests/SettingsUITests.swift @@ -24,6 +24,21 @@ final class SettingsUITests: XCTestCase { XCTAssertEqual(toggle.value as? String, "1", "Tapping should turn it on") } + func testUninstallProtectionLockedDuringHardSession() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") + app.goToSettingsTab() + + // While the seeded "Locked In" Hard Mode rule is blocking, the toggle is + // replaced by a lock (mirroring Home's "Currently Blocking" rows) so the + // protection can't be turned off mid-block. + app.element("uninstallProtectionLockedNotice").waitToAppear() + app.element("uninstallProtectionLockIcon").waitToAppear() + XCTAssertFalse( + app.switches["uninstallProtectionToggle"].exists, + "The Uninstall Protection switch must be hidden while a Hard Mode rule is blocking" + ) + } + func testManageAppListsCreateFlow() throws { let app = XCUIApplication.launchOpenAppLock() app.goToSettingsTab() diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift index 83f1632..c8061df 100644 --- a/Shared/AppGroup.swift +++ b/Shared/AppGroup.swift @@ -11,6 +11,11 @@ import Foundation enum AppGroup { static let identifier = "group.dev.bchen.OpenAppLock" + /// Defaults key for the Uninstall Protection opt-in. Lives here (not on the + /// app-only `AppSettingsStore`) so the Screen Time extensions can read the + /// same setting when recomputing app-removal denial in the background. + static let uninstallProtectionKey = "uninstallProtectionEnabled" + /// Shared defaults; falls back to standard defaults when the group /// container is unavailable (e.g. entitlement not provisioned yet). static var defaults: UserDefaults { diff --git a/Shared/UninstallProtectionEnforcer.swift b/Shared/UninstallProtectionEnforcer.swift new file mode 100644 index 0000000..e78b021 --- /dev/null +++ b/Shared/UninstallProtectionEnforcer.swift @@ -0,0 +1,31 @@ +// +// UninstallProtectionEnforcer.swift +// OpenAppLock +// + +import Foundation + +/// Background half of Uninstall Protection: recomputes device app-removal +/// denial from the shared rule snapshots and the persisted opt-in, then applies +/// it through the shield layer. The DeviceActivity monitor and ShieldAction +/// extensions call `reconcile()` after handling an event so protection stays in +/// step with hard-mode blocks even while the app is closed — mirroring what +/// `RuleEnforcer.refresh` does in the foreground. +struct UninstallProtectionEnforcer { + let snapshots: RuleSnapshotStore + let shields: ShieldApplying + /// Day-usage source for limit rules; reads the shared ledger by default. + var ledger = UsageLedger() + /// Where the opt-in flag is read from; the shared app-group suite by default. + var defaults: UserDefaults = AppGroup.defaults + + func reconcile(at now: Date = .now, calendar: Calendar = .current) { + let enabled = defaults.bool(forKey: AppGroup.uninstallProtectionKey) + let deny = UninstallProtectionPolicy.shouldDenyAppRemoval( + snapshots: snapshots.load(), + enabled: enabled, + usageFor: { ledger.usage(for: $0.id, onDayContaining: now, calendar: calendar) }, + at: now, calendar: calendar) + shields.setAppRemovalDenied(deny) + } +} diff --git a/Shared/UninstallProtectionPolicy.swift b/Shared/UninstallProtectionPolicy.swift new file mode 100644 index 0000000..15c26bb --- /dev/null +++ b/Shared/UninstallProtectionPolicy.swift @@ -0,0 +1,64 @@ +// +// UninstallProtectionPolicy.swift +// OpenAppLock +// + +import Foundation + +/// Snapshot-based mirror of the uninstall-protection decision in `RulePolicy`, +/// living in `Shared` so the Screen Time extensions (which cannot open the +/// SwiftData store) can recompute it in the background from `RuleSnapshot`s. +/// +/// The active / hard-locked semantics deliberately match +/// `BlockingRule.status(...).isActive` exactly so the foreground and background +/// paths never disagree; a parity unit test enforces this. +enum UninstallProtectionPolicy { + /// Whether device app removal should be denied right now: the user opted in + /// (`enabled`) *and* some snapshot is actively blocking with Hard Mode on. + static func shouldDenyAppRemoval( + snapshots: [RuleSnapshot], enabled: Bool, + usageFor: (RuleSnapshot) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + enabled && isAnyHardLocked(snapshots: snapshots, usageFor: usageFor, at: now, calendar: calendar) + } + + /// Whether any snapshot is currently a hard block. + static func isAnyHardLocked( + snapshots: [RuleSnapshot], + usageFor: (RuleSnapshot) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + snapshots.contains { + isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar) + } + } + + /// True while the snapshot is actively blocking with Hard Mode on. + static func isHardLocked( + _ snapshot: RuleSnapshot, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + snapshot.hardMode && isActive(snapshot, usage: usage, at: now, calendar: calendar) + } + + /// Whether the snapshot is actively blocking right now. Mirrors + /// `BlockingRule.status(...).isActive`: schedule rules block by the clock; + /// limit rules block once the day's budget is spent on an enabled day. A + /// paused (unblocked) rule is never active. + static func isActive( + _ snapshot: RuleSnapshot, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + guard snapshot.isEnabled, !snapshot.isPaused(at: now) else { return false } + switch snapshot.kind { + case .schedule: + return snapshot.schedule.isActive(at: now, calendar: calendar) + case .timeLimit, .openLimit: + guard let usage, snapshot.isScheduledToday(at: now, calendar: calendar) else { + return false + } + return snapshot.limitReached(given: usage) + } + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index ead2d9b..33c1e25 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -534,7 +534,7 @@ it runs regardless of the selected tab. |---|---| | Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a ` · ` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a ` · NN of MM used today` subtitle + trailing remaining/blocked label. | | Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | -| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). | +| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). | | Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | | New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | | Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule | @@ -547,17 +547,36 @@ icon-pair/circle-button chrome. A device-wide opt-in that makes Hard Mode harder to escape: while it is on **and** any Hard Mode rule is actively blocking, the user cannot delete apps from the -device. `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` (= setting on -AND any rule `isHardLocked`) is the single gate; `RuleEnforcer.refresh` applies it +device (so the block can't be removed by uninstalling OpenAppLock itself). The +decision (= setting on AND any rule actively blocking with Hard Mode) is applied through `ShieldApplying.setAppRemovalDenied`, which sets `ManagedSettingsStore(named: "uninstall-protection").application.denyAppRemoval` (`true` to engage, `nil` to relinquish) on a **dedicated** store so per-rule shield clears never touch it. The setting persists in the app-group defaults -(`uninstallProtectionEnabled`). - -Enforced on the **foreground path only** for v1 (launch + 30 s loop + rule change -+ scene-active). Known limitation: a Hard Mode window that *ends* while the app is -closed leaves protection engaged until the app is next foregrounded — the safe -failure direction for a locker. Background recompute in the monitor extension is a -follow-up. Like all Screen Time behavior, the real device effect is only -observable on a device (the simulator uses mock shields). +under `AppGroup.uninstallProtectionKey` (`"uninstallProtectionEnabled"`), readable +by both the app and the extensions. + +Recomputed on **both** enforcement paths so it stays correct whether the app is +open or not: +- **Foreground** — `RuleEnforcer.refresh` evaluates + `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` over the live + `BlockingRule`s (launch + 30 s loop + rule change + scene-active). +- **Background** — the DeviceActivity monitor extension (interval start/end, + usage threshold) and the ShieldAction extension (after a granted open) call + `UninstallProtectionEnforcer.reconcile()`, which evaluates the snapshot mirror + `UninstallProtectionPolicy.shouldDenyAppRemoval(snapshots:enabled:usageFor:)` + over the `RuleSnapshot`s in the app group. `UninstallProtectionPolicy` mirrors + `RulePolicy`'s active/hard-locked semantics exactly (a unit test asserts parity), + so the two paths never disagree. This closes the prior v1 gap where a Hard Mode + window that started or ended while the app was closed left protection out of sync. + +The toggle is **fully locked while any Hard Mode rule is actively blocking**: +`SettingsView` replaces the switch with a trailing red `lock.fill` +(`uninstallProtectionLockIcon`, mirroring the Home "Currently Blocking" treatment) +and shows the `uninstallProtectionLockedNotice` footer; the gate is +`RulePolicy.canToggleUninstallProtection(rules:usageFor:)` (= no rule +`isHardLocked`). It can't be turned off (or on) mid-block — turning it off would be +an escape hatch. + +Like all Screen Time behavior, the real device effect is only observable on a +device (the simulator uses mock shields and delivers no DeviceActivity callbacks).