Compare commits
4 Commits
1652c2f410
...
fix/open-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 31830cb141 | |||
| fc0f518608 | |||
| 52802a9984 | |||
| 9092221d32 |
11
AGENTS.md
11
AGENTS.md
@@ -140,9 +140,14 @@ Gotchas learned the hard way:
|
||||
limits accrue in the Usage section and block at the budget; open-limit
|
||||
apps shield immediately with an "Open (N left)" button; an open lasts
|
||||
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
|
||||
- **Schedule-rule background transitions** still rely on the app running
|
||||
(launch / foreground 30s loop); schedule rules have no DeviceActivity
|
||||
monitoring yet — only limit rules do.
|
||||
- **Schedule-rule background transitions** are now backed by DeviceActivity:
|
||||
`RuleScheduler` registers a repeating window activity per schedule rule
|
||||
(`sched-<uuid>`, plus `sched2-<uuid>` for midnight-crossing windows) and the
|
||||
monitor extension recomputes + applies/clears the shield on interval
|
||||
start/end. The foreground 30s loop remains as the reconciliation safety net
|
||||
because interval callbacks are unreliable. On-device verification of the
|
||||
background transition is still pending (the simulator does not deliver
|
||||
DeviceActivity callbacks).
|
||||
- `FamilyActivityPicker` shows few apps on the simulator; fine on device.
|
||||
- `FamilyActivityPicker` **silently ignores selections** (binding never
|
||||
updates, rows still show checkmarks) unless real FamilyControls
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
|
||||
remoteInfo = OpenAppLock;
|
||||
};
|
||||
20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
|
||||
remoteInfo = OpenAppLock;
|
||||
};
|
||||
E20000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||
@@ -34,22 +48,24 @@
|
||||
remoteGlobalIDString = C10000000000000000000003;
|
||||
remoteInfo = OpenAppLockShieldAction;
|
||||
};
|
||||
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
|
||||
remoteInfo = OpenAppLock;
|
||||
};
|
||||
20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
|
||||
remoteInfo = OpenAppLock;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E10000000000000000000001 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
|
||||
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
|
||||
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -59,6 +75,30 @@
|
||||
B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000001 /* OpenAppLockMonitor */;
|
||||
};
|
||||
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
|
||||
};
|
||||
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
@@ -106,46 +146,6 @@
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000001 /* OpenAppLockMonitor */;
|
||||
};
|
||||
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
|
||||
};
|
||||
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E10000000000000000000001 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
|
||||
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
|
||||
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
@@ -308,8 +308,8 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA00000000000000000000A2 /* OpenAppLockMonitor */,
|
||||
AA00000000000000000000A1 /* Shared */,
|
||||
AA00000000000000000000A2 /* OpenAppLockMonitor */,
|
||||
);
|
||||
name = OpenAppLockMonitor;
|
||||
packageProductDependencies = (
|
||||
@@ -331,8 +331,8 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
|
||||
AA00000000000000000000A1 /* Shared */,
|
||||
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
|
||||
);
|
||||
name = OpenAppLockShieldConfig;
|
||||
packageProductDependencies = (
|
||||
@@ -354,8 +354,8 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
|
||||
AA00000000000000000000A1 /* Shared */,
|
||||
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
|
||||
);
|
||||
name = OpenAppLockShieldAction;
|
||||
packageProductDependencies = (
|
||||
|
||||
@@ -82,6 +82,22 @@ extension BlockingRule {
|
||||
return .dormant
|
||||
}
|
||||
|
||||
/// User-facing status label, kind-aware. Limit rules apply all day and have
|
||||
/// no clock window, so while they are not blocking they show their daily
|
||||
/// budget ("15m / day") instead of `.upcoming`'s vestigial start countdown.
|
||||
/// Schedule rules, and any rule that is actually blocking/paused/dormant,
|
||||
/// use the plain status label.
|
||||
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
|
||||
if case .upcoming = status {
|
||||
switch configuration {
|
||||
case .schedule: break
|
||||
case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day"
|
||||
case .openLimit(let config): return "\(config.maxOpens) opens / day"
|
||||
}
|
||||
}
|
||||
return status.label(relativeTo: now)
|
||||
}
|
||||
|
||||
/// Whether the rule's enabled days include the day containing `now`.
|
||||
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
||||
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
||||
@@ -93,10 +109,10 @@ extension BlockingRule {
|
||||
/// Whether the given usage exhausts this rule's daily budget.
|
||||
/// Always false for schedule rules — they block by the clock.
|
||||
func limitReached(given usage: RuleUsage) -> Bool {
|
||||
switch kind {
|
||||
switch configuration {
|
||||
case .schedule: false
|
||||
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
|
||||
case .openLimit: usage.opensUsed >= maxOpens
|
||||
case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes
|
||||
case .openLimit(let config): usage.opensUsed >= config.maxOpens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import Foundation
|
||||
enum UsageDisplay {
|
||||
/// "18m of 45m used today" / "2 of 5 opens today".
|
||||
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
|
||||
switch rule.kind {
|
||||
switch rule.configuration {
|
||||
case .schedule:
|
||||
""
|
||||
case .timeLimit:
|
||||
"\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of "
|
||||
+ "\(rule.dailyLimitMinutes)m used today"
|
||||
case .openLimit:
|
||||
"\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today"
|
||||
case .timeLimit(let config):
|
||||
"\(min(usage.minutesUsed, config.dailyLimitMinutes))m of "
|
||||
+ "\(config.dailyLimitMinutes)m used today"
|
||||
case .openLimit(let config):
|
||||
"\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ enum UsageDisplay {
|
||||
guard !rule.limitReached(given: usage) else {
|
||||
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
|
||||
}
|
||||
switch rule.kind {
|
||||
switch rule.configuration {
|
||||
case .schedule:
|
||||
return ""
|
||||
case .timeLimit:
|
||||
return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left"
|
||||
case .openLimit:
|
||||
let remaining = rule.maxOpens - usage.opensUsed
|
||||
case .timeLimit(let config):
|
||||
return "\(config.dailyLimitMinutes - usage.minutesUsed)m left"
|
||||
case .openLimit(let config):
|
||||
let remaining = config.maxOpens - usage.opensUsed
|
||||
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import SwiftData
|
||||
|
||||
/// A recurring screen-time blocking rule.
|
||||
///
|
||||
/// The kind-specific options live in a `RuleConfiguration` sum type, exposed
|
||||
/// through the computed `configuration` bridge below. The raw per-kind columns
|
||||
/// (`startMinutes`, `selectionModeRaw`, `dailyLimitMinutes`, …) are persistence
|
||||
/// detail: they are read and written *only* via `configuration`, which keeps a
|
||||
/// rule from ever carrying another kind's options (e.g. a Time Limit rule can
|
||||
/// never hold Block Adult Content — see `applyConfiguration`).
|
||||
///
|
||||
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
|
||||
/// is independent of time zones at creation. A window whose end is at or before
|
||||
/// its start (e.g. 22:00 → 06:00) crosses midnight into the following day.
|
||||
@@ -19,10 +26,6 @@ final class BlockingRule {
|
||||
var isEnabled: Bool
|
||||
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
||||
var hardMode: Bool
|
||||
/// Engage Screen Time's adult-website filter while this rule is blocking.
|
||||
/// Inline default so existing stores migrate cleanly.
|
||||
var blockAdultContent: Bool = false
|
||||
var selectionModeRaw: String
|
||||
/// The reusable app list this rule blocks (or allows, in Allow Only mode).
|
||||
///
|
||||
/// Deliberately not an `init` parameter: SwiftData relationship properties
|
||||
@@ -36,51 +39,99 @@ final class BlockingRule {
|
||||
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
|
||||
var selectionCount: Int
|
||||
var dayNumbers: [Int]
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
/// Daily usage budget for `.timeLimit` rules.
|
||||
var dailyLimitMinutes: Int
|
||||
/// Daily open budget for `.openLimit` rules.
|
||||
var maxOpens: Int
|
||||
/// When set, the rule's current window is suspended (user tapped Unblock).
|
||||
/// Cleared automatically once the date passes; never set while Hard Mode is active.
|
||||
var pausedUntil: Date?
|
||||
var createdAt: Date
|
||||
|
||||
// MARK: Raw per-kind storage (access via `configuration`)
|
||||
|
||||
/// Schedule-window bounds, minutes from midnight. Meaningful for `.schedule`.
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
/// Schedule-only; forced to `.block` for limit kinds by `applyConfiguration`.
|
||||
var selectionModeRaw: String
|
||||
/// Schedule-only; forced to `false` for limit kinds by `applyConfiguration`.
|
||||
var blockAdultContent: Bool
|
||||
/// Daily usage budget for `.timeLimit` rules.
|
||||
var dailyLimitMinutes: Int
|
||||
/// Daily open budget for `.openLimit` rules.
|
||||
var maxOpens: Int
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
kind: RuleKind = .schedule,
|
||||
configuration: RuleConfiguration = .schedule(ScheduleConfig()),
|
||||
isEnabled: Bool = true,
|
||||
hardMode: Bool = false,
|
||||
blockAdultContent: Bool = false,
|
||||
selectionMode: SelectionMode = .block,
|
||||
selectionData: Data? = nil,
|
||||
selectionCount: Int = 0,
|
||||
days: Set<Weekday> = Weekday.weekdays,
|
||||
startMinutes: Int = 9 * 60,
|
||||
endMinutes: Int = 17 * 60,
|
||||
dailyLimitMinutes: Int = 45,
|
||||
maxOpens: Int = 5,
|
||||
pausedUntil: Date? = nil,
|
||||
createdAt: Date = .now
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kindRaw = kind.rawValue
|
||||
self.isEnabled = isEnabled
|
||||
self.hardMode = hardMode
|
||||
self.blockAdultContent = blockAdultContent
|
||||
self.selectionModeRaw = selectionMode.rawValue
|
||||
self.selectionData = selectionData
|
||||
self.selectionCount = selectionCount
|
||||
self.appList = nil
|
||||
self.selectionData = nil
|
||||
self.selectionCount = 0
|
||||
self.dayNumbers = days.map(\.rawValue).sorted()
|
||||
self.startMinutes = startMinutes
|
||||
self.endMinutes = endMinutes
|
||||
self.dailyLimitMinutes = dailyLimitMinutes
|
||||
self.maxOpens = maxOpens
|
||||
self.pausedUntil = pausedUntil
|
||||
self.createdAt = createdAt
|
||||
// Raw per-kind columns start at the reference defaults, then the
|
||||
// configuration overwrites the ones that apply to its kind.
|
||||
self.kindRaw = configuration.kind.rawValue
|
||||
self.startMinutes = 9 * 60
|
||||
self.endMinutes = 17 * 60
|
||||
self.selectionModeRaw = SelectionMode.block.rawValue
|
||||
self.blockAdultContent = false
|
||||
self.dailyLimitMinutes = 45
|
||||
self.maxOpens = 5
|
||||
applyConfiguration(configuration)
|
||||
}
|
||||
|
||||
/// The kind-specific options of this rule. Reading assembles the sum type
|
||||
/// from the raw columns; writing routes through `applyConfiguration`, which
|
||||
/// keeps the schedule-only options off limit rules.
|
||||
var configuration: RuleConfiguration {
|
||||
get {
|
||||
switch kind {
|
||||
case .schedule:
|
||||
.schedule(
|
||||
ScheduleConfig(
|
||||
startMinutes: startMinutes,
|
||||
endMinutes: endMinutes,
|
||||
selectionMode: selectionMode,
|
||||
blockAdultContent: blockAdultContent))
|
||||
case .timeLimit:
|
||||
.timeLimit(TimeLimitConfig(dailyLimitMinutes: dailyLimitMinutes))
|
||||
case .openLimit:
|
||||
.openLimit(OpenLimitConfig(maxOpens: maxOpens))
|
||||
}
|
||||
}
|
||||
set { applyConfiguration(newValue) }
|
||||
}
|
||||
|
||||
/// Writes a configuration onto the raw columns. Limit kinds explicitly
|
||||
/// reset the Schedule-only options so a rule can never carry Block / Allow
|
||||
/// Only or Block Adult Content unless it is a Schedule rule.
|
||||
private func applyConfiguration(_ configuration: RuleConfiguration) {
|
||||
kindRaw = configuration.kind.rawValue
|
||||
switch configuration {
|
||||
case .schedule(let config):
|
||||
startMinutes = config.startMinutes
|
||||
endMinutes = config.endMinutes
|
||||
selectionModeRaw = config.selectionMode.rawValue
|
||||
blockAdultContent = config.blockAdultContent
|
||||
case .timeLimit(let config):
|
||||
dailyLimitMinutes = config.dailyLimitMinutes
|
||||
selectionModeRaw = SelectionMode.block.rawValue
|
||||
blockAdultContent = false
|
||||
case .openLimit(let config):
|
||||
maxOpens = config.maxOpens
|
||||
selectionModeRaw = SelectionMode.block.rawValue
|
||||
blockAdultContent = false
|
||||
}
|
||||
}
|
||||
|
||||
var kind: RuleKind {
|
||||
@@ -88,9 +139,9 @@ final class BlockingRule {
|
||||
set { kindRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// How this rule interprets its app list. Always `.block` for limit kinds.
|
||||
var selectionMode: SelectionMode {
|
||||
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
||||
set { selectionModeRaw = newValue.rawValue }
|
||||
SelectionMode(rawValue: selectionModeRaw) ?? .block
|
||||
}
|
||||
|
||||
var days: Set<Weekday> {
|
||||
|
||||
@@ -9,58 +9,46 @@ import SwiftData
|
||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
||||
/// edit never touches the persisted model. Hashable so it can drive
|
||||
/// `navigationDestination(item:)`.
|
||||
///
|
||||
/// The kind-specific options live in `configuration`, so each editor branch
|
||||
/// only ever sees the options that belong to its kind (the Schedule editor
|
||||
/// gets Block/Allow-Only and Block Adult Content; the limit editors do not).
|
||||
struct RuleDraft: Hashable {
|
||||
var name: String
|
||||
var kind: RuleKind
|
||||
var days: Set<Weekday>
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var dailyLimitMinutes: Int
|
||||
var maxOpens: Int
|
||||
var hardMode: Bool
|
||||
var blockAdultContent: Bool
|
||||
var selectionMode: SelectionMode
|
||||
/// Reference to the persisted list the rule will use. App lists are
|
||||
/// managed (created/edited) directly by the picker, so the draft only
|
||||
/// carries the pointer.
|
||||
var appList: AppList?
|
||||
var configuration: RuleConfiguration
|
||||
|
||||
var kind: RuleKind { configuration.kind }
|
||||
|
||||
/// A fresh draft for a new rule of the given kind, using the reference
|
||||
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||
init(kind: RuleKind) {
|
||||
self.name = kind.defaultRuleName
|
||||
self.kind = kind
|
||||
self.days = Weekday.weekdays
|
||||
self.startMinutes = 9 * 60
|
||||
self.endMinutes = 17 * 60
|
||||
self.dailyLimitMinutes = 45
|
||||
self.maxOpens = 5
|
||||
self.hardMode = false
|
||||
self.blockAdultContent = false
|
||||
self.selectionMode = .block
|
||||
self.appList = nil
|
||||
self.configuration = .default(for: kind)
|
||||
}
|
||||
|
||||
init(rule: BlockingRule) {
|
||||
self.name = rule.name
|
||||
self.kind = rule.kind
|
||||
self.days = rule.days
|
||||
self.startMinutes = rule.startMinutes
|
||||
self.endMinutes = rule.endMinutes
|
||||
self.dailyLimitMinutes = rule.dailyLimitMinutes
|
||||
self.maxOpens = rule.maxOpens
|
||||
self.hardMode = rule.hardMode
|
||||
self.blockAdultContent = rule.blockAdultContent
|
||||
self.selectionMode = rule.selectionMode
|
||||
self.appList = rule.appList
|
||||
self.configuration = rule.configuration
|
||||
}
|
||||
|
||||
init(preset: RulePreset) {
|
||||
self.init(kind: .schedule)
|
||||
self.name = preset.name
|
||||
self.days = preset.days
|
||||
self.startMinutes = preset.startMinutes
|
||||
self.endMinutes = preset.endMinutes
|
||||
self.configuration = .schedule(
|
||||
ScheduleConfig(startMinutes: preset.startMinutes, endMinutes: preset.endMinutes))
|
||||
}
|
||||
|
||||
/// Writes the draft back onto a rule. The rule (and the chosen list) must
|
||||
@@ -68,15 +56,9 @@ struct RuleDraft: Hashable {
|
||||
/// assigned between managed models (see `BlockingRule.appList`).
|
||||
func apply(to rule: BlockingRule) {
|
||||
rule.name = name
|
||||
rule.kind = kind
|
||||
rule.days = days
|
||||
rule.startMinutes = startMinutes
|
||||
rule.endMinutes = endMinutes
|
||||
rule.dailyLimitMinutes = dailyLimitMinutes
|
||||
rule.maxOpens = maxOpens
|
||||
rule.hardMode = hardMode
|
||||
rule.blockAdultContent = blockAdultContent
|
||||
rule.selectionMode = selectionMode
|
||||
rule.configuration = configuration
|
||||
if rule.appList !== appList {
|
||||
rule.appList = appList
|
||||
}
|
||||
@@ -87,26 +69,47 @@ struct RuleDraft: Hashable {
|
||||
/// ever written on a managed model.
|
||||
@discardableResult
|
||||
func insertRule(into context: ModelContext) -> BlockingRule {
|
||||
let rule = BlockingRule(name: name, kind: kind)
|
||||
let rule = BlockingRule(name: name, configuration: configuration)
|
||||
context.insert(rule)
|
||||
apply(to: rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
/// Trims the name (falling back to the kind's default when it is empty)
|
||||
/// and forces Block mode for time- and open-limit rules — Allow Only is a
|
||||
/// schedule-rule concept.
|
||||
/// Trims the name, falling back to the kind's default when it is empty.
|
||||
/// (Block / Allow Only no longer needs sanitizing: the sum type makes it
|
||||
/// impossible for a limit draft to carry a selection mode at all.)
|
||||
func sanitized() -> RuleDraft {
|
||||
var copy = self
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
||||
if kind != .schedule {
|
||||
copy.selectionMode = .block
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
RuleSchedule(
|
||||
startMinutes: scheduleConfig.startMinutes,
|
||||
endMinutes: scheduleConfig.endMinutes,
|
||||
days: days)
|
||||
}
|
||||
}
|
||||
|
||||
extension RuleDraft {
|
||||
/// Typed projections of the active configuration, used to bind editor
|
||||
/// controls. The getter returns kind defaults when the draft is a
|
||||
/// different kind; each editor only reads the projection for its own kind,
|
||||
/// and writing through it repackages the configuration to that kind.
|
||||
var scheduleConfig: ScheduleConfig {
|
||||
get { configuration.scheduleConfig ?? ScheduleConfig() }
|
||||
set { configuration = .schedule(newValue) }
|
||||
}
|
||||
|
||||
var timeLimitConfig: TimeLimitConfig {
|
||||
get { configuration.timeLimitConfig ?? TimeLimitConfig() }
|
||||
set { configuration = .timeLimit(newValue) }
|
||||
}
|
||||
|
||||
var openLimitConfig: OpenLimitConfig {
|
||||
get { configuration.openLimitConfig ?? OpenLimitConfig() }
|
||||
set { configuration = .openLimit(newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,12 @@ struct OpenAppLockApp: App {
|
||||
let scheduler =
|
||||
config.isUITesting
|
||||
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
|
||||
let openSessions: OpenSessionReading =
|
||||
config.isUITesting ? MockOpenSessionStore() : OpenSessionStore()
|
||||
_enforcer = State(
|
||||
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler))
|
||||
initialValue: RuleEnforcer(
|
||||
shields: shields, usage: usageLedger, scheduler: scheduler,
|
||||
openSessions: openSessions))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -24,14 +24,19 @@ final class RuleEnforcer {
|
||||
/// Day-usage source consulted for limit rules; also exposed to views for
|
||||
/// the Usage section.
|
||||
let usageReader: UsageReading
|
||||
/// Granted-open sessions, so a proactively-gated open-limit rule is left
|
||||
/// un-shielded while the user is inside a session they paid an open for.
|
||||
private let openSessions: OpenSessionReading
|
||||
|
||||
init(
|
||||
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
|
||||
scheduler: RuleScheduler? = nil
|
||||
scheduler: RuleScheduler? = nil,
|
||||
openSessions: OpenSessionReading = OpenSessionStore()
|
||||
) {
|
||||
self.shields = shields
|
||||
self.usageReader = usage
|
||||
self.scheduler = scheduler
|
||||
self.openSessions = openSessions
|
||||
}
|
||||
|
||||
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
||||
@@ -44,25 +49,55 @@ final class RuleEnforcer {
|
||||
|
||||
/// Recomputes shields from scratch. Call on launch, on any rule change,
|
||||
/// and periodically while the app is visible. Also expires stale pauses.
|
||||
///
|
||||
/// Each rule shields its *own* `ManagedSettingsStore`, and Screen Time
|
||||
/// unions shields across stores, so overlapping rules enforce strictly:
|
||||
/// an app is blocked if *any* covering rule blocks it (see spec §4.8). A
|
||||
/// rule is shielded when it is actively blocking (a schedule window is
|
||||
/// open, or a limit budget is spent) *or* when it is an open-limit rule
|
||||
/// that must gate its apps so opens can be counted.
|
||||
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
|
||||
var active: Set<UUID> = []
|
||||
var blocking: Set<UUID> = []
|
||||
var shielded: Set<UUID> = []
|
||||
for rule in rules {
|
||||
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
|
||||
rule.pausedUntil = nil
|
||||
}
|
||||
let usage = usage(for: rule, at: now, calendar: calendar)
|
||||
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
|
||||
active.insert(rule.id)
|
||||
let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
|
||||
if isBlocking { blocking.insert(rule.id) }
|
||||
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
|
||||
continue
|
||||
}
|
||||
shielded.insert(rule.id)
|
||||
shields.applyShield(
|
||||
ruleID: rule.id,
|
||||
selectionData: rule.appList?.selectionData,
|
||||
// Allow Only is a schedule-rule concept; limit blocks always Block.
|
||||
mode: rule.kind == .schedule ? rule.selectionMode : .block,
|
||||
// Allow Only and Block Adult Content are Schedule-only options;
|
||||
// the model already forces .block / false on limit rules, so we
|
||||
// can forward the rule's values directly.
|
||||
mode: rule.selectionMode,
|
||||
blockAdultContent: rule.blockAdultContent
|
||||
)
|
||||
}
|
||||
shields.clearShields(except: active)
|
||||
blockingRuleIDs = active
|
||||
shields.clearShields(except: shielded)
|
||||
// "Blocked Apps" lists only rules whose budget/window is spent — not the
|
||||
// proactive open-limit gate, which surfaces under "Usage" instead.
|
||||
blockingRuleIDs = blocking
|
||||
scheduler?.sync(rules: rules, at: now)
|
||||
}
|
||||
|
||||
/// Whether an open-limit rule should carry its proactive gate right now:
|
||||
/// enabled, scheduled today, not unblocked, and not inside a granted open
|
||||
/// session (which would otherwise be cut short). Mirrors
|
||||
/// `LimitEnforcement.handleDayStart` so the foreground and background agree.
|
||||
private func shouldGateOpenLimit(
|
||||
_ rule: BlockingRule, at now: Date, calendar: Calendar
|
||||
) -> Bool {
|
||||
rule.kind == .openLimit
|
||||
&& rule.isEnabled
|
||||
&& rule.pausedUntil == nil
|
||||
&& rule.isScheduledToday(at: now, calendar: calendar)
|
||||
&& !openSessions.hasActiveSession(for: rule.id, at: now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ protocol ActivityMonitoring: AnyObject {
|
||||
func startDailyMonitoring(
|
||||
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
||||
) throws
|
||||
/// Starts (or replaces) a repeating window activity spanning
|
||||
/// `intervalStartMinutes`…`intervalEndMinutes` (minutes from midnight),
|
||||
/// carrying no events — used to wake the monitor at a schedule rule's
|
||||
/// window edges so its shield engages in the background.
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws
|
||||
func stopMonitoring(names: [String])
|
||||
var monitoredNames: [String] { get }
|
||||
}
|
||||
@@ -47,32 +54,50 @@ final class RuleScheduler {
|
||||
var fingerprints = storedFingerprints
|
||||
var desiredNames: Set<String> = []
|
||||
|
||||
for rule in rules where rule.kind != .schedule {
|
||||
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue }
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
desiredNames.insert(name)
|
||||
|
||||
let events =
|
||||
rule.kind == .timeLimit
|
||||
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
|
||||
: [:]
|
||||
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
|
||||
+ "\(selectionData.hashValue)"
|
||||
guard fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
|
||||
for rule in rules {
|
||||
// A rule must be enabled, have days, and have apps to be monitored.
|
||||
guard rule.isEnabled, !rule.days.isEmpty,
|
||||
let selectionData = rule.appList?.selectionData
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try monitor.startDailyMonitoring(
|
||||
name: name, selectionData: selectionData, eventMinutes: events)
|
||||
fingerprints[name] = fingerprint
|
||||
} catch {
|
||||
// Monitoring is best-effort here; the next sync retries.
|
||||
// (Throws on the simulator and when authorization is missing.)
|
||||
switch rule.kind {
|
||||
case .timeLimit, .openLimit:
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
desiredNames.insert(name)
|
||||
let events =
|
||||
rule.kind == .timeLimit
|
||||
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
|
||||
: [:]
|
||||
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
|
||||
+ "\(selectionData.hashValue)"
|
||||
guard needsRestart(name, fingerprint, in: fingerprints) else { continue }
|
||||
start(name: name) {
|
||||
try monitor.startDailyMonitoring(
|
||||
name: name, selectionData: selectionData, eventMinutes: events)
|
||||
} onStarted: { fingerprints[name] = fingerprint }
|
||||
|
||||
case .schedule:
|
||||
// A window activity encodes only its interval (no events, no
|
||||
// selection); days, mode and apps are read fresh by reconcile()
|
||||
// at each callback, so only a start/end change needs a restart.
|
||||
let fingerprint = "schedule|\(rule.startMinutes)|\(rule.endMinutes)"
|
||||
for window in scheduleWindows(for: rule) {
|
||||
desiredNames.insert(window.name)
|
||||
guard needsRestart(window.name, fingerprint, in: fingerprints) else { continue }
|
||||
start(name: window.name) {
|
||||
try monitor.startWindowMonitoring(
|
||||
name: window.name,
|
||||
intervalStartMinutes: window.start,
|
||||
intervalEndMinutes: window.end)
|
||||
} onStarted: { fingerprints[window.name] = fingerprint }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stale = monitor.monitoredNames.filter {
|
||||
MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil && !desiredNames.contains($0)
|
||||
(MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil
|
||||
|| MonitoringPlan.ruleID(fromScheduleWindowName: $0) != nil)
|
||||
&& !desiredNames.contains($0)
|
||||
}
|
||||
if !stale.isEmpty {
|
||||
monitor.stopMonitoring(names: stale)
|
||||
@@ -83,6 +108,50 @@ final class RuleScheduler {
|
||||
storedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
/// Whether `name` should be (re)started: its configuration changed, or the
|
||||
/// system isn't actually monitoring it (e.g. a prior start threw).
|
||||
private func needsRestart(
|
||||
_ name: String, _ fingerprint: String, in fingerprints: [String: String]
|
||||
) -> Bool {
|
||||
fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
|
||||
}
|
||||
|
||||
/// Runs a best-effort `startMonitoring` call. Monitoring throws on the
|
||||
/// simulator, when authorization is missing, and when the activity cap or
|
||||
/// minimum interval is exceeded; the next sync retries.
|
||||
private func start(name: String, _ body: () throws -> Void, onStarted: () -> Void) {
|
||||
do {
|
||||
try body()
|
||||
onStarted()
|
||||
} catch {
|
||||
// Best-effort; the foreground reconciliation loop is the safety net.
|
||||
}
|
||||
}
|
||||
|
||||
/// The DeviceActivity window activities for a schedule rule. Normal windows
|
||||
/// map to one activity; midnight-crossing windows split into an evening half
|
||||
/// (to 23:59) and a morning half (from 00:00); a `start == end` window is
|
||||
/// treated as all-day.
|
||||
private func scheduleWindows(for rule: BlockingRule) -> [(name: String, start: Int, end: Int)] {
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
let endOfDay = 24 * 60 - 1
|
||||
let start = rule.startMinutes
|
||||
let end = rule.endMinutes
|
||||
|
||||
if start < end {
|
||||
return [(name: primary, start: start, end: end)]
|
||||
}
|
||||
if start == end {
|
||||
return [(name: primary, start: 0, end: endOfDay)]
|
||||
}
|
||||
var windows = [(name: primary, start: start, end: endOfDay)]
|
||||
if end > 0 {
|
||||
windows.append((name: late, start: 0, end: end))
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
private var storedFingerprints: [String: String] {
|
||||
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
|
||||
set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
|
||||
@@ -101,6 +170,8 @@ extension RuleSnapshot {
|
||||
selectionModeRaw: rule.selectionModeRaw,
|
||||
selectionData: rule.appList?.selectionData,
|
||||
dayNumbers: rule.dayNumbers,
|
||||
startMinutes: rule.startMinutes,
|
||||
endMinutes: rule.endMinutes,
|
||||
dailyLimitMinutes: rule.dailyLimitMinutes,
|
||||
maxOpens: rule.maxOpens,
|
||||
pausedUntil: rule.pausedUntil
|
||||
@@ -142,6 +213,19 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
||||
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
|
||||
}
|
||||
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws {
|
||||
let schedule = DeviceActivitySchedule(
|
||||
intervalStart: DateComponents(
|
||||
hour: intervalStartMinutes / 60, minute: intervalStartMinutes % 60),
|
||||
intervalEnd: DateComponents(
|
||||
hour: intervalEndMinutes / 60, minute: intervalEndMinutes % 60),
|
||||
repeats: true
|
||||
)
|
||||
try center.startMonitoring(DeviceActivityName(name), during: schedule)
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
center.stopMonitoring(names.map { DeviceActivityName($0) })
|
||||
}
|
||||
@@ -150,6 +234,7 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
||||
/// Records scheduling calls for tests.
|
||||
final class MockActivityMonitor: ActivityMonitoring {
|
||||
private(set) var startedEvents: [String: [String: Int]] = [:]
|
||||
private(set) var startedWindows: [String: (start: Int, end: Int)] = [:]
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var monitoredNames: [String] = []
|
||||
|
||||
@@ -163,10 +248,21 @@ final class MockActivityMonitor: ActivityMonitoring {
|
||||
}
|
||||
}
|
||||
|
||||
func startWindowMonitoring(
|
||||
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
|
||||
) throws {
|
||||
startCallCount += 1
|
||||
startedWindows[name] = (intervalStartMinutes, intervalEndMinutes)
|
||||
if !monitoredNames.contains(name) {
|
||||
monitoredNames.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
monitoredNames.removeAll(where: names.contains)
|
||||
for name in names {
|
||||
startedEvents[name] = nil
|
||||
startedWindows[name] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,17 @@ enum SampleRules {
|
||||
]
|
||||
case .limits:
|
||||
let timeKeeper = BlockingRule(
|
||||
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
||||
dailyLimitMinutes: 45)
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||
days: Weekday.everyDay)
|
||||
let gateKeeper = BlockingRule(
|
||||
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
let doomScroll = BlockingRule(
|
||||
name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay,
|
||||
dailyLimitMinutes: 30)
|
||||
name: "Doom Scroll",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)),
|
||||
days: Weekday.everyDay)
|
||||
usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18)
|
||||
usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2)
|
||||
usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30)
|
||||
@@ -65,10 +69,9 @@ enum SampleRules {
|
||||
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
|
||||
return BlockingRule(
|
||||
name: name,
|
||||
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
|
||||
hardMode: hardMode,
|
||||
days: Weekday.everyDay,
|
||||
startMinutes: start,
|
||||
endMinutes: end
|
||||
days: Weekday.everyDay
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,9 +85,8 @@ enum SampleRules {
|
||||
let end = (start + 8 * 60) % (24 * 60)
|
||||
return BlockingRule(
|
||||
name: name,
|
||||
days: Weekday.everyDay,
|
||||
startMinutes: start,
|
||||
endMinutes: end
|
||||
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
|
||||
days: Weekday.everyDay
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -231,18 +231,9 @@ struct AppsHomeView: View {
|
||||
}
|
||||
|
||||
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
||||
switch rule.kind {
|
||||
case .schedule:
|
||||
return status.label(relativeTo: now)
|
||||
case .timeLimit:
|
||||
// While blocking (or paused/disabled) show the live state; the
|
||||
// budget is only informative while it is still being spent.
|
||||
if case .upcoming = status { return "\(rule.dailyLimitMinutes)m / day" }
|
||||
return status.label(relativeTo: now)
|
||||
case .openLimit:
|
||||
if case .upcoming = status { return "\(rule.maxOpens) opens / day" }
|
||||
return status.label(relativeTo: now)
|
||||
}
|
||||
// Shared with the detail sheet: limit rules that aren't blocking show
|
||||
// their daily budget; everything else uses the live status label.
|
||||
rule.statusLabel(for: status, relativeTo: now)
|
||||
}
|
||||
|
||||
// MARK: - Enforcement
|
||||
|
||||
@@ -89,7 +89,7 @@ struct RuleDetailSheet: View {
|
||||
Text(rule.name)
|
||||
.font(.headline)
|
||||
.accessibilityIdentifier("detailRuleName")
|
||||
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
|
||||
Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("detailStatusLabel")
|
||||
@@ -100,26 +100,25 @@ struct RuleDetailSheet: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var detailRows: some View {
|
||||
switch rule.kind {
|
||||
case .schedule:
|
||||
switch rule.configuration {
|
||||
case .schedule(let config):
|
||||
row("During this time", rule.schedule.timeRangeLabel)
|
||||
row("On these days", rule.days.summary)
|
||||
row(rule.selectionMode.displayName, appCountLabel)
|
||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||
row(config.selectionMode.displayName, appCountLabel)
|
||||
// Adult websites is a Schedule-only option (see spec §1).
|
||||
row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed")
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
case .timeLimit:
|
||||
case .timeLimit(let config):
|
||||
row("When I use", appCountLabel)
|
||||
row("For this long", "\(rule.dailyLimitMinutes)m daily")
|
||||
row("For this long", "\(config.dailyLimitMinutes)m daily")
|
||||
row("On these days", rule.days.summary)
|
||||
row("Then block until", "Tomorrow")
|
||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
case .openLimit:
|
||||
case .openLimit(let config):
|
||||
row("When I open", appCountLabel)
|
||||
row("More than", "\(rule.maxOpens) opens daily")
|
||||
row("More than", "\(config.maxOpens) opens daily")
|
||||
row("On these days", rule.days.summary)
|
||||
row("Then block until", "Tomorrow")
|
||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,13 @@ struct RuleEditorView: View {
|
||||
Section {
|
||||
DatePicker(
|
||||
"From",
|
||||
selection: timeBinding($draft.startMinutes),
|
||||
selection: timeBinding($draft.scheduleConfig.startMinutes),
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.accessibilityIdentifier("fromTimePicker")
|
||||
DatePicker(
|
||||
"To",
|
||||
selection: timeBinding($draft.endMinutes),
|
||||
selection: timeBinding($draft.scheduleConfig.endMinutes),
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.accessibilityIdentifier("toTimePicker")
|
||||
@@ -112,7 +112,7 @@ struct RuleEditorView: View {
|
||||
}
|
||||
daysSection
|
||||
Section {
|
||||
Picker("Mode", selection: $draft.selectionMode) {
|
||||
Picker("Mode", selection: $draft.scheduleConfig.selectionMode) {
|
||||
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
@@ -121,10 +121,14 @@ struct RuleEditorView: View {
|
||||
.accessibilityIdentifier("selectionModePicker")
|
||||
appListRow
|
||||
} header: {
|
||||
Text(draft.selectionMode == .block ? "Apps are blocked" : "Only these apps are allowed")
|
||||
Text(draft.scheduleConfig.selectionMode == .block
|
||||
? "Apps are blocked" : "Only these apps are allowed")
|
||||
.textCase(nil)
|
||||
}
|
||||
toggleSections
|
||||
hardModeSection
|
||||
// Block Adult Content is a Schedule-only option: a usage budget
|
||||
// does not pair with a web-content filter (see spec §1).
|
||||
adultContentSection
|
||||
case .timeLimit:
|
||||
Section {
|
||||
appListRow
|
||||
@@ -133,17 +137,23 @@ struct RuleEditorView: View {
|
||||
}
|
||||
Section {
|
||||
budgetRow(
|
||||
value: "\(draft.dailyLimitMinutes)m",
|
||||
value: "\(draft.timeLimitConfig.dailyLimitMinutes)m",
|
||||
stepperID: "dailyLimitStepper",
|
||||
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
|
||||
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
|
||||
onIncrement: {
|
||||
draft.timeLimitConfig.dailyLimitMinutes =
|
||||
min(240, draft.timeLimitConfig.dailyLimitMinutes + 15)
|
||||
},
|
||||
onDecrement: {
|
||||
draft.timeLimitConfig.dailyLimitMinutes =
|
||||
max(15, draft.timeLimitConfig.dailyLimitMinutes - 15)
|
||||
}
|
||||
)
|
||||
} header: {
|
||||
Text("For this long").textCase(nil)
|
||||
}
|
||||
daysSection
|
||||
blockUntilSection
|
||||
toggleSections
|
||||
hardModeSection
|
||||
case .openLimit:
|
||||
Section {
|
||||
appListRow
|
||||
@@ -152,17 +162,21 @@ struct RuleEditorView: View {
|
||||
}
|
||||
Section {
|
||||
budgetRow(
|
||||
value: "\(draft.maxOpens) opens",
|
||||
value: "\(draft.openLimitConfig.maxOpens) opens",
|
||||
stepperID: "maxOpensStepper",
|
||||
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
|
||||
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
|
||||
onIncrement: {
|
||||
draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1)
|
||||
},
|
||||
onDecrement: {
|
||||
draft.openLimitConfig.maxOpens = max(1, draft.openLimitConfig.maxOpens - 1)
|
||||
}
|
||||
)
|
||||
} header: {
|
||||
Text("More than").textCase(nil)
|
||||
}
|
||||
daysSection
|
||||
blockUntilSection
|
||||
toggleSections
|
||||
hardModeSection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +200,8 @@ struct RuleEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toggleSections: some View {
|
||||
/// Hard Mode applies to every kind.
|
||||
private var hardModeSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Hard Mode")
|
||||
@@ -199,11 +213,15 @@ struct RuleEditorView: View {
|
||||
} footer: {
|
||||
Text("No unblocks allowed while the rule is blocking.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule-only: filter adult websites while the rule's window is active.
|
||||
private var adultContentSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Block Adult Content")
|
||||
Spacer()
|
||||
Toggle("", isOn: $draft.blockAdultContent)
|
||||
Toggle("", isOn: $draft.scheduleConfig.blockAdultContent)
|
||||
.labelsHidden()
|
||||
.accessibilityIdentifier("adultContentToggle")
|
||||
}
|
||||
|
||||
@@ -19,10 +19,21 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
|
||||
)
|
||||
}
|
||||
|
||||
private var scheduleEnforcement: ScheduleEnforcement {
|
||||
ScheduleEnforcement(
|
||||
snapshots: RuleSnapshotStore(),
|
||||
shields: ManagedSettingsShieldController()
|
||||
)
|
||||
}
|
||||
|
||||
override func intervalDidStart(for activity: DeviceActivityName) {
|
||||
super.intervalDidStart(for: activity)
|
||||
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
|
||||
enforcement.handleDayStart(ruleID: ruleID)
|
||||
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
|
||||
// A schedule window opened: shield it (the recompute honours days,
|
||||
// pause and the midnight-crossing rule).
|
||||
scheduleEnforcement.reconcile(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +42,11 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
|
||||
if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) {
|
||||
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
|
||||
// one clears.
|
||||
scheduleEnforcement.reconcile(ruleID: ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,21 @@ struct AppListModelTests {
|
||||
@MainActor
|
||||
@Suite("Legacy selection → AppList migration")
|
||||
struct AppListMigrationTests {
|
||||
/// Simulates a rule decoded from a pre-app-list store: a plain rule whose
|
||||
/// legacy inline-selection columns are populated.
|
||||
private func legacyRule(name: String, selectionData: Data, selectionCount: Int) -> BlockingRule {
|
||||
let rule = BlockingRule(name: name)
|
||||
rule.selectionData = selectionData
|
||||
rule.selectionCount = selectionCount
|
||||
return rule
|
||||
}
|
||||
|
||||
@Test("Rules with legacy inline selections get a list named after them")
|
||||
func createsListsFromLegacySelections() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3)
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
rule.selectionData = Data([1])
|
||||
rule.selectionCount = 3
|
||||
context.insert(rule)
|
||||
try context.save()
|
||||
|
||||
@@ -108,9 +119,9 @@ struct AppListMigrationTests {
|
||||
@Test("Rules with identical selections share one list")
|
||||
func sharesListForIdenticalSelections() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
||||
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
||||
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
||||
let first = legacyRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
||||
let second = legacyRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
||||
let different = legacyRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
||||
context.insert(first)
|
||||
context.insert(second)
|
||||
context.insert(different)
|
||||
@@ -127,7 +138,7 @@ struct AppListMigrationTests {
|
||||
@Test("Migration is idempotent and skips selection-less rules")
|
||||
func idempotentAndSkipsEmpty() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
|
||||
let legacy = legacyRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
|
||||
let empty = BlockingRule(name: "No Apps")
|
||||
context.insert(legacy)
|
||||
context.insert(empty)
|
||||
@@ -163,22 +174,24 @@ struct AppListDraftTests {
|
||||
#expect(other.name == "Other")
|
||||
}
|
||||
|
||||
@Test("Sanitizing forces Block mode for time- and open-limit rules")
|
||||
func sanitizedForcesBlockForLimitKinds() {
|
||||
var timeDraft = RuleDraft(kind: .timeLimit)
|
||||
timeDraft.selectionMode = .allowOnly
|
||||
#expect(timeDraft.sanitized().selectionMode == .block)
|
||||
@Test("Limit drafts structurally cannot carry a selection mode")
|
||||
func limitDraftsHaveNoSelectionMode() {
|
||||
// The sum type makes Block / Allow Only a Schedule-only option: a limit
|
||||
// draft's configuration has no selection mode to force back to Block.
|
||||
#expect(RuleDraft(kind: .timeLimit).configuration.scheduleConfig == nil)
|
||||
#expect(RuleDraft(kind: .openLimit).configuration.scheduleConfig == nil)
|
||||
|
||||
var openDraft = RuleDraft(kind: .openLimit)
|
||||
openDraft.selectionMode = .allowOnly
|
||||
#expect(openDraft.sanitized().selectionMode == .block)
|
||||
// And the rule built from a limit draft is always Block.
|
||||
let rule = BlockingRule(
|
||||
name: "Time Keeper", configuration: RuleDraft(kind: .timeLimit).configuration)
|
||||
#expect(rule.selectionMode == .block)
|
||||
}
|
||||
|
||||
@Test("Sanitizing keeps Allow Only on schedule rules")
|
||||
func sanitizedKeepsAllowOnlyForSchedule() {
|
||||
@Test("Allow Only survives on schedule rules")
|
||||
func keepsAllowOnlyForSchedule() {
|
||||
var draft = RuleDraft(kind: .schedule)
|
||||
draft.selectionMode = .allowOnly
|
||||
#expect(draft.sanitized().selectionMode == .allowOnly)
|
||||
draft.scheduleConfig.selectionMode = .allowOnly
|
||||
#expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// RuleConfigurationTests.swift
|
||||
// OpenAppLockTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import OpenAppLock
|
||||
|
||||
@MainActor
|
||||
@Suite("RuleConfiguration sum type")
|
||||
struct RuleConfigurationTests {
|
||||
@Test("Each case reports its kind")
|
||||
func kindDerivation() {
|
||||
#expect(RuleConfiguration.schedule(ScheduleConfig()).kind == .schedule)
|
||||
#expect(RuleConfiguration.timeLimit(TimeLimitConfig()).kind == .timeLimit)
|
||||
#expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit)
|
||||
}
|
||||
|
||||
@Test("Defaults match the reference app's new-rule defaults")
|
||||
func defaults() {
|
||||
let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig
|
||||
#expect(schedule?.startMinutes == 9 * 60)
|
||||
#expect(schedule?.endMinutes == 17 * 60)
|
||||
#expect(schedule?.selectionMode == .block)
|
||||
#expect(schedule?.blockAdultContent == false)
|
||||
|
||||
#expect(RuleConfiguration.default(for: .timeLimit).timeLimitConfig?.dailyLimitMinutes == 45)
|
||||
#expect(RuleConfiguration.default(for: .openLimit).openLimitConfig?.maxOpens == 5)
|
||||
}
|
||||
|
||||
@Test("Typed projections only unwrap the matching case")
|
||||
func projections() {
|
||||
let schedule = RuleConfiguration.schedule(ScheduleConfig(blockAdultContent: true))
|
||||
#expect(schedule.scheduleConfig?.blockAdultContent == true)
|
||||
#expect(schedule.timeLimitConfig == nil)
|
||||
#expect(schedule.openLimitConfig == nil)
|
||||
|
||||
let openLimit = RuleConfiguration.openLimit(OpenLimitConfig(maxOpens: 7))
|
||||
#expect(openLimit.openLimitConfig?.maxOpens == 7)
|
||||
#expect(openLimit.scheduleConfig == nil)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ struct RuleEnforcerTests {
|
||||
func skipsTimeLimitRules() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields)
|
||||
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
|
||||
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
@@ -104,18 +104,61 @@ struct RuleEnforcerTests {
|
||||
func forwardsSelectionMode() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields)
|
||||
let rule = BlockingRule(name: "Focus", selectionMode: .allowOnly)
|
||||
let rule = BlockingRule(
|
||||
name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.appliedModes[rule.id] == .allowOnly)
|
||||
}
|
||||
|
||||
@Test("Open-limit rules are proactively shielded while opens remain")
|
||||
func proactivelyShieldsOpenLimit() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
// 2 of 5 opens spent: budget not reached, so the rule is not "active",
|
||||
// but its apps must still be gated so the next open can be counted.
|
||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
#expect(shields.appliedModes[rule.id] == .block)
|
||||
// A proactive gate (opens remaining) is not "Blocked Apps"-blocking.
|
||||
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A granted open session is left un-shielded, not re-locked")
|
||||
func respectsGrantedOpenSession() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let sessions = MockOpenSessionStore()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||
// The user spent an open and is mid-session; re-shielding would cut the
|
||||
// sanctioned ~15-minute session short.
|
||||
sessions.activeRuleIDs = [rule.id]
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The adult-content flag is forwarded to the shield layer")
|
||||
func forwardsAdultContentFlag() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields)
|
||||
let filtered = BlockingRule(name: "Clean Mode", blockAdultContent: true)
|
||||
let filtered = BlockingRule(
|
||||
name: "Clean Mode", configuration: .schedule(ScheduleConfig(blockAdultContent: true)))
|
||||
let unfiltered = BlockingRule(name: "Plain")
|
||||
|
||||
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
|
||||
@@ -124,3 +167,105 @@ struct RuleEnforcerTests {
|
||||
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the "strictest enforcement wins" model for rules that target the
|
||||
/// same apps (spec §4.8). Each rule shields its own store and Screen Time
|
||||
/// unions them, so the unit-level invariant is: every rule that should block
|
||||
/// applies its own shield, and no rule's shield is suppressed by another.
|
||||
@MainActor
|
||||
@Suite("Overlapping rules → strictest enforcement")
|
||||
struct OverlappingRuleEnforcementTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
|
||||
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
||||
BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
||||
BlockingRule(
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
@Test("Every rule that should block applies its own shield")
|
||||
func eachRuleShieldsIndependently() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let schedule = BlockingRule(name: "Work Time") // 09:00–17:00, active now
|
||||
let timeLimit = timeLimitRule()
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // spent → blocking
|
||||
|
||||
enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// Neither cancels the other: both carry their own shield.
|
||||
#expect(shields.shieldedRuleIDs == [schedule.id, timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("The first limit to be spent blocks, whatever the other's budget")
|
||||
func firstSpentLimitBlocks() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let openLimit = openLimitRule(maxOpens: 5)
|
||||
let timeLimit = timeLimitRule(limit: 45)
|
||||
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1) // opens remain
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // budget spent
|
||||
|
||||
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// Time-limit blocks (spent) while the open-limit still gates its turnstile.
|
||||
#expect(shields.shieldedRuleIDs == [openLimit.id, timeLimit.id])
|
||||
// Only the spent budget counts as "Blocked Apps"; the gate shows in Usage.
|
||||
#expect(enforcer.blockingRuleIDs == [timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("A time limit blocks even during an open-limit's granted session")
|
||||
func timeLimitBlocksDuringGrantedOpen() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let sessions = MockOpenSessionStore()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
|
||||
let openLimit = openLimitRule(maxOpens: 5)
|
||||
let timeLimit = timeLimitRule(limit: 45)
|
||||
sessions.activeRuleIDs = [openLimit.id] // user is mid-open on the shared app
|
||||
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1)
|
||||
// The metered minutes during the open push the time limit over budget.
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45)
|
||||
|
||||
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// The open-limit stays lifted (its session is sanctioned), but the time
|
||||
// limit shields the app anyway — strictest wins.
|
||||
#expect(shields.shieldedRuleIDs == [timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("Spent opens reset the next day: re-gated, not blocked")
|
||||
func opensResetNextDay() {
|
||||
let suite = "overlap-tests-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let ledger = UsageLedger(defaults: defaults)
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = openLimitRule(maxOpens: 5)
|
||||
let yesterday = date(2025, 1, 5, 10, 0)
|
||||
let today = date(2025, 1, 6, 10, 0)
|
||||
for _ in 0..<5 {
|
||||
ledger.recordOpen(for: rule.id, onDayContaining: yesterday, calendar: utc)
|
||||
}
|
||||
|
||||
// Yesterday: budget exhausted → blocking.
|
||||
enforcer.refresh(rules: [rule], at: yesterday, calendar: utc)
|
||||
#expect(enforcer.blockingRuleIDs == [rule.id])
|
||||
|
||||
// Today: fresh budget → not blocking, but the turnstile is back up.
|
||||
enforcer.refresh(rules: [rule], at: today, calendar: utc)
|
||||
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ struct RuleModelTests {
|
||||
|
||||
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
|
||||
func enumRoundTrip() {
|
||||
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly)
|
||||
#expect(rule.kind == .openLimit)
|
||||
let rule = BlockingRule(
|
||||
name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
|
||||
#expect(rule.kind == .schedule)
|
||||
#expect(rule.selectionMode == .allowOnly)
|
||||
rule.kindRaw = "garbage"
|
||||
rule.selectionModeRaw = "garbage"
|
||||
@@ -45,12 +46,43 @@ struct RuleModelTests {
|
||||
#expect(rule.selectionMode == .block)
|
||||
}
|
||||
|
||||
@Test("Limit kinds can never carry Schedule-only options")
|
||||
func limitKindsHaveNoScheduleOptions() {
|
||||
let timeLimit = BlockingRule(
|
||||
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)))
|
||||
#expect(timeLimit.kind == .timeLimit)
|
||||
#expect(timeLimit.selectionMode == .block)
|
||||
#expect(!timeLimit.blockAdultContent)
|
||||
#expect(timeLimit.dailyLimitMinutes == 30)
|
||||
|
||||
let openLimit = BlockingRule(
|
||||
name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 3)))
|
||||
#expect(openLimit.kind == .openLimit)
|
||||
#expect(openLimit.selectionMode == .block)
|
||||
#expect(!openLimit.blockAdultContent)
|
||||
#expect(openLimit.maxOpens == 3)
|
||||
}
|
||||
|
||||
@Test("Configuration round-trips through the model's raw storage")
|
||||
func configurationRoundTrip() {
|
||||
let config = RuleConfiguration.schedule(
|
||||
ScheduleConfig(
|
||||
startMinutes: 22 * 60, endMinutes: 6 * 60,
|
||||
selectionMode: .allowOnly, blockAdultContent: true))
|
||||
let rule = BlockingRule(name: "Deep Sleep", configuration: config)
|
||||
#expect(rule.configuration == config)
|
||||
#expect(rule.startMinutes == 22 * 60)
|
||||
#expect(rule.blockAdultContent)
|
||||
}
|
||||
|
||||
@Test("Rules persist and fetch through SwiftData")
|
||||
func persistence() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(
|
||||
name: "Deep Sleep", hardMode: true,
|
||||
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
||||
name: "Deep Sleep",
|
||||
configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)),
|
||||
hardMode: true,
|
||||
days: Weekday.everyDay
|
||||
)
|
||||
context.insert(rule)
|
||||
try context.save()
|
||||
@@ -73,7 +105,7 @@ struct RuleDraftTests {
|
||||
let draft = RuleDraft(kind: .timeLimit)
|
||||
#expect(draft.name == "Time Keeper")
|
||||
#expect(draft.kind == .timeLimit)
|
||||
#expect(draft.dailyLimitMinutes == 45)
|
||||
#expect(draft.timeLimitConfig.dailyLimitMinutes == 45)
|
||||
#expect(draft.days == Weekday.weekdays)
|
||||
#expect(!draft.hardMode)
|
||||
}
|
||||
@@ -87,18 +119,32 @@ struct RuleDraftTests {
|
||||
var draft = RuleDraft(kind: .schedule)
|
||||
draft.name = "Locked In"
|
||||
draft.days = Weekday.everyDay
|
||||
draft.startMinutes = 22 * 60
|
||||
draft.endMinutes = 6 * 60
|
||||
draft.configuration = .schedule(
|
||||
ScheduleConfig(
|
||||
startMinutes: 22 * 60, endMinutes: 6 * 60,
|
||||
selectionMode: .allowOnly, blockAdultContent: true))
|
||||
draft.hardMode = true
|
||||
draft.blockAdultContent = true
|
||||
draft.selectionMode = .allowOnly
|
||||
draft.appList = list
|
||||
|
||||
let rule = draft.insertRule(into: context)
|
||||
#expect(rule.blockAdultContent)
|
||||
#expect(rule.selectionMode == .allowOnly)
|
||||
#expect(RuleDraft(rule: rule) == draft)
|
||||
}
|
||||
|
||||
@Test("A limit draft cannot carry Schedule-only options")
|
||||
func limitDraftHasNoScheduleOptions() {
|
||||
let draft = RuleDraft(kind: .openLimit)
|
||||
// The configuration is an open-limit case, so there is structurally no
|
||||
// selection mode or adult-content flag to set.
|
||||
#expect(draft.configuration.scheduleConfig == nil)
|
||||
#expect(draft.openLimitConfig.maxOpens == 5)
|
||||
|
||||
let rule = BlockingRule(name: draft.name, configuration: draft.configuration)
|
||||
#expect(!rule.blockAdultContent)
|
||||
#expect(rule.selectionMode == .block)
|
||||
}
|
||||
|
||||
@Test("Applying a draft updates an existing rule")
|
||||
func applyToExisting() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
@@ -135,8 +181,8 @@ struct RuleDraftTests {
|
||||
)
|
||||
let draft = RuleDraft(preset: preset)
|
||||
#expect(draft.name == "Deep Sleep")
|
||||
#expect(draft.startMinutes == 22 * 60)
|
||||
#expect(draft.endMinutes == 6 * 60)
|
||||
#expect(draft.scheduleConfig.startMinutes == 22 * 60)
|
||||
#expect(draft.scheduleConfig.endMinutes == 6 * 60)
|
||||
#expect(draft.kind == .schedule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ struct RuleStatusTests {
|
||||
|
||||
@Test("Time-limit rules are never schedule-active")
|
||||
func timeLimitNeverActive() {
|
||||
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
|
||||
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
|
||||
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||||
#expect(!status.isActive)
|
||||
if case .upcoming = status {} else {
|
||||
@@ -109,4 +109,45 @@ struct RuleStatusTests {
|
||||
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
|
||||
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
|
||||
}
|
||||
|
||||
// MARK: - Kind-aware display label
|
||||
|
||||
/// A non-blocking time-limit rule has no clock window, so it must show its
|
||||
/// daily budget — never the vestigial 09:00 start as "Starts in 22h".
|
||||
@Test("Idle time-limit rule shows its daily budget, not a clock countdown")
|
||||
func timeLimitDisplayLabel() {
|
||||
let rule = BlockingRule(
|
||||
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15)))
|
||||
let now = date(2025, 1, 6, 11, 38) // past the vestigial 09:00 window start
|
||||
let status = rule.status(at: now, calendar: utc)
|
||||
#expect(rule.statusLabel(for: status, relativeTo: now) == "15m / day")
|
||||
}
|
||||
|
||||
@Test("Idle open-limit rule shows its daily opens budget")
|
||||
func openLimitDisplayLabel() {
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 5)))
|
||||
let now = date(2025, 1, 6, 11, 38)
|
||||
let status = rule.status(at: now, calendar: utc)
|
||||
#expect(rule.statusLabel(for: status, relativeTo: now) == "5 opens / day")
|
||||
}
|
||||
|
||||
@Test("Schedule rule still shows the clock countdown")
|
||||
func scheduleDisplayLabelUnchanged() {
|
||||
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
|
||||
let friday = date(2025, 1, 10, 11, 28)
|
||||
let status = weekend.status(at: friday, calendar: utc)
|
||||
#expect(weekend.statusLabel(for: status, relativeTo: friday) == "Starts in 22h")
|
||||
}
|
||||
|
||||
@Test("A spent time-limit budget still shows the blocked countdown")
|
||||
func timeLimitBlockingDisplayLabel() {
|
||||
let rule = BlockingRule(
|
||||
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15)))
|
||||
let now = date(2025, 1, 6, 11, 38)
|
||||
let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15))
|
||||
#expect(status.isActive)
|
||||
// Stays on the live countdown ("Xh left"), not overridden to the budget.
|
||||
#expect(rule.statusLabel(for: status, relativeTo: now) == status.label(relativeTo: now))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,14 @@ struct RuleSnapshotTests {
|
||||
let snapshot = RuleSnapshot(
|
||||
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: [2, 3], dailyLimitMinutes: 45, maxOpens: 5,
|
||||
pausedUntil: nil
|
||||
selectionData: Data([1]), dayNumbers: [2, 3], startMinutes: 540, endMinutes: 1020,
|
||||
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: nil
|
||||
)
|
||||
store.save([snapshot])
|
||||
#expect(store.load() == [snapshot])
|
||||
#expect(store.snapshot(for: snapshot.id) == snapshot)
|
||||
#expect(store.snapshot(for: snapshot.id)?.startMinutes == 540)
|
||||
#expect(store.snapshot(for: snapshot.id)?.endMinutes == 1020)
|
||||
#expect(store.snapshot(for: UUID()) == nil)
|
||||
}
|
||||
|
||||
@@ -39,7 +41,9 @@ struct RuleSnapshotTests {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper", kind: .openLimit, days: Weekday.weekends, maxOpens: 3)
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 3)),
|
||||
days: Weekday.weekends)
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
@@ -50,6 +54,29 @@ struct RuleSnapshotTests {
|
||||
#expect(snapshot.selectionData == Data([9]))
|
||||
#expect(snapshot.days == Weekday.weekends)
|
||||
#expect(snapshot.maxOpens == 3)
|
||||
#expect(snapshot.startMinutes == rule.startMinutes)
|
||||
#expect(snapshot.endMinutes == rule.endMinutes)
|
||||
}
|
||||
|
||||
@Test("Snapshots saved before the window fields decode with a zeroed window")
|
||||
func decodesLegacySnapshotWithoutWindowFields() throws {
|
||||
// A blob shaped like a pre-schedule-window snapshot: no start/endMinutes.
|
||||
let id = UUID()
|
||||
let legacy = """
|
||||
[{"id":"\(id.uuidString)","name":"Old","kindRaw":"timeLimit","isEnabled":true,\
|
||||
"hardMode":false,"blockAdultContent":false,"selectionModeRaw":"block",\
|
||||
"dayNumbers":[2,3],"dailyLimitMinutes":45,"maxOpens":5}]
|
||||
"""
|
||||
let defaults = freshDefaults()
|
||||
defaults.set(Data(legacy.utf8), forKey: "ruleSnapshots")
|
||||
let store = RuleSnapshotStore(defaults: defaults)
|
||||
|
||||
let loaded = store.load()
|
||||
#expect(loaded.count == 1)
|
||||
#expect(loaded.first?.id == id)
|
||||
#expect(loaded.first?.startMinutes == 0)
|
||||
#expect(loaded.first?.endMinutes == 0)
|
||||
#expect(loaded.first?.dailyLimitMinutes == 45)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +98,22 @@ struct MonitoringPlanTests {
|
||||
fromSessionActivityName: MonitoringPlan.dailyActivityName(for: id)) == nil)
|
||||
}
|
||||
|
||||
@Test("Schedule-window activity names round-trip rule IDs")
|
||||
func scheduleWindowNameRoundTrip() {
|
||||
let id = UUID()
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: id)
|
||||
#expect(primary != late)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: primary) == id)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: late) == id)
|
||||
// Daily / session / garbage names are not schedule-window names.
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: MonitoringPlan.dailyActivityName(for: id)) == nil)
|
||||
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: "garbage") == nil)
|
||||
// Schedule-window names are not mistaken for daily activities.
|
||||
#expect(MonitoringPlan.ruleID(fromDailyActivityName: primary) == nil)
|
||||
#expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil)
|
||||
}
|
||||
|
||||
@Test("Minute checkpoints cover every minute up to the budget")
|
||||
func minuteEvents() {
|
||||
let events = MonitoringPlan.minuteEvents(forLimit: 45)
|
||||
@@ -94,13 +137,32 @@ struct RuleSchedulerTests {
|
||||
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1)
|
||||
let rule = BlockingRule(name: name, kind: kind, days: Weekday.everyDay)
|
||||
let rule = BlockingRule(
|
||||
name: name, configuration: .default(for: kind), days: Weekday.everyDay)
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
return rule
|
||||
}
|
||||
|
||||
private func scheduleRule(
|
||||
name: String, start: Int, end: Int, days: Set<Weekday> = Weekday.everyDay,
|
||||
withApps: Bool = true
|
||||
) throws -> BlockingRule {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(
|
||||
name: name,
|
||||
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
|
||||
days: days)
|
||||
context.insert(rule)
|
||||
if withApps {
|
||||
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
|
||||
context.insert(list)
|
||||
rule.appList = list
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
@Test("Enabled limit rules with apps start daily monitoring")
|
||||
func startsMonitoring() throws {
|
||||
let (scheduler, monitor, store) = makeScheduler()
|
||||
@@ -124,20 +186,153 @@ struct RuleSchedulerTests {
|
||||
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("Schedule rules and app-less limit rules are not monitored")
|
||||
@Test("App-less rules (schedule or limit) are not monitored")
|
||||
func skipsUnmonitorable() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let context = try makeInMemoryContext()
|
||||
let scheduleRule = BlockingRule(name: "Work Time")
|
||||
let appless = BlockingRule(name: "Empty", kind: .timeLimit)
|
||||
context.insert(scheduleRule)
|
||||
context.insert(appless)
|
||||
let applessSchedule = BlockingRule(name: "Work Time")
|
||||
let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig()))
|
||||
context.insert(applessSchedule)
|
||||
context.insert(applessLimit)
|
||||
|
||||
scheduler.sync(rules: [scheduleRule, appless])
|
||||
scheduler.sync(rules: [applessSchedule, applessLimit])
|
||||
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A non-crossing schedule rule registers one window activity")
|
||||
func schedulesNonCrossingWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 9 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 17 * 60)
|
||||
// A schedule window carries no usage-threshold events.
|
||||
#expect(monitor.startedEvents[primary] == nil)
|
||||
}
|
||||
|
||||
@Test("A dayless schedule rule is not monitored")
|
||||
func skipsDaylessSchedule() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "No Days", start: 9 * 60, end: 17 * 60, days: [])
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A midnight-crossing schedule rule registers two window activities")
|
||||
func schedulesCrossingWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(Set(monitor.monitoredNames) == [primary, late])
|
||||
// The evening half runs to the end of the day; the morning half from midnight.
|
||||
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
#expect(monitor.startedWindows[late]?.start == 0)
|
||||
#expect(monitor.startedWindows[late]?.end == 6 * 60)
|
||||
}
|
||||
|
||||
@Test("A window ending exactly at midnight registers a single evening activity")
|
||||
func scheduleWindowEndingAtMidnight() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Late", start: 22 * 60, end: 0)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
#expect(monitor.startedWindows[late] == nil)
|
||||
}
|
||||
|
||||
@Test("A 24-hour window registers a single all-day activity")
|
||||
func scheduleFullDayWindow() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Always", start: 0, end: 0)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[primary]?.start == 0)
|
||||
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
|
||||
}
|
||||
|
||||
@Test("Switching a window from crossing to non-crossing stops the morning half")
|
||||
func dropsLateActivityWhenWindowStopsCrossing() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
|
||||
#expect(Set(monitor.monitoredNames) == [primary, late])
|
||||
|
||||
// Now a normal daytime window — the post-midnight half must be stopped.
|
||||
rule.startMinutes = 9 * 60
|
||||
rule.endMinutes = 17 * 60
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.monitoredNames == [primary])
|
||||
#expect(monitor.startedWindows[late] == nil)
|
||||
}
|
||||
|
||||
@Test("Changing only the days does not restart the window activity")
|
||||
func keepsWindowWhenOnlyDaysChange() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(
|
||||
name: "Work Time", start: 9 * 60, end: 17 * 60, days: Weekday.weekdays)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
|
||||
// The window interval is unchanged, so the DeviceActivity activity that
|
||||
// only encodes start/end need not restart — reconcile() reads days fresh.
|
||||
rule.days = Weekday.everyDay
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("Disabling a schedule rule stops its window monitoring")
|
||||
func stopsScheduleWindowWhenDisabled() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(!monitor.monitoredNames.isEmpty)
|
||||
|
||||
rule.isEnabled = false
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Changing a schedule window restarts monitoring")
|
||||
func restartsOnWindowChange() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
|
||||
rule.endMinutes = 18 * 60
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 2)
|
||||
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
|
||||
#expect(monitor.startedWindows[primary]?.end == 18 * 60)
|
||||
}
|
||||
|
||||
@Test("Monitoring stops when a rule is disabled or removed")
|
||||
func stopsStaleMonitoring() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
@@ -178,7 +373,12 @@ struct LimitEnforcementTests {
|
||||
let shields = MockShieldController()
|
||||
let ledger = UsageLedger(defaults: freshDefaults())
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
return (LimitEnforcement(snapshots: store, ledger: ledger, shields: shields), shields, ledger, store)
|
||||
// Isolated session store so granted-open writes don't touch the app group.
|
||||
return (
|
||||
LimitEnforcement(
|
||||
snapshots: store, ledger: ledger, shields: shields,
|
||||
sessions: OpenSessionStore(defaults: freshDefaults())),
|
||||
shields, ledger, store)
|
||||
}
|
||||
|
||||
private func snapshot(
|
||||
@@ -188,6 +388,7 @@ struct LimitEnforcementTests {
|
||||
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
|
||||
startMinutes: 0, endMinutes: 0,
|
||||
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
|
||||
)
|
||||
}
|
||||
@@ -254,6 +455,28 @@ struct LimitEnforcementTests {
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("Granting an open records a session marker; ending it clears it")
|
||||
func openSessionMarkerLifecycle() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = UsageLedger(defaults: freshDefaults())
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
let sessions = OpenSessionStore(defaults: freshDefaults())
|
||||
let enforcement = LimitEnforcement(
|
||||
snapshots: store, ledger: ledger, shields: shields, sessions: sessions)
|
||||
let snap = snapshot(kind: .openLimit, maxOpens: 3)
|
||||
store.save([snap])
|
||||
|
||||
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
|
||||
#expect(enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
|
||||
// The granted ~15-minute session is now recorded and live...
|
||||
#expect(sessions.hasActiveSession(for: snap.id, at: monday))
|
||||
// ...but expires after the session length.
|
||||
#expect(!sessions.hasActiveSession(for: snap.id, at: date(2025, 1, 6, 10, 17)))
|
||||
|
||||
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
|
||||
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
|
||||
}
|
||||
|
||||
@Test("Session end re-shields the rule for the next open")
|
||||
func sessionEndReshields() {
|
||||
let (enforcement, shields, _, store) = makeEnforcement()
|
||||
@@ -277,3 +500,111 @@ struct LimitEnforcementTests {
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Schedule-window enforcement reactions")
|
||||
struct ScheduleEnforcementTests {
|
||||
/// Monday 2025-01-06 10:00 UTC sits inside a 09:00–17:00 weekday window.
|
||||
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||
|
||||
private func makeEnforcement() -> (ScheduleEnforcement, MockShieldController, RuleSnapshotStore) {
|
||||
let shields = MockShieldController()
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
return (ScheduleEnforcement(snapshots: store, shields: shields), shields, store)
|
||||
}
|
||||
|
||||
private func snapshot(
|
||||
start: Int = 9 * 60, end: Int = 17 * 60, days: Set<Weekday> = Weekday.everyDay,
|
||||
isEnabled: Bool = true, mode: SelectionMode = .block, pausedUntil: Date? = nil
|
||||
) -> RuleSnapshot {
|
||||
RuleSnapshot(
|
||||
id: UUID(), name: "Work Time", kindRaw: RuleKind.schedule.rawValue,
|
||||
isEnabled: isEnabled, hardMode: false, blockAdultContent: false,
|
||||
selectionModeRaw: mode.rawValue, selectionData: Data([1]),
|
||||
dayNumbers: days.map(\.rawValue), startMinutes: start, endMinutes: end,
|
||||
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: pausedUntil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Reconcile shields a rule whose window is active now")
|
||||
func shieldsActiveWindow() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot()
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("Reconcile clears a rule whose window is over")
|
||||
func clearsInactiveWindow() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot()
|
||||
store.save([snap])
|
||||
shields.applyShield(
|
||||
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayEvening, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Reconcile forwards the rule's selection mode")
|
||||
func forwardsSelectionMode() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(mode: .allowOnly)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.appliedModes[snap.id] == .allowOnly)
|
||||
}
|
||||
|
||||
@Test("A disabled schedule rule is never shielded")
|
||||
func skipsDisabled() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(isEnabled: false)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A paused (unblocked) schedule rule is left clear")
|
||||
func skipsPaused() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(pausedUntil: date(2025, 1, 6, 17, 0))
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A midnight-crossing window is active in the early morning")
|
||||
func shieldsCrossingWindowAfterMidnight() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
// 22:00–06:00 every day; 02:00 belongs to the window that started the
|
||||
// previous evening (so the previous day must be enabled — it is).
|
||||
let snap = snapshot(start: 22 * 60, end: 6 * 60)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 6, 2, 0), calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("A weekday window is inactive on the weekend")
|
||||
func clearsOnDisabledDay() {
|
||||
let (enforcement, shields, store) = makeEnforcement()
|
||||
let snap = snapshot(days: Weekday.weekdays)
|
||||
store.save([snap])
|
||||
// 2025-01-11 is a Saturday.
|
||||
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 11, 10, 0), calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,13 +79,17 @@ struct UsageStatusTests {
|
||||
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||
|
||||
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
||||
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
||||
dailyLimitMinutes: limit)
|
||||
BlockingRule(
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
||||
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
|
||||
maxOpens: maxOpens)
|
||||
BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
@Test("A time-limit rule with budget left is not active")
|
||||
@@ -153,24 +157,51 @@ struct UsageEnforcementTests {
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = BlockingRule(
|
||||
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||
days: Weekday.everyDay)
|
||||
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
#expect(shields.appliedModes[rule.id] == .block)
|
||||
// Limit rules never engage the adult-content filter (Schedule-only).
|
||||
#expect(shields.appliedAdultContentFlags[rule.id] == false)
|
||||
}
|
||||
|
||||
@Test("A limit rule with budget left is not shielded")
|
||||
func leavesUnspentLimitAlone() {
|
||||
@Test("A time-limit rule with budget left is not shielded")
|
||||
func leavesUnspentTimeLimitAlone() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||
days: Weekday.everyDay)
|
||||
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 20)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||
|
||||
// Time limits let the OS meter usage on the unshielded app, so nothing
|
||||
// is shielded until the budget is spent. (Open limits differ — the
|
||||
// shield is the meter, so they are gated proactively; see
|
||||
// RuleEnforcerTests.proactivelyShieldsOpenLimit.)
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An open-limit rule not scheduled today is not gated")
|
||||
func leavesOffDayOpenLimitAlone() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.weekends)
|
||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||
|
||||
// mondayMorning is a weekday, so the weekend-only rule does not gate.
|
||||
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
@@ -181,9 +212,13 @@ struct UsageEnforcementTests {
|
||||
@Suite("Usage display strings")
|
||||
struct UsageDisplayTests {
|
||||
let timeRule = BlockingRule(
|
||||
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||
days: Weekday.everyDay)
|
||||
let openRule = BlockingRule(
|
||||
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
|
||||
@Test("Time-limit rows show minutes used and remaining")
|
||||
func timeLimitStrings() {
|
||||
|
||||
@@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase {
|
||||
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
|
||||
}
|
||||
|
||||
/// Block Adult Content is a Schedule-only option: the Time Limit editor must
|
||||
/// not offer the toggle, and the rule's detail must not show the row.
|
||||
func testTimeLimitOmitsAdultContent() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
// Scroll to the bottom of the form, where Hard Mode lives. The adult
|
||||
// toggle (which would sit beside it on a Schedule rule) is absent.
|
||||
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||
app.swipeUp()
|
||||
app.switches["hardModeToggle"].waitToAppear()
|
||||
XCTAssertFalse(
|
||||
app.switches["adultContentToggle"].exists,
|
||||
"Time-limit rules must not offer Block Adult Content"
|
||||
)
|
||||
|
||||
app.buttons["commitRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleCard-Time Keeper"].waitToAppear().tap()
|
||||
app.element("detailRuleName").waitToAppear()
|
||||
XCTAssertFalse(
|
||||
app.element("detailRow-Adult websites").exists,
|
||||
"Time-limit detail must not show the Adult websites row"
|
||||
)
|
||||
}
|
||||
|
||||
func testEditorSupportsNativeSwipeBack() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
@@ -13,6 +13,8 @@ struct LimitEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let ledger: UsageLedger
|
||||
let shields: ShieldApplying
|
||||
/// Granted-open session bookkeeping shared with the foreground enforcer.
|
||||
var sessions = OpenSessionStore()
|
||||
|
||||
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
||||
/// proactively shielded on enabled days so the shield can count opens;
|
||||
@@ -62,6 +64,7 @@ struct LimitEnforcement {
|
||||
func handleOpenSessionEnded(
|
||||
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
||||
) {
|
||||
sessions.endSession(for: ruleID)
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
||||
snapshot.kind == .openLimit,
|
||||
!snapshot.isPaused(at: now),
|
||||
@@ -84,6 +87,13 @@ struct LimitEnforcement {
|
||||
guard !snapshot.limitReached(given: usage) else { return false }
|
||||
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
||||
shields.clearShield(ruleID: ruleID)
|
||||
// Mark the session so neither enforcement path re-shields the app until
|
||||
// it ends (+1 minute matches the one-shot activity's padding).
|
||||
if let expiry = calendar.date(
|
||||
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
|
||||
{
|
||||
sessions.startSession(for: ruleID, until: expiry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -91,8 +101,10 @@ struct LimitEnforcement {
|
||||
shields.applyShield(
|
||||
ruleID: snapshot.id,
|
||||
selectionData: snapshot.selectionData,
|
||||
// Limit rules are always Block and never engage the adult-content
|
||||
// filter — those are Schedule-only options.
|
||||
mode: .block,
|
||||
blockAdultContent: snapshot.blockAdultContent
|
||||
blockAdultContent: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ enum MonitoringPlan {
|
||||
private static let dailyPrefix = "rule-"
|
||||
private static let sessionPrefix = "open-session-"
|
||||
private static let minutePrefix = "minutes-"
|
||||
private static let scheduleWindowPrefix = "sched-"
|
||||
private static let scheduleWindowLatePrefix = "sched2-"
|
||||
|
||||
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
|
||||
/// minimum schedule interval, so an "open" lasts at most this long before
|
||||
@@ -38,6 +40,28 @@ enum MonitoringPlan {
|
||||
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
|
||||
}
|
||||
|
||||
/// The primary window activity for a schedule rule. A second
|
||||
/// (`scheduleWindowLateName`) covers the post-midnight half of a window
|
||||
/// that crosses midnight, since DeviceActivity can't express an interval
|
||||
/// whose end is earlier than its start.
|
||||
static func scheduleWindowName(for ruleID: UUID) -> String {
|
||||
scheduleWindowPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func scheduleWindowLateName(for ruleID: UUID) -> String {
|
||||
scheduleWindowLatePrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func ruleID(fromScheduleWindowName name: String) -> UUID? {
|
||||
if name.hasPrefix(scheduleWindowLatePrefix) {
|
||||
return UUID(uuidString: String(name.dropFirst(scheduleWindowLatePrefix.count)))
|
||||
}
|
||||
if name.hasPrefix(scheduleWindowPrefix) {
|
||||
return UUID(uuidString: String(name.dropFirst(scheduleWindowPrefix.count)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func minuteEventName(for minutes: Int) -> String {
|
||||
minutePrefix + String(minutes)
|
||||
}
|
||||
|
||||
59
Shared/OpenSessionStore.swift
Normal file
59
Shared/OpenSessionStore.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// OpenSessionStore.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Read access to in-progress granted "Open" sessions, keyed by rule.
|
||||
protocol OpenSessionReading: AnyObject {
|
||||
/// Whether a granted open for `ruleID` is still running at `now`.
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool
|
||||
}
|
||||
|
||||
/// Records when a granted "Open" expires, per rule, in the shared app-group
|
||||
/// defaults. Pressing the shield's "Open" button lifts an open-limit rule's
|
||||
/// shield for ~15 minutes (`MonitoringPlan.openSessionMinutes`); this marker
|
||||
/// lets the foreground enforcer leave that one rule un-shielded for the life of
|
||||
/// the session instead of re-locking the app mid-session. The monitor clears it
|
||||
/// when the session's one-shot activity ends.
|
||||
final class OpenSessionStore: OpenSessionReading {
|
||||
private static let key = "openSessionExpiry"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date = .now) -> Bool {
|
||||
guard let expiry = expiries[ruleID.uuidString] else { return false }
|
||||
return Date(timeIntervalSince1970: expiry) > now
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
private var expiries: [String: TimeInterval] {
|
||||
defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory granted sessions for tests and UI-test launches.
|
||||
final class MockOpenSessionStore: OpenSessionReading {
|
||||
var activeRuleIDs: Set<UUID> = []
|
||||
|
||||
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool {
|
||||
activeRuleIDs.contains(ruleID)
|
||||
}
|
||||
}
|
||||
90
Shared/RuleConfiguration.swift
Normal file
90
Shared/RuleConfiguration.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// RuleConfiguration.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The kind-specific options of a rule, modelled as a sum type so a rule can
|
||||
/// only carry the options that belong to its kind. This makes illegal states
|
||||
/// unrepresentable: an Open Limit rule cannot hold a time window, a Time Limit
|
||||
/// rule cannot hold a Block/Allow-Only mode, and neither limit kind can hold
|
||||
/// Block Adult Content — those are Schedule-only options.
|
||||
///
|
||||
/// Kind-common attributes (name, days, hardMode, isEnabled, appList,
|
||||
/// pausedUntil) live on the owning `BlockingRule` / `RuleDraft`, not here.
|
||||
enum RuleConfiguration: Hashable, Sendable {
|
||||
case schedule(ScheduleConfig)
|
||||
case timeLimit(TimeLimitConfig)
|
||||
case openLimit(OpenLimitConfig)
|
||||
|
||||
var kind: RuleKind {
|
||||
switch self {
|
||||
case .schedule: .schedule
|
||||
case .timeLimit: .timeLimit
|
||||
case .openLimit: .openLimit
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configuration for a brand-new rule of the given kind, using
|
||||
/// the reference app's defaults (9–5 schedule, 45m/day, 5 opens/day).
|
||||
static func `default`(for kind: RuleKind) -> RuleConfiguration {
|
||||
switch kind {
|
||||
case .schedule: .schedule(ScheduleConfig())
|
||||
case .timeLimit: .timeLimit(TimeLimitConfig())
|
||||
case .openLimit: .openLimit(OpenLimitConfig())
|
||||
}
|
||||
}
|
||||
|
||||
var scheduleConfig: ScheduleConfig? {
|
||||
if case .schedule(let config) = self { config } else { nil }
|
||||
}
|
||||
|
||||
var timeLimitConfig: TimeLimitConfig? {
|
||||
if case .timeLimit(let config) = self { config } else { nil }
|
||||
}
|
||||
|
||||
var openLimitConfig: OpenLimitConfig? {
|
||||
if case .openLimit(let config) = self { config } else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule-rule options: a recurring time window, how the app list is
|
||||
/// interpreted, and whether the adult-website filter engages while the window
|
||||
/// is active. A window whose end is at or before its start crosses midnight.
|
||||
struct ScheduleConfig: Hashable, Sendable {
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var selectionMode: SelectionMode
|
||||
var blockAdultContent: Bool
|
||||
|
||||
init(
|
||||
startMinutes: Int = 9 * 60,
|
||||
endMinutes: Int = 17 * 60,
|
||||
selectionMode: SelectionMode = .block,
|
||||
blockAdultContent: Bool = false
|
||||
) {
|
||||
self.startMinutes = startMinutes
|
||||
self.endMinutes = endMinutes
|
||||
self.selectionMode = selectionMode
|
||||
self.blockAdultContent = blockAdultContent
|
||||
}
|
||||
}
|
||||
|
||||
/// Time Limit option: a daily cumulative-usage budget in minutes.
|
||||
struct TimeLimitConfig: Hashable, Sendable {
|
||||
var dailyLimitMinutes: Int
|
||||
|
||||
init(dailyLimitMinutes: Int = 45) {
|
||||
self.dailyLimitMinutes = dailyLimitMinutes
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Limit option: a daily budget of app opens.
|
||||
struct OpenLimitConfig: Hashable, Sendable {
|
||||
var maxOpens: Int
|
||||
|
||||
init(maxOpens: Int = 5) {
|
||||
self.maxOpens = maxOpens
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,23 @@ struct RuleSnapshot: Codable, Equatable {
|
||||
var selectionModeRaw: String
|
||||
var selectionData: Data?
|
||||
var dayNumbers: [Int]
|
||||
/// Schedule-window bounds, minutes from midnight (mirrors `BlockingRule`).
|
||||
/// Only meaningful for `.schedule` rules; limit rules carry 0/0.
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var dailyLimitMinutes: Int
|
||||
var maxOpens: Int
|
||||
var pausedUntil: Date?
|
||||
|
||||
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
|
||||
var selectionMode: SelectionMode { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
||||
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
|
||||
|
||||
/// The recurring time window this rule blocks, for schedule rules.
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
|
||||
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
||||
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
||||
return false
|
||||
@@ -48,6 +58,35 @@ struct RuleSnapshot: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
extension RuleSnapshot {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, kindRaw, isEnabled, hardMode, blockAdultContent
|
||||
case selectionModeRaw, selectionData, dayNumbers, startMinutes, endMinutes
|
||||
case dailyLimitMinutes, maxOpens, pausedUntil
|
||||
}
|
||||
|
||||
/// Decodes tolerantly so snapshots written before `startMinutes`/`endMinutes`
|
||||
/// existed still load (defaulting the window to 0) instead of failing the
|
||||
/// whole batch — which would blind the extensions until the app reopened.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
kindRaw = try container.decode(String.self, forKey: .kindRaw)
|
||||
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
|
||||
hardMode = try container.decode(Bool.self, forKey: .hardMode)
|
||||
blockAdultContent = try container.decode(Bool.self, forKey: .blockAdultContent)
|
||||
selectionModeRaw = try container.decode(String.self, forKey: .selectionModeRaw)
|
||||
selectionData = try container.decodeIfPresent(Data.self, forKey: .selectionData)
|
||||
dayNumbers = try container.decode([Int].self, forKey: .dayNumbers)
|
||||
startMinutes = try container.decodeIfPresent(Int.self, forKey: .startMinutes) ?? 0
|
||||
endMinutes = try container.decodeIfPresent(Int.self, forKey: .endMinutes) ?? 0
|
||||
dailyLimitMinutes = try container.decode(Int.self, forKey: .dailyLimitMinutes)
|
||||
maxOpens = try container.decode(Int.self, forKey: .maxOpens)
|
||||
pausedUntil = try container.decodeIfPresent(Date.self, forKey: .pausedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistence for the rule mirror in the shared app-group defaults.
|
||||
final class RuleSnapshotStore {
|
||||
private static let key = "ruleSnapshots"
|
||||
|
||||
37
Shared/ScheduleEnforcement.swift
Normal file
37
Shared/ScheduleEnforcement.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// ScheduleEnforcement.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Background reaction for schedule (time-window) rules. The monitor extension
|
||||
/// calls `reconcile` at each window boundary; it recomputes the rule's live
|
||||
/// schedule state from its snapshot and applies or clears the shield to match.
|
||||
///
|
||||
/// This intentionally mirrors what `RuleEnforcer.refresh` does for schedule
|
||||
/// rules in the foreground, so the background and foreground paths never
|
||||
/// disagree. Recomputing (rather than blindly shielding on start / clearing on
|
||||
/// end) also makes the two activities of a midnight-crossing window — and any
|
||||
/// late or duplicated interval callback — converge on the correct state.
|
||||
struct ScheduleEnforcement {
|
||||
let snapshots: RuleSnapshotStore
|
||||
let shields: ShieldApplying
|
||||
|
||||
func reconcile(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
|
||||
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.kind == .schedule else {
|
||||
return
|
||||
}
|
||||
if snapshot.isEnabled, !snapshot.isPaused(at: now),
|
||||
snapshot.schedule.isActive(at: now, calendar: calendar) {
|
||||
shields.applyShield(
|
||||
ruleID: snapshot.id,
|
||||
selectionData: snapshot.selectionData,
|
||||
mode: snapshot.selectionMode,
|
||||
blockAdultContent: snapshot.blockAdultContent
|
||||
)
|
||||
} else {
|
||||
shields.clearShield(ruleID: snapshot.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ offered (Opal's "New Rule" sheet):
|
||||
| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point |
|
||||
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) |
|
||||
|
||||
Common attributes across all types:
|
||||
**Common attributes** — present for *every* rule kind:
|
||||
|
||||
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
|
||||
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
|
||||
@@ -28,14 +28,26 @@ Common attributes across all types:
|
||||
independently of any rule. When editing a rule the user picks an existing
|
||||
list or creates a new one; editing a list affects every rule that uses it.
|
||||
Deleting a list detaches it from its rules (they fall back to "no apps").
|
||||
- **Selection mode** — **Block** (block the list) or **Allow Only** (block
|
||||
everything except the list). The mode belongs to the *rule*, not the list.
|
||||
Only **Schedule** rules offer the choice; Time Limit and Open Limit rules
|
||||
are always Block (a usage budget over "everything except X" is not
|
||||
meaningful, and drafts of those kinds are sanitized back to Block).
|
||||
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
|
||||
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
|
||||
|
||||
**Per-kind options** — each kind carries only the options that make sense for
|
||||
it. The data model expresses this as a sum type (`RuleConfiguration`, see §5.2)
|
||||
so a kind structurally cannot hold another kind's options:
|
||||
|
||||
- **Schedule** — a recurring **time window** (`From`/`To`, may cross midnight),
|
||||
a **Selection mode** (**Block** the list, or **Allow Only** = block
|
||||
everything except it; the mode belongs to the *rule*, not the list), and
|
||||
**Block Adult Content** (engage Screen Time's adult-website filter while the
|
||||
window is active).
|
||||
- **Time Limit** — a **daily minutes budget**.
|
||||
- **Open Limit** — a **daily opens budget**.
|
||||
|
||||
Selection mode and Block Adult Content are **Schedule-only**: a usage budget
|
||||
over "everything except X" is not meaningful, and engaging a web-content
|
||||
filter when a *usage* budget is spent does not fit the feature. Time Limit and
|
||||
Open Limit rules are always Block and never touch the adult-content filter.
|
||||
|
||||
Derived status (drives card/detail UI):
|
||||
- **Active** → countdown to window end: green pill "6h left"
|
||||
- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h"
|
||||
@@ -174,12 +186,13 @@ Sections (each an inset rounded group with a small icon + caption header):
|
||||
blocked" / "Only these apps are allowed" accordingly.
|
||||
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
||||
toggle.
|
||||
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* —
|
||||
subtitle "Filter adult websites while this rule is active"; trailing
|
||||
toggle. Maps to Screen Time's web-content filter
|
||||
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
|
||||
**Schedule rules only**)* — subtitle "Filter adult websites while this rule
|
||||
is active"; trailing toggle. Maps to Screen Time's web-content filter
|
||||
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
|
||||
and cleared together with the rule's shield. Surfaces in the rule detail
|
||||
as an "Adult websites | Blocked/Allowed" row.
|
||||
as an "Adult websites | Blocked/Allowed" row. Time Limit and Open Limit
|
||||
editors do **not** offer this toggle (see §1, Per-kind options).
|
||||
6. **CTA**
|
||||
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
|
||||
interaction (deliberate friction) that fills, then saves and dismisses to
|
||||
@@ -198,14 +211,13 @@ Same chrome (back / title / rename). Sections:
|
||||
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
|
||||
(reset point — e.g. tomorrow/next morning).
|
||||
5. **Hard Mode** toggle — same as Schedule.
|
||||
6. **Block Adult Content** toggle — same as Schedule.
|
||||
7. **Hold to Commit**.
|
||||
6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)*
|
||||
|
||||
### 3.6 Rule Editor — Open Limit type
|
||||
|
||||
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
||||
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
|
||||
Hard Mode / Block Adult Content / Hold to Commit.
|
||||
Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)*
|
||||
|
||||
### 3.7 App Picker (shared component — also used in onboarding & timer)
|
||||
|
||||
@@ -276,7 +288,20 @@ Full-height sheet:
|
||||
work the same with an opens budget; while opens remain, their apps stay
|
||||
shielded with an "Open" button on the shield (each press spends one open
|
||||
and lifts the shield for up to 15 minutes — the DeviceActivity minimum
|
||||
interval).
|
||||
interval). Because the shield is what *counts* opens, an enabled open-limit
|
||||
rule scheduled today is shielded **proactively from the start of the day,
|
||||
even before any opens are spent** — by *both* the background
|
||||
(`LimitEnforcement.handleDayStart`) and the foreground
|
||||
(`RuleEnforcer.refresh`) paths, so a freshly created open-limit rule gates
|
||||
its apps immediately and the gate survives the app being foregrounded. The
|
||||
one exception is a **granted "Open" session**: pressing Open lifts the
|
||||
shield for ~15 minutes, recorded as an expiry in the shared
|
||||
`OpenSessionStore`; while that session is live, neither path re-shields the
|
||||
rule (so the sanctioned session is never cut short), and the monitor
|
||||
re-shields when the session's one-shot activity ends. Unlike a *spent*
|
||||
budget, this proactive gate does **not** put the rule in "Blocked Apps"
|
||||
(which lists only rules whose budget is exhausted); it shows under "Usage"
|
||||
with its remaining opens.
|
||||
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
|
||||
rule (card presumably shows disabled state). No delete flow was shown;
|
||||
add delete via swipe/long-press or a button in the editor.
|
||||
@@ -285,6 +310,26 @@ Full-height sheet:
|
||||
should be deliberate. Editing uses a plain "Done".
|
||||
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
|
||||
(minute granularity is fine).
|
||||
8. **Overlapping rules — strictest enforcement wins.** When several rules
|
||||
target the same app, the app is blocked if **any** of them is currently
|
||||
blocking it; rules never cancel each other out. This is structural rather
|
||||
than a resolved decision: each rule owns its own `ManagedSettingsStore`
|
||||
(`rule-<uuid>`), Screen Time **unions** shields across all stores, and a
|
||||
rule only ever writes/clears *its own* store. Consequences:
|
||||
- An open-limit and a time-limit rule on the same app each block via their
|
||||
own store, so whichever's budget is spent **first** blocks the app,
|
||||
regardless of the other's remaining budget.
|
||||
- An **Allow-Only** schedule cannot punch a hole for an app that another
|
||||
rule blocks: `.all(except:)` is itself a *shield* directive ("block
|
||||
everything except these"), not a whitelist that lifts other stores'
|
||||
shields. So if a schedule "allows" an app but a time limit blocks it, the
|
||||
time-limit block stands.
|
||||
- A soft **unblock** pauses only the one rule it was invoked on; other rules
|
||||
blocking the same app keep it blocked.
|
||||
|
||||
There is deliberately **no** central merge of selections into a single
|
||||
shield set — such a merge would be the only place a block could be
|
||||
accidentally dropped.
|
||||
|
||||
---
|
||||
|
||||
@@ -309,36 +354,58 @@ Full-height sheet:
|
||||
|
||||
### 5.2 Data model (SwiftData)
|
||||
|
||||
Replace the template `AppListProfile` with:
|
||||
The domain currency is a **sum type** so a rule can only hold the options that
|
||||
belong to its kind — illegal states (e.g. an Open Limit rule with a time
|
||||
window, or a Time Limit rule with Block Adult Content) are unrepresentable:
|
||||
|
||||
```swift
|
||||
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
|
||||
enum SelectionMode: String, Codable { case block, allowOnly }
|
||||
|
||||
enum RuleConfiguration: Hashable, Sendable {
|
||||
case schedule(ScheduleConfig)
|
||||
case timeLimit(TimeLimitConfig)
|
||||
case openLimit(OpenLimitConfig)
|
||||
var kind: RuleKind { … }
|
||||
}
|
||||
|
||||
struct ScheduleConfig: Hashable, Sendable { // Schedule-only options
|
||||
var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00
|
||||
var endMinutes: Int // may be ≤ start (crosses midnight)
|
||||
var selectionMode: SelectionMode
|
||||
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
|
||||
}
|
||||
struct TimeLimitConfig: Hashable, Sendable { var dailyLimitMinutes: Int }
|
||||
struct OpenLimitConfig: Hashable, Sendable { var maxOpens: Int }
|
||||
```
|
||||
|
||||
The kind-common attributes (`name`, `days`, `hardMode`, `isEnabled`,
|
||||
`appList`, `pausedUntil`) live alongside the configuration:
|
||||
|
||||
```swift
|
||||
@Model final class BlockingRule {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var kind: RuleKind
|
||||
var isEnabled: Bool
|
||||
var hardMode: Bool
|
||||
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
|
||||
var selectionMode: SelectionMode
|
||||
var selectionData: Data // encoded FamilyActivitySelection
|
||||
var days: [Int] // 1...7, Calendar weekday numbers
|
||||
// schedule
|
||||
var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00
|
||||
var endMinutes: Int // may be < start (crosses midnight)
|
||||
// time limit
|
||||
var dailyLimitMinutes: Int?
|
||||
var resetPoint: String? // "tomorrow" | "nextMorning" …
|
||||
// open limit
|
||||
var maxOpens: Int?
|
||||
var appList: AppList?
|
||||
var days: [Int] // 1...7, Calendar weekday numbers
|
||||
var pausedUntil: Date?
|
||||
var createdAt: Date
|
||||
// The kind-specific options, exposed as a computed bridge over the
|
||||
// model's raw stored columns:
|
||||
var configuration: RuleConfiguration { get set }
|
||||
var kind: RuleKind { configuration.kind }
|
||||
}
|
||||
```
|
||||
|
||||
`FamilyActivitySelection` is `Codable` → store as `Data`. Status
|
||||
("active", "starts in Xh", "Xh left") is **derived**, not stored.
|
||||
`RuleDraft` (the editors' value-type working copy) carries the same
|
||||
`configuration` + common fields, so each editor only renders its kind's
|
||||
options. `BlockingRule` persists the configuration as flat columns and the
|
||||
cross-process `RuleSnapshot` mirror keeps its flat wire shape; both are
|
||||
read/written exclusively through the sum type. `FamilyActivitySelection` is
|
||||
`Codable` → stored on the `AppList` as `Data`. Status ("active", "starts in
|
||||
Xh", "Xh left") is **derived**, not stored.
|
||||
|
||||
### 5.3 View inventory
|
||||
|
||||
@@ -373,27 +440,59 @@ enum SelectionMode: String, Codable { case block, allowOnly }
|
||||
|
||||
### 5.5 Background enforcement architecture (implemented)
|
||||
|
||||
- **App group** `group.dev.bchen.OpenAppLock` shares three stores between the
|
||||
- **App group** `group.dev.bchen.OpenAppLock` shares four stores between the
|
||||
app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
|
||||
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
|
||||
per-day minutes/opens), and the shield-store tracking list.
|
||||
per-day minutes/opens), `OpenSessionStore` (per-rule expiry of a granted
|
||||
"Open" session), and the shield-store tracking list.
|
||||
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
|
||||
enabled limit rules: one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
enabled rules:
|
||||
- **Limit rules** — one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
- **Schedule rules** — one (or, for windows that cross midnight, two)
|
||||
repeating window activit(ies) per rule matching the rule's
|
||||
`From…To` window (`sched-<uuid>` and, for the post-midnight half,
|
||||
`sched2-<uuid>`). These carry no events; they exist purely to wake the
|
||||
monitor at the window edges so shields engage **in the background even
|
||||
when the app is closed**. A window that ends exactly at midnight, or is
|
||||
shorter than DeviceActivity's 15-minute minimum interval, may fail to
|
||||
register (`intervalTooShort`) and falls back to the foreground loop.
|
||||
Activities restart only when their configuration changes, because a
|
||||
restart resets threshold accounting.
|
||||
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
|
||||
= midnight reset (open-limit rules re-shield so opens can be counted;
|
||||
time-limit shields clear for the fresh budget); each `minutes-<k>` event
|
||||
records usage and shields at the budget; a finished `open-session-<uuid>`
|
||||
one-shot re-shields after a granted open.
|
||||
= midnight reset for limit rules (open-limit rules re-shield so opens can
|
||||
be counted; time-limit shields clear for the fresh budget); each
|
||||
`minutes-<k>` event records usage and shields at the budget; a finished
|
||||
`open-session-<uuid>` one-shot re-shields after a granted open. For
|
||||
schedule-window activities (`sched-`/`sched2-`), **both** interval start
|
||||
and interval end **recompute** the rule's live schedule state from its
|
||||
snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the
|
||||
midnight-crossing rule) and apply or clear the shield accordingly — the
|
||||
same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths
|
||||
agree.
|
||||
- **Reliability posture** — DeviceActivity interval callbacks are
|
||||
"first device use after the boundary", are known to fire late or be
|
||||
skipped (device asleep, OS regressions on iOS 17/18/26), and a shield
|
||||
written over an app the user already has open may not visibly engage until
|
||||
that app is relaunched (a long-standing Screen Time platform limitation).
|
||||
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
|
||||
(launch + 30 s foreground loop) is retained as the reconciliation safety
|
||||
net and is the source of truth whenever the app runs. To keep that net
|
||||
consistent with the background, `refresh` applies the **same** open-limit
|
||||
proactive gate as `handleDayStart`: an enabled, scheduled-today, un-paused
|
||||
open-limit rule is shielded even before its budget is spent, *unless* the
|
||||
`OpenSessionStore` reports a still-running granted open for it — so the
|
||||
foreground loop establishes the turnstile for newly created rules and never
|
||||
re-locks an app mid-session.
|
||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
||||
button; other shields show the blocking rule's name.
|
||||
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
|
||||
spends one open in the ledger, lifts the rule's shield, and starts the
|
||||
~15-minute one-shot session (DeviceActivity's minimum interval).
|
||||
spends one open in the ledger, lifts the rule's shield, records the session
|
||||
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
|
||||
(DeviceActivity's minimum interval); the monitor clears that record when the
|
||||
session ends.
|
||||
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
|
||||
tested from the app test target.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user