Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions OpenAppLock/Logic/RuleStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
2 changes: 1 addition & 1 deletion OpenAppLock/Models/AppList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 16 additions & 8 deletions OpenAppLock/Services/RuleEnforcer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions OpenAppLock/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 8 additions & 2 deletions OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 38 additions & 32 deletions OpenAppLockShieldAction/ShieldActionExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
)
}
Expand Down
16 changes: 15 additions & 1 deletion OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
42 changes: 21 additions & 21 deletions OpenAppLockTests/RuleActivationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ DEVELOPMENT_TEAM = <the team ID of your Apple Developer account>

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.

Expand Down
Loading
Loading