From 9b52048b74891aa9eab40c170856a5bbcc6d13ff Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 19:58:58 -0400 Subject: [PATCH 01/11] Refactor the handlers to be more readable top-to-down --- .../ShieldActionExtension.swift | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift index c131395..27fc374 100644 --- a/OpenAppLockShieldAction/ShieldActionExtension.swift +++ b/OpenAppLockShieldAction/ShieldActionExtension.swift @@ -12,6 +12,8 @@ import ManagedSettings /// one-shot DeviceActivity session after which the monitor extension /// re-shields. final class ShieldActionExtension: ShieldActionDelegate { + // MARK: Overrides + override func handle( action: ShieldAction, for application: ApplicationToken, completionHandler: @escaping (ShieldActionResponse) -> Void @@ -34,21 +36,23 @@ final class ShieldActionExtension: ShieldActionDelegate { action: ShieldAction, for webDomain: WebDomainToken, completionHandler: @escaping (ShieldActionResponse) -> Void ) { + // TODO: support open limits for web domains completionHandler(.close) } override func handle( - action: ShieldAction, for category: ActivityCategoryToken, + action: ShieldAction, for categoryToken: ActivityCategoryToken, completionHandler: @escaping (ShieldActionResponse) -> Void ) { switch action { case .secondaryButtonPressed: - if let snapshot = arbitratedOpenLimitSnapshot({ lookup in - ShieldLookup.openLimitSnapshot( - containingCategory: category, in: lookup.snapshots, - usage: lookup.usage, hasActiveOpenSession: lookup.hasActiveOpenSession, - at: lookup.now) - }) { + let lookupEnvironment = LookupEnvironment.construct() + let snapshot = ShieldLookup.openLimitSnapshot( + containingCategory: categoryToken, in: lookupEnvironment.snapshots, + usage: lookupEnvironment.usage, hasActiveOpenSession: lookupEnvironment.hasActiveOpenSession, + at: lookupEnvironment.now) + + if let snapshot = snapshot { completionHandler(grantOpen(ruleID: snapshot.id)) } else { completionHandler(.close) @@ -57,16 +61,20 @@ final class ShieldActionExtension: ShieldActionDelegate { completionHandler(.close) } } + + // MARK: Open limit handler private func handleOpenPress(applicationToken: ApplicationToken) -> ShieldActionResponse { - guard let snapshot = arbitratedOpenLimitSnapshot({ lookup in - ShieldLookup.openLimitSnapshot( - containingApplication: applicationToken, in: lookup.snapshots, - usage: lookup.usage, hasActiveOpenSession: lookup.hasActiveOpenSession, - at: lookup.now) - }) - else { return .close } - return grantOpen(ruleID: snapshot.id) + let lookupEnvironment = LookupEnvironment.construct() + let snapshot = ShieldLookup.openLimitSnapshot( + containingApplication: applicationToken, in: lookupEnvironment.snapshots, + usage: lookupEnvironment.usage, hasActiveOpenSession: lookupEnvironment.hasActiveOpenSession, + at: lookupEnvironment.now) + + if let snapshot = snapshot { + return grantOpen(ruleID: snapshot.id) + } + return .close } /// Everything a `ShieldLookup` arbitration reads, captured at one instant so @@ -76,26 +84,21 @@ final class ShieldActionExtension: ShieldActionDelegate { let usage: (UUID) -> RuleUsageDTO let hasActiveOpenSession: (UUID) -> Bool let now: Date - } - - /// Runs a `ShieldLookup` query against the live stores, applying the same - /// arbitration the shield UI uses — nil when another covering rule is - /// actively blocking, so a press on a stale shield cannot waste an open - /// that would not actually lift the block. - private func arbitratedOpenLimitSnapshot( - _ find: (LookupEnvironment) -> RuleSnapshotDTO? - ) -> RuleSnapshotDTO? { - let ledger = UsageLedger() - let sessions = OpenSessionStore() - let now = Date.now - return find( - LookupEnvironment( + + static func construct() -> LookupEnvironment { + let ledger = UsageLedger() + let sessions = OpenSessionStore() + let now = Date.now + let lookupEnvironment = LookupEnvironment( snapshots: RuleSnapshotUserDefaultsStore().load(), usage: { ledger.usage(for: $0, onDayContaining: now) }, hasActiveOpenSession: { sessions.hasActiveSession(for: $0, at: now) }, - now: now)) + now: now + ) + return lookupEnvironment + } } - + private func grantOpen(ruleID: UUID) -> ShieldActionResponse { Diag.log(.session, .event, "shieldAction Open pressed rule-\(ruleID.logTag)") let enforcement = LimitEnforcement( From ec423b7831411d2430f43fe3f6f9146fe02b6b86 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 20:44:49 -0400 Subject: [PATCH 02/11] Implement Equatable for AppList --- OpenAppLock/Models/AppList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenAppLock/Models/AppList.swift b/OpenAppLock/Models/AppList.swift index b7d1a52..f47c223 100644 --- a/OpenAppLock/Models/AppList.swift +++ b/OpenAppLock/Models/AppList.swift @@ -10,7 +10,7 @@ import SwiftData /// list, so editing the list affects every rule that uses it. Deleting a list /// detaches it from its rules (they fall back to "no apps"). @Model -final class AppList { +final class AppList: Equatable { @Attribute(.unique) var id: UUID var name: String /// Encoded `FamilyActivitySelection` (opaque tokens). Nil until apps are picked. From 02f3d990921e6f16479ff67cf4741f21ec8202e2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 21:05:40 -0400 Subject: [PATCH 03/11] Rename RuleActivation.inactive to .notBlockingNow --- OpenAppLock/Logic/RuleStatus.swift | 5 +++-- Shared/DTOs/RuleSnapshotDTO.swift | 1 + Shared/Models/RuleActivation.swift | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 7b70e45..52443ed 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -55,11 +55,12 @@ nonisolated extension RuleSnapshotDTO { ) -> RuleStatus { guard isEnabled else { return .disabled } guard !days.isEmpty else { return .dormant } + switch activation(usage: usage, at: now, calendar: calendar) { case .active(let until): return .active(until: until) case .paused(let until): return .paused(until: until) - case .inactive(let nextStart): - return nextStart.map(RuleStatus.upcoming(startsAt:)) ?? .dormant + case .notBlockingNow(let nextReset): + return nextReset.map(RuleStatus.upcoming(startsAt:)) ?? .dormant } } diff --git a/Shared/DTOs/RuleSnapshotDTO.swift b/Shared/DTOs/RuleSnapshotDTO.swift index 51ee40c..98bd325 100644 --- a/Shared/DTOs/RuleSnapshotDTO.swift +++ b/Shared/DTOs/RuleSnapshotDTO.swift @@ -48,6 +48,7 @@ nonisolated struct RuleSnapshotDTO: Codable, Equatable { } /// Whether the given usage exhausts this rule's daily budget. + /// Open limit counts opens, time limit counts minutes used. func limitReached(given usage: RuleUsageDTO, at now: Date = .now) -> Bool { switch kind { case .schedule: false diff --git a/Shared/Models/RuleActivation.swift b/Shared/Models/RuleActivation.swift index 4279041..245d751 100644 --- a/Shared/Models/RuleActivation.swift +++ b/Shared/Models/RuleActivation.swift @@ -19,7 +19,7 @@ import Foundation nonisolated enum RuleActivation: Equatable, Sendable { /// Not blocking now. `nextStart` is when the rule's next window begins, or /// nil when it never will (disabled, or no days selected). - case inactive(nextStart: Date?) + case notBlockingNow(nextReset: Date?) /// Currently blocking; ends at the associated date. case active(until: Date) /// Would be blocking, but the user temporarily paused it until the associated date. @@ -39,7 +39,7 @@ nonisolated extension RuleSnapshotDTO { func activation( usage: RuleUsageDTO?, at now: Date = .now, calendar: Calendar = .current ) -> RuleActivation { - guard isEnabled else { return .inactive(nextStart: nil) } + guard isEnabled else { return .notBlockingNow(nextReset: nil) } let currentBlockEnd: Date? switch kind { @@ -55,7 +55,7 @@ nonisolated extension RuleSnapshotDTO { } guard let end = currentBlockEnd else { - return .inactive(nextStart: schedule.nextStart(after: now, calendar: calendar)) + return .notBlockingNow(nextReset: schedule.nextStart(after: now, calendar: calendar)) } // A pause only surfaces when the rule would otherwise be blocking, and // never outlasts the block itself. From 0f74c6f0661fae6962f8dfe547c5200440a4fa71 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 21:07:58 -0400 Subject: [PATCH 04/11] Refactor the rule enforcer's enforcement logic for clarity --- OpenAppLock/Services/RuleEnforcer.swift | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 7b94fd4..eb3e2dd 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -276,15 +276,23 @@ actor RuleEnforcementEngine { let status = snapshot.status(at: now, calendar: calendar, usage: usage) let isBlocking = status.isActive logTimeLimitDecision(snapshot, usage: usage, isBlocking: isBlocking, at: now) - guard isBlocking || shouldGateOpenLimit(snapshot, at: now, calendar: calendar) else { - let ruleTag = snapshot.id.logTag - Diag.log( - .enforcer, - "rule-\(ruleTag) \(snapshot.kindRaw): not shielded (status=\(status) enabled=\(snapshot.isEnabled))") - return (isBlocking, false) + + let ruleTag = snapshot.id.logTag + + if shouldGateOpenLimit(snapshot, at: now, calendar: calendar) { + applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking) + return (isBlocking, true) + } + + if isBlocking && snapshot.kind != .openLimit { + applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking) + return (isBlocking, true) } - applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking) - return (isBlocking, true) + + Diag.log( + .enforcer, + "rule-\(ruleTag) \(snapshot.kindRaw): not shielded (status=\(status) enabled=\(snapshot.isEnabled))") + return (isBlocking, false) } /// Surfaces the time-limit block decision: the threshold count vs the budget. From e0f686ea4d505de420b9df7c024fe877ad66ab76 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 22:13:02 -0400 Subject: [PATCH 05/11] Add a debug method to reset all the open limit sessions --- OpenAppLock/Views/Settings/SettingsView.swift | 12 ++++++++++++ Shared/Stores/OpenSessionStore.swift | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/OpenAppLock/Views/Settings/SettingsView.swift b/OpenAppLock/Views/Settings/SettingsView.swift index cb0fe8e..435e8b7 100644 --- a/OpenAppLock/Views/Settings/SettingsView.swift +++ b/OpenAppLock/Views/Settings/SettingsView.swift @@ -103,6 +103,18 @@ struct SettingsView: View { .accessibilityIdentifier("openedLinkProbe") } } + +#if DEBUG + Section { + Button { + let store = OpenSessionStore() + store.expireAllActiveSessions() + } label: { + Text("Expire all open limit sessions") + } + } +#endif + } .navigationTitle(CopyKey.settingsNavigationTitle.resource) .captureLinkTaps(when: launch.isUITesting) { lastOpenedLink = $0 } diff --git a/Shared/Stores/OpenSessionStore.swift b/Shared/Stores/OpenSessionStore.swift index c343116..9c6a690 100644 --- a/Shared/Stores/OpenSessionStore.swift +++ b/Shared/Stores/OpenSessionStore.swift @@ -44,6 +44,12 @@ nonisolated final class OpenSessionStore: OpenSessionReading, @unchecked Sendabl map[ruleID.uuidString] = nil defaults.set(map, forKey: Self.key) } + + func expireAllActiveSessions() { + var map = expiries + map.removeAll() + defaults.set(map, forKey: Self.key) + } private var expiries: [String: TimeInterval] { defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:] From ad176e2b337519861055fcf86d438e43a5263cb5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:04:55 -0400 Subject: [PATCH 06/11] Display a "just blocked" screen for the open limit This screen appears if the open limit shield was activated at most 2 minutes before rendering the screen. --- .../ShieldConfigurationExtension.swift | 16 ++++++- Shared/Copy.xcstrings | 11 +++++ Shared/Copy/CopyKey.swift | 1 + Shared/Enforcement/ShieldPresentation.swift | 9 ++++ Shared/Stores/OpenSessionStore.swift | 42 ++++++++++++++----- 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift index 40b36dd..5d0a7d6 100644 --- a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift +++ b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift @@ -45,6 +45,7 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { let snapshots = RuleSnapshotUserDefaultsStore().load() let ledger = UsageLedger() let sessions = OpenSessionStore() + let now = Date.now guard let snapshot = ShieldLookup.openLimitSnapshot( @@ -55,12 +56,25 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { else { return configuration(for: .blocked) } + + let previousExpiry = sessions.getPreviousExpiry(for: snapshot.id) let usage = ledger.usage(for: snapshot.id, onDayContaining: now) + + if let previousExpiry = previousExpiry { + let difference = Date.now.distance(to: previousExpiry) + if difference > TimeInterval(-120) && usage.opensUsed < snapshot.maxOpens { + return configuration( + for: .openLimitJustBlocked(sessionMinutes: MonitoringPlan.openSessionMinutes) + ) + } + } + return configuration( for: .openLimit( opensUsed: usage.opensUsed, maxOpens: snapshot.maxOpens, - sessionMinutes: MonitoringPlan.openSessionMinutes)) + sessionMinutes: MonitoringPlan.openSessionMinutes) + ) } private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration { diff --git a/Shared/Copy.xcstrings b/Shared/Copy.xcstrings index 6948b9e..4d19a6d 100644 --- a/Shared/Copy.xcstrings +++ b/Shared/Copy.xcstrings @@ -2124,6 +2124,17 @@ } } }, + "shield.openLimitJustBlocked.subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You just used an open for this app list. You may use another open later." + } + } + } + }, "shield.primaryButtonLabel" : { "extractionState" : "manual", "localizations" : { diff --git a/Shared/Copy/CopyKey.swift b/Shared/Copy/CopyKey.swift index afc046a..ba749c3 100644 --- a/Shared/Copy/CopyKey.swift +++ b/Shared/Copy/CopyKey.swift @@ -251,6 +251,7 @@ nonisolated enum CopyKey: String, CaseIterable { case shieldBlockedSubtitle = "shield.blockedSubtitle" case shieldNoOpensLeft = "shield.noOpensLeft" case shieldOpenLimitSubtitle = "shield.openLimit.subtitle" + case shieldOpenLimitJustBlockedSubtitle = "shield.openLimitJustBlocked.subtitle" case shieldOpenButtonOne = "shield.openButtonOne" case shieldOpenButtonMany = "shield.openButtonMany" case shieldPrimaryButtonLabel = "shield.primaryButtonLabel" diff --git a/Shared/Enforcement/ShieldPresentation.swift b/Shared/Enforcement/ShieldPresentation.swift index b2bcee5..2fa7f32 100644 --- a/Shared/Enforcement/ShieldPresentation.swift +++ b/Shared/Enforcement/ShieldPresentation.swift @@ -16,6 +16,7 @@ struct ShieldPresentation: Equatable { let secondaryButton: String? static let blockedTitle = CopyKey.shieldBlockedTitle.string + static let openLimitJustBlockedTitle = CopyKey.shieldBlockedTitle.string /// A plain, fully-blocked app: no counts, no way through. static let blocked = ShieldPresentation( @@ -45,4 +46,12 @@ struct ShieldPresentation: Equatable { : CopyKey.shieldOpenButtonMany.string(remaining) ) } + + static func openLimitJustBlocked(sessionMinutes: Int) -> ShieldPresentation { + return ShieldPresentation( + title: openLimitJustBlockedTitle, + subtitle: CopyKey.shieldOpenLimitJustBlockedSubtitle.string(sessionMinutes), + secondaryButton: nil + ) + } } diff --git a/Shared/Stores/OpenSessionStore.swift b/Shared/Stores/OpenSessionStore.swift index 9c6a690..3592def 100644 --- a/Shared/Stores/OpenSessionStore.swift +++ b/Shared/Stores/OpenSessionStore.swift @@ -19,7 +19,8 @@ nonisolated protocol OpenSessionReading: AnyObject, Sendable { /// the session instead of re-locking the app mid-session. The monitor clears it /// when the session's one-shot activity ends. nonisolated final class OpenSessionStore: OpenSessionReading, @unchecked Sendable { - private static let key = "openSessionExpiry" + private static let openSessionExpiryKey = "openSessionExpiry" + private static let previousExpiryKey = "prevSessionExpiry" private let defaults: UserDefaults init(defaults: UserDefaults = AppGroup.defaults) { @@ -30,29 +31,50 @@ nonisolated final class OpenSessionStore: OpenSessionReading, @unchecked Sendabl guard let expiry = expiries[ruleID.uuidString] else { return false } return Date(timeIntervalSince1970: expiry) > now } + + func getPreviousExpiry(for ruleID: UUID) -> Date? { + guard let expiry = previousExpiries[ruleID.uuidString] else { return nil } + return Date(timeIntervalSince1970: expiry) + } /// Marks a granted open for `ruleID` running until `expiry`. func startSession(for ruleID: UUID, until expiry: Date) { var map = expiries map[ruleID.uuidString] = expiry.timeIntervalSince1970 - defaults.set(map, forKey: Self.key) + defaults.set(map, forKey: Self.openSessionExpiryKey) } /// Ends a granted open (its one-shot activity fired, or it is being reset). func endSession(for ruleID: UUID) { - var map = expiries - map[ruleID.uuidString] = nil - defaults.set(map, forKey: Self.key) + var expiryMap = expiries + expiryMap[ruleID.uuidString] = nil + + var prevExpiryMap = previousExpiries + prevExpiryMap[ruleID.uuidString] = Date.now.timeIntervalSince1970 + + defaults.set(expiryMap, forKey: Self.openSessionExpiryKey) + defaults.set(prevExpiryMap, forKey: Self.previousExpiryKey) } func expireAllActiveSessions() { - var map = expiries - map.removeAll() - defaults.set(map, forKey: Self.key) + var expiryMap = expiries + var prevExpiryMap = previousExpiries + + for (key, _) in expiryMap { + prevExpiryMap[key] = Date.now.timeIntervalSince1970 + } + expiryMap.removeAll() + + defaults.set(expiryMap, forKey: Self.openSessionExpiryKey) + defaults.set(prevExpiryMap, forKey: Self.previousExpiryKey) } - + private var expiries: [String: TimeInterval] { - defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:] + defaults.dictionary(forKey: Self.openSessionExpiryKey) as? [String: TimeInterval] ?? [:] + } + + private var previousExpiries: [String: TimeInterval] { + defaults.dictionary(forKey: Self.previousExpiryKey) as? [String: TimeInterval] ?? [:] } } From 487e074a81f0f56ed0c5d678342a882d4b31519b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:05:10 -0400 Subject: [PATCH 07/11] Explicitly stop monitoring the device activity before starting it again --- OpenAppLockShieldAction/ShieldActionExtension.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift index 27fc374..01e2c5e 100644 --- a/OpenAppLockShieldAction/ShieldActionExtension.swift +++ b/OpenAppLockShieldAction/ShieldActionExtension.swift @@ -132,8 +132,11 @@ final class ShieldActionExtension: ShieldActionDelegate { byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now) else { return } let schedule = DeviceActivityFactory.nonRepeatingSchedule(from: now, to: end, calendar: calendar) + + let deviceActivityName = DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)) + DeviceActivityCenter().stopMonitoring([deviceActivityName]) try? DeviceActivityCenter().startMonitoring( - DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)), + deviceActivityName, during: schedule ) } From d879803d5f2849aa0c7c544f3d021caa261014a8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:28:45 -0400 Subject: [PATCH 08/11] Compare against the OpenSessionStore when deciding whether to end the open limit session `intervalDidEnd` may fire when the app stops monitoring an activity, or when the activity is replaced. Thank you to [Dmitrii M. on LinkedIn](https://www.linkedin.com/posts/dmamakarov_iosdevelopment-swift-familycontrols-activity-7486012266102865920-JgmK) for validating this. --- .../DeviceActivityMonitorExtension.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift index c1dd5c0..be463ea 100644 --- a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift +++ b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift @@ -38,6 +38,10 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { shields: ManagedSettingsShieldController() ) } + + private var openSessionStore: OpenSessionStore { + OpenSessionStore() + } /// A temporary pause activity reached an interval edge: recompute the rule's /// shield from its snapshot. At the start edge the rule is still paused, so @@ -72,8 +76,10 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { super.intervalDidEnd(for: activity) Diag.log(.monitor, .event, "intervalDidEnd \(activity.rawValue)") if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) { - enforcement.handleOpenSessionEnded(ruleID: ruleID) - DeviceActivityCenter().stopMonitoring([activity]) + if !openSessionStore.hasActiveSession(for: ruleID) { + enforcement.handleOpenSessionEnded(ruleID: ruleID) + DeviceActivityCenter().stopMonitoring([activity]) + } } else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) { // A schedule window closed (or its evening half ended at 23:59): // recompute so a still-active window stays shielded and a finished From c00a08d6f16370862952cc5ab2ac49f2f6de822e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:32:24 -0400 Subject: [PATCH 09/11] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5fd905..36fcc87 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ DEVELOPMENT_TEAM = 4. Open the project in Xcode -This setup was stolen from [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire/blob/main/README.md#building); go check them out! +This setup was inspired by [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire/blob/main/README.md#building), an awesome RSS reader. By default, the app will attempt to read this file, falling back to an empty development team if the file doesn't exist. This will work for simulator testing, but not for real device testing. From 0617e6db4bffaa786919132c9627655d24335328 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:32:30 -0400 Subject: [PATCH 10/11] Fix remaining test issues --- OpenAppLockTests/RuleActivationTests.swift | 42 +++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/OpenAppLockTests/RuleActivationTests.swift b/OpenAppLockTests/RuleActivationTests.swift index eb3a1f4..5f0c25b 100644 --- a/OpenAppLockTests/RuleActivationTests.swift +++ b/OpenAppLockTests/RuleActivationTests.swift @@ -61,10 +61,10 @@ struct RuleActivationTests { == .active(until: date(2025, 1, 6, 17, 0))) } - @Test("Schedule rule outside its window is inactive with the next start") + @Test("Schedule rule outside its window is notBlockingNow with the next start") func scheduleInactiveOutsideWindow() { #expect(scheduleSnapshot().activation(usage: nil, at: date(2025, 1, 6, 19, 0), calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } @Test("Exactly at the window start counts as active") @@ -76,19 +76,19 @@ struct RuleActivationTests { @Test("Exactly at the window end is no longer active (half-open interval)") func scheduleAtWindowEnd() { #expect(scheduleSnapshot().activation(usage: nil, at: date(2025, 1, 6, 17, 0), calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } - @Test("A schedule with no days is inactive with no next start") + @Test("A schedule with no days is notBlockingNow with no next start") func scheduleEmptyDays() { #expect(scheduleSnapshot(days: []).activation(usage: nil, at: mon10, calendar: utc) - == .inactive(nextStart: nil)) + == .notBlockingNow(nextReset: nil)) } - @Test("A disabled schedule rule is inactive with no next start") + @Test("A disabled schedule rule is notBlockingNow with no next start") func scheduleDisabled() { #expect(scheduleSnapshot(isEnabled: false).activation(usage: nil, at: mon10, calendar: utc) - == .inactive(nextStart: nil)) + == .notBlockingNow(nextReset: nil)) } // MARK: Schedule — pause invariants @@ -118,7 +118,7 @@ struct RuleActivationTests { func schedulePausedButOutsideWindow() { #expect(scheduleSnapshot(pausedUntil: date(2025, 1, 6, 20, 0)) .activation(usage: nil, at: date(2025, 1, 6, 19, 0), calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } // MARK: Schedule — midnight-crossing & full day @@ -137,11 +137,11 @@ struct RuleActivationTests { == .active(until: date(2025, 1, 7, 6, 0))) } - @Test("A midnight-crossing window is inactive midday with the evening start next") + @Test("A midnight-crossing window is notBlockingNow midday with the evening start next") func crossingInactiveMidday() { let snap = scheduleSnapshot(start: 22 * 60, end: 6 * 60, days: Weekday.everyDay) #expect(snap.activation(usage: nil, at: date(2025, 1, 6, 12, 0), calendar: utc) - == .inactive(nextStart: date(2025, 1, 6, 22, 0))) + == .notBlockingNow(nextReset: date(2025, 1, 6, 22, 0))) } @Test("A full-day window is active any time on an enabled day") @@ -159,30 +159,30 @@ struct RuleActivationTests { == .active(until: tueMidnight)) } - @Test("A time-limit one minute under budget is inactive") + @Test("A time-limit one minute under budget is notBlockingNow") func timeLimitUnderBudget() { #expect(limitSnapshot().activation(usage: RuleUsageDTO(minutesUsed: 44), at: mon10, calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } - @Test("A time-limit rule without usage data is inactive") + @Test("A time-limit rule without usage data is notBlockingNow") func timeLimitNoUsage() { #expect(limitSnapshot().activation(usage: nil, at: mon10, calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } - @Test("A spent time-limit not scheduled today is inactive (the scheduled-today guard)") + @Test("A spent time-limit not scheduled today is notBlockingNow (the scheduled-today guard)") func timeLimitSpentButNotScheduledToday() { #expect(limitSnapshot(days: [.tuesday]) .activation(usage: RuleUsageDTO(minutesUsed: 99), at: mon10, calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } - @Test("A disabled time-limit with a spent budget is inactive with no next start") + @Test("A disabled time-limit with a spent budget is notBlockingNow with no next start") func timeLimitDisabledSpent() { #expect(limitSnapshot(isEnabled: false) .activation(usage: RuleUsageDTO(minutesUsed: 45), at: mon10, calendar: utc) - == .inactive(nextStart: nil)) + == .notBlockingNow(nextReset: nil)) } @Test("A paused spent time-limit clamps the pause to the next midnight") @@ -200,10 +200,10 @@ struct RuleActivationTests { == .active(until: tueMidnight)) } - @Test("Opens under budget are inactive") + @Test("Opens under budget are notBlockingNow") func openLimitUnderBudget() { #expect(openSnapshot().activation(usage: RuleUsageDTO(opensUsed: 4), at: mon10, calendar: utc) - == .inactive(nextStart: tue9)) + == .notBlockingNow(nextReset: tue9)) } // MARK: isBlocking convenience @@ -212,6 +212,6 @@ struct RuleActivationTests { func isBlockingConvenience() { #expect(RuleActivation.active(until: tueMidnight).isBlocking) #expect(!RuleActivation.paused(until: tueMidnight).isBlocking) - #expect(!RuleActivation.inactive(nextStart: nil).isBlocking) + #expect(!RuleActivation.notBlockingNow(nextReset: nil).isBlocking) } } From ba1b94d86d977f24ed4eaf681f8e78973c8d4fca Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 6 Aug 2026 23:47:07 -0400 Subject: [PATCH 11/11] Remove the magic number --- OpenAppLockShieldConfig/ShieldConfigurationExtension.swift | 2 +- Shared/Platform/MonitoringPlan.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift index 5d0a7d6..901a5c3 100644 --- a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift +++ b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift @@ -62,7 +62,7 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { if let previousExpiry = previousExpiry { let difference = Date.now.distance(to: previousExpiry) - if difference > TimeInterval(-120) && usage.opensUsed < snapshot.maxOpens { + if difference > TimeInterval(-MonitoringPlan.openSessionJustBlockedDelaySeconds) && usage.opensUsed < snapshot.maxOpens { return configuration( for: .openLimitJustBlocked(sessionMinutes: MonitoringPlan.openSessionMinutes) ) diff --git a/Shared/Platform/MonitoringPlan.swift b/Shared/Platform/MonitoringPlan.swift index 6ebf391..a93ac4d 100644 --- a/Shared/Platform/MonitoringPlan.swift +++ b/Shared/Platform/MonitoringPlan.swift @@ -32,6 +32,10 @@ nonisolated enum MonitoringPlan { /// shield can fire right at the pause's end (with one extra minute of /// interval padding, as for granted opens). static let temporaryPauseMinutes = 15 + + /// The time between when an open limit session blocks and when a user + /// can use another open. + static let openSessionJustBlockedDelaySeconds = 120 /// The always-on, midnight-to-midnight activity tracking an open-limit /// rule's day. Open limits carry no usage events and have no cross-midnight