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/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. 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. 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/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 diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift index c131395..01e2c5e 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( @@ -129,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 ) } diff --git a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift index 40b36dd..901a5c3 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(-MonitoringPlan.openSessionJustBlockedDelaySeconds) && 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/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) } } 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. 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/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/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/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. 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 diff --git a/Shared/Stores/OpenSessionStore.swift b/Shared/Stores/OpenSessionStore.swift index c343116..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,23 +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 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] ?? [:] } }