Compare commits

...

4 Commits

Author SHA1 Message Date
31830cb141 fix: enforce open-limit rules proactively in the foreground
RuleEnforcer.refresh only shielded a limit rule once its budget was
spent, so it tore down the background's proactive open-limit "turnstile"
shield (the shield is how opens are counted) whenever the app was
foregrounded, and a freshly created open-limit rule did nothing until
the next midnight reset.

refresh now applies the same proactive gate as
LimitEnforcement.handleDayStart: an enabled, scheduled-today, un-paused
open-limit rule is shielded even before its budget is spent, so it takes
effect immediately. A new app-group OpenSessionStore records a granted
"Open" session's expiry so neither enforcement path re-shields (and cuts
short) a sanctioned ~15-minute session.

Overlapping rules already enforce strictly: each rule shields its own
ManagedSettingsStore and Screen Time unions them, so an app is blocked
if any covering rule blocks it. Document that model in the spec (§4.8)
and lock it in with tests, including a time limit blocking during an
open-limit's granted session and a daily opens reset.
2026-06-13 13:52:54 -04:00
fc0f518608 refactor: model rule options as a per-kind sum type
Replace the wide BlockingRule/RuleDraft "god struct" — where every option
existed for every kind — with a RuleConfiguration sum type that carries only
the options each kind actually has:

  .schedule(ScheduleConfig: window + selectionMode + blockAdultContent)
  .timeLimit(TimeLimitConfig: dailyLimitMinutes)
  .openLimit(OpenLimitConfig: maxOpens)

Name, days, Hard Mode, app list, and pause stay common to all kinds. This
makes illegal states unrepresentable: Block / Allow Only and Block Adult
Content are now structurally Schedule-only.

User-visible behavior change: the Time Limit and Open Limit editors no longer
offer a Block Adult Content toggle, and their detail sheets drop the "Adult
websites" row — those never made sense for a usage budget. Limit rules are
always Block and never engage the web-content filter.

BlockingRule keeps flat columns as raw persistence behind a computed
`configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot
wire format and the Screen Time extensions are untouched). Logic, the editors,
and the detail sheet all switch on the sum type.

Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with
new structural-guarantee unit tests and a UI test asserting the Time Limit
editor/detail omit adult content. Full suite green (180 tests).
2026-06-13 12:51:26 -04:00
52802a9984 fix: show daily budget instead of bogus clock countdown for limit rules
Time-limit and open-limit rules reuse BlockingRule's startMinutes/
endMinutes, which default to 09:00-17:00. Those fields are meaningless
for limit rules (the budget applies all day, resetting at midnight), but
status() still falls through to schedule.nextStart(), so an idle limit
rule reported .upcoming(startsAt: next 09:00) — rendered in the detail
sheet header as "Starts in 22h".

The home card already special-cased this; the detail sheet rendered the
raw status label, so the misleading countdown leaked there. Centralize a
kind-aware BlockingRule.statusLabel(for:relativeTo:) used by both the
card and the detail header: limit rules that aren't blocking show their
daily budget ("45m / day", "5 opens / day"); schedule rules and any
blocking/paused/dormant rule keep the live status label.

Verified on simulator: the Time Keeper detail header now reads
"Time Limit, 45m / day". 173 tests pass.
2026-06-13 11:55:19 -04:00
9092221d32 feat: enforce schedule rules in the background via DeviceActivity windows
Schedule (time-window) rules had no background enforcement: RuleScheduler.sync()
skipped them, so their shields were applied only by RuleEnforcer.refresh() — the
launch + 30s foreground loop. A window that began while the app was closed didn't
engage until the user reopened the app, which is why scheduled blocks could land
late or unevenly.

RuleScheduler now registers a repeating DeviceActivitySchedule per enabled
schedule rule's window (sched-<uuid>, plus sched2-<uuid> for windows that cross
midnight, since DeviceActivity can't express an interval whose end precedes its
start). The monitor extension routes these to a new ScheduleEnforcement.reconcile(),
which recomputes the rule's live schedule state from its snapshot
(RuleSchedule.isActive, honouring days, pause and the midnight-crossing rule) and
applies or clears the shield to match — the same logic RuleEnforcer.refresh runs
in the foreground, kept as the reconciliation safety net because interval callbacks
are known to fire late or not at all.

- RuleSnapshot gains startMinutes/endMinutes, with a tolerant decoder so snapshots
  written before these fields still load instead of failing the whole batch and
  blinding the extensions until the app reopens.
- Window activities are fingerprinted on their interval alone, so changing days,
  mode or apps no longer needlessly restarts monitoring.

On-device verification of the background transition is still pending (the simulator
does not deliver DeviceActivity callbacks); covered by new unit tests across the
scheduler, the snapshot codec and the new enforcement reactions.
2026-06-13 02:36:09 -04:00
29 changed files with 1593 additions and 316 deletions

View File

@@ -140,9 +140,14 @@ Gotchas learned the hard way:
limits accrue in the Usage section and block at the budget; open-limit 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 apps shield immediately with an "Open (N left)" button; an open lasts
~15 minutes (DeviceActivity's minimum interval) before re-shielding. ~15 minutes (DeviceActivity's minimum interval) before re-shielding.
- **Schedule-rule background transitions** still rely on the app running - **Schedule-rule background transitions** are now backed by DeviceActivity:
(launch / foreground 30s loop); schedule rules have no DeviceActivity `RuleScheduler` registers a repeating window activity per schedule rule
monitoring yet — only limit rules do. (`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` shows few apps on the simulator; fine on device.
- `FamilyActivityPicker` **silently ignores selections** (binding never - `FamilyActivityPicker` **silently ignores selections** (binding never
updates, rows still show checkmarks) unless real FamilyControls updates, rows still show checkmarks) unless real FamilyControls

View File

@@ -13,6 +13,20 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy 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 */ = { E20000000000000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
@@ -34,22 +48,24 @@
remoteGlobalIDString = C10000000000000000000003; remoteGlobalIDString = C10000000000000000000003;
remoteInfo = OpenAppLockShieldAction; 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 */ /* 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 */ /* Begin PBXFileReference section */
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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; }; B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* 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 */ /* Begin PBXFileSystemSynchronizedRootGroup section */
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = { 20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
@@ -106,46 +146,6 @@
}; };
/* End PBXFileSystemSynchronizedRootGroup section */ /* 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 */ /* Begin PBXFrameworksBuildPhase section */
20A7EDCB2E47B7CD0097608D /* Frameworks */ = { 20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
@@ -308,8 +308,8 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA00000000000000000000A2 /* OpenAppLockMonitor */,
AA00000000000000000000A1 /* Shared */, AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A2 /* OpenAppLockMonitor */,
); );
name = OpenAppLockMonitor; name = OpenAppLockMonitor;
packageProductDependencies = ( packageProductDependencies = (
@@ -331,8 +331,8 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
AA00000000000000000000A1 /* Shared */, AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
); );
name = OpenAppLockShieldConfig; name = OpenAppLockShieldConfig;
packageProductDependencies = ( packageProductDependencies = (
@@ -354,8 +354,8 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
AA00000000000000000000A1 /* Shared */, AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
); );
name = OpenAppLockShieldAction; name = OpenAppLockShieldAction;
packageProductDependencies = ( packageProductDependencies = (

View File

@@ -82,6 +82,22 @@ extension BlockingRule {
return .dormant 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`. /// Whether the rule's enabled days include the day containing `now`.
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool { func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else { 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. /// Whether the given usage exhausts this rule's daily budget.
/// Always false for schedule rules they block by the clock. /// Always false for schedule rules they block by the clock.
func limitReached(given usage: RuleUsage) -> Bool { func limitReached(given usage: RuleUsage) -> Bool {
switch kind { switch configuration {
case .schedule: false case .schedule: false
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes
case .openLimit: usage.opensUsed >= maxOpens case .openLimit(let config): usage.opensUsed >= config.maxOpens
} }
} }
} }

View File

@@ -10,14 +10,14 @@ import Foundation
enum UsageDisplay { enum UsageDisplay {
/// "18m of 45m used today" / "2 of 5 opens today". /// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.kind { switch rule.configuration {
case .schedule: case .schedule:
"" ""
case .timeLimit: case .timeLimit(let config):
"\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of " "\(min(usage.minutesUsed, config.dailyLimitMinutes))m of "
+ "\(rule.dailyLimitMinutes)m used today" + "\(config.dailyLimitMinutes)m used today"
case .openLimit: case .openLimit(let config):
"\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today" "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today"
} }
} }
@@ -27,13 +27,13 @@ enum UsageDisplay {
guard !rule.limitReached(given: usage) else { guard !rule.limitReached(given: usage) else {
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow" return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
} }
switch rule.kind { switch rule.configuration {
case .schedule: case .schedule:
return "" return ""
case .timeLimit: case .timeLimit(let config):
return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left" return "\(config.dailyLimitMinutes - usage.minutesUsed)m left"
case .openLimit: case .openLimit(let config):
let remaining = rule.maxOpens - usage.opensUsed let remaining = config.maxOpens - usage.opensUsed
return remaining == 1 ? "1 open left" : "\(remaining) opens left" return remaining == 1 ? "1 open left" : "\(remaining) opens left"
} }
} }

View File

@@ -8,6 +8,13 @@ import SwiftData
/// A recurring screen-time blocking rule. /// 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 /// 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 /// 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. /// its start (e.g. 22:00 06:00) crosses midnight into the following day.
@@ -19,10 +26,6 @@ final class BlockingRule {
var isEnabled: Bool var isEnabled: Bool
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked. /// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
var hardMode: Bool 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). /// The reusable app list this rule blocks (or allows, in Allow Only mode).
/// ///
/// Deliberately not an `init` parameter: SwiftData relationship properties /// Deliberately not an `init` parameter: SwiftData relationship properties
@@ -36,51 +39,99 @@ final class BlockingRule {
/// Legacy denormalized count; superseded by `appList?.selectionCount`. /// Legacy denormalized count; superseded by `appList?.selectionCount`.
var selectionCount: Int var selectionCount: Int
var dayNumbers: [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). /// 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. /// Cleared automatically once the date passes; never set while Hard Mode is active.
var pausedUntil: Date? var pausedUntil: Date?
var createdAt: 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( init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
kind: RuleKind = .schedule, configuration: RuleConfiguration = .schedule(ScheduleConfig()),
isEnabled: Bool = true, isEnabled: Bool = true,
hardMode: Bool = false, hardMode: Bool = false,
blockAdultContent: Bool = false,
selectionMode: SelectionMode = .block,
selectionData: Data? = nil,
selectionCount: Int = 0,
days: Set<Weekday> = Weekday.weekdays, days: Set<Weekday> = Weekday.weekdays,
startMinutes: Int = 9 * 60,
endMinutes: Int = 17 * 60,
dailyLimitMinutes: Int = 45,
maxOpens: Int = 5,
pausedUntil: Date? = nil, pausedUntil: Date? = nil,
createdAt: Date = .now createdAt: Date = .now
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.kindRaw = kind.rawValue
self.isEnabled = isEnabled self.isEnabled = isEnabled
self.hardMode = hardMode self.hardMode = hardMode
self.blockAdultContent = blockAdultContent self.appList = nil
self.selectionModeRaw = selectionMode.rawValue self.selectionData = nil
self.selectionData = selectionData self.selectionCount = 0
self.selectionCount = selectionCount
self.dayNumbers = days.map(\.rawValue).sorted() self.dayNumbers = days.map(\.rawValue).sorted()
self.startMinutes = startMinutes
self.endMinutes = endMinutes
self.dailyLimitMinutes = dailyLimitMinutes
self.maxOpens = maxOpens
self.pausedUntil = pausedUntil self.pausedUntil = pausedUntil
self.createdAt = createdAt 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 { var kind: RuleKind {
@@ -88,9 +139,9 @@ final class BlockingRule {
set { kindRaw = newValue.rawValue } set { kindRaw = newValue.rawValue }
} }
/// How this rule interprets its app list. Always `.block` for limit kinds.
var selectionMode: SelectionMode { var selectionMode: SelectionMode {
get { SelectionMode(rawValue: selectionModeRaw) ?? .block } SelectionMode(rawValue: selectionModeRaw) ?? .block
set { selectionModeRaw = newValue.rawValue }
} }
var days: Set<Weekday> { var days: Set<Weekday> {

View File

@@ -9,58 +9,46 @@ import SwiftData
/// Value-type working copy of a rule used by the editors, so cancelling an /// 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 /// edit never touches the persisted model. Hashable so it can drive
/// `navigationDestination(item:)`. /// `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 { struct RuleDraft: Hashable {
var name: String var name: String
var kind: RuleKind
var days: Set<Weekday> var days: Set<Weekday>
var startMinutes: Int
var endMinutes: Int
var dailyLimitMinutes: Int
var maxOpens: Int
var hardMode: Bool var hardMode: Bool
var blockAdultContent: Bool
var selectionMode: SelectionMode
/// Reference to the persisted list the rule will use. App lists are /// Reference to the persisted list the rule will use. App lists are
/// managed (created/edited) directly by the picker, so the draft only /// managed (created/edited) directly by the picker, so the draft only
/// carries the pointer. /// carries the pointer.
var appList: AppList? 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 /// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day). /// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) { init(kind: RuleKind) {
self.name = kind.defaultRuleName self.name = kind.defaultRuleName
self.kind = kind
self.days = Weekday.weekdays self.days = Weekday.weekdays
self.startMinutes = 9 * 60
self.endMinutes = 17 * 60
self.dailyLimitMinutes = 45
self.maxOpens = 5
self.hardMode = false self.hardMode = false
self.blockAdultContent = false
self.selectionMode = .block
self.appList = nil self.appList = nil
self.configuration = .default(for: kind)
} }
init(rule: BlockingRule) { init(rule: BlockingRule) {
self.name = rule.name self.name = rule.name
self.kind = rule.kind
self.days = rule.days 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.hardMode = rule.hardMode
self.blockAdultContent = rule.blockAdultContent
self.selectionMode = rule.selectionMode
self.appList = rule.appList self.appList = rule.appList
self.configuration = rule.configuration
} }
init(preset: RulePreset) { init(preset: RulePreset) {
self.init(kind: .schedule) self.init(kind: .schedule)
self.name = preset.name self.name = preset.name
self.days = preset.days self.days = preset.days
self.startMinutes = preset.startMinutes self.configuration = .schedule(
self.endMinutes = preset.endMinutes ScheduleConfig(startMinutes: preset.startMinutes, endMinutes: preset.endMinutes))
} }
/// Writes the draft back onto a rule. The rule (and the chosen list) must /// 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`). /// assigned between managed models (see `BlockingRule.appList`).
func apply(to rule: BlockingRule) { func apply(to rule: BlockingRule) {
rule.name = name rule.name = name
rule.kind = kind
rule.days = days rule.days = days
rule.startMinutes = startMinutes
rule.endMinutes = endMinutes
rule.dailyLimitMinutes = dailyLimitMinutes
rule.maxOpens = maxOpens
rule.hardMode = hardMode rule.hardMode = hardMode
rule.blockAdultContent = blockAdultContent rule.configuration = configuration
rule.selectionMode = selectionMode
if rule.appList !== appList { if rule.appList !== appList {
rule.appList = appList rule.appList = appList
} }
@@ -87,26 +69,47 @@ struct RuleDraft: Hashable {
/// ever written on a managed model. /// ever written on a managed model.
@discardableResult @discardableResult
func insertRule(into context: ModelContext) -> BlockingRule { func insertRule(into context: ModelContext) -> BlockingRule {
let rule = BlockingRule(name: name, kind: kind) let rule = BlockingRule(name: name, configuration: configuration)
context.insert(rule) context.insert(rule)
apply(to: rule) apply(to: rule)
return rule return rule
} }
/// Trims the name (falling back to the kind's default when it is empty) /// 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 /// (Block / Allow Only no longer needs sanitizing: the sum type makes it
/// schedule-rule concept. /// impossible for a limit draft to carry a selection mode at all.)
func sanitized() -> RuleDraft { func sanitized() -> RuleDraft {
var copy = self var copy = self
let trimmed = name.trimmingCharacters(in: .whitespaces) let trimmed = name.trimmingCharacters(in: .whitespaces)
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
if kind != .schedule {
copy.selectionMode = .block
}
return copy return copy
} }
var schedule: RuleSchedule { 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) }
} }
} }

View File

@@ -53,8 +53,12 @@ struct OpenAppLockApp: App {
let scheduler = let scheduler =
config.isUITesting config.isUITesting
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor()) ? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
let openSessions: OpenSessionReading =
config.isUITesting ? MockOpenSessionStore() : OpenSessionStore()
_enforcer = State( _enforcer = State(
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler)) initialValue: RuleEnforcer(
shields: shields, usage: usageLedger, scheduler: scheduler,
openSessions: openSessions))
} }
var body: some Scene { var body: some Scene {

View File

@@ -24,14 +24,19 @@ final class RuleEnforcer {
/// Day-usage source consulted for limit rules; also exposed to views for /// Day-usage source consulted for limit rules; also exposed to views for
/// the Usage section. /// the Usage section.
let usageReader: UsageReading 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( init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(), shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore()
) { ) {
self.shields = shields self.shields = shields
self.usageReader = usage self.usageReader = usage
self.scheduler = scheduler self.scheduler = scheduler
self.openSessions = openSessions
} }
/// The day's usage for a rule (nil for schedule rules, which don't track). /// 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, /// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses. /// 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) { 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 { for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now { if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil rule.pausedUntil = nil
} }
let usage = usage(for: rule, at: now, calendar: calendar) let usage = usage(for: rule, at: now, calendar: calendar)
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue } let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
active.insert(rule.id) if isBlocking { blocking.insert(rule.id) }
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
continue
}
shielded.insert(rule.id)
shields.applyShield( shields.applyShield(
ruleID: rule.id, ruleID: rule.id,
selectionData: rule.appList?.selectionData, selectionData: rule.appList?.selectionData,
// Allow Only is a schedule-rule concept; limit blocks always Block. // Allow Only and Block Adult Content are Schedule-only options;
mode: rule.kind == .schedule ? rule.selectionMode : .block, // the model already forces .block / false on limit rules, so we
// can forward the rule's values directly.
mode: rule.selectionMode,
blockAdultContent: rule.blockAdultContent blockAdultContent: rule.blockAdultContent
) )
} }
shields.clearShields(except: active) shields.clearShields(except: shielded)
blockingRuleIDs = active // "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) 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)
}
} }

View File

@@ -15,6 +15,13 @@ protocol ActivityMonitoring: AnyObject {
func startDailyMonitoring( func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int] name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws ) 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]) func stopMonitoring(names: [String])
var monitoredNames: [String] { get } var monitoredNames: [String] { get }
} }
@@ -47,32 +54,50 @@ final class RuleScheduler {
var fingerprints = storedFingerprints var fingerprints = storedFingerprints
var desiredNames: Set<String> = [] var desiredNames: Set<String> = []
for rule in rules where rule.kind != .schedule { for rule in rules {
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue } // A rule must be enabled, have days, and have apps to be monitored.
let name = MonitoringPlan.dailyActivityName(for: rule.id) guard rule.isEnabled, !rule.days.isEmpty,
desiredNames.insert(name) let selectionData = rule.appList?.selectionData
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)
else { continue } else { continue }
do { switch rule.kind {
try monitor.startDailyMonitoring( case .timeLimit, .openLimit:
name: name, selectionData: selectionData, eventMinutes: events) let name = MonitoringPlan.dailyActivityName(for: rule.id)
fingerprints[name] = fingerprint desiredNames.insert(name)
} catch { let events =
// Monitoring is best-effort here; the next sync retries. rule.kind == .timeLimit
// (Throws on the simulator and when authorization is missing.) ? 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 { 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 { if !stale.isEmpty {
monitor.stopMonitoring(names: stale) monitor.stopMonitoring(names: stale)
@@ -83,6 +108,50 @@ final class RuleScheduler {
storedFingerprints = fingerprints 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] { private var storedFingerprints: [String: String] {
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] } get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
set { defaults.set(newValue, forKey: Self.fingerprintsKey) } set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
@@ -101,6 +170,8 @@ extension RuleSnapshot {
selectionModeRaw: rule.selectionModeRaw, selectionModeRaw: rule.selectionModeRaw,
selectionData: rule.appList?.selectionData, selectionData: rule.appList?.selectionData,
dayNumbers: rule.dayNumbers, dayNumbers: rule.dayNumbers,
startMinutes: rule.startMinutes,
endMinutes: rule.endMinutes,
dailyLimitMinutes: rule.dailyLimitMinutes, dailyLimitMinutes: rule.dailyLimitMinutes,
maxOpens: rule.maxOpens, maxOpens: rule.maxOpens,
pausedUntil: rule.pausedUntil pausedUntil: rule.pausedUntil
@@ -142,6 +213,19 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events) 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]) { func stopMonitoring(names: [String]) {
center.stopMonitoring(names.map { DeviceActivityName($0) }) center.stopMonitoring(names.map { DeviceActivityName($0) })
} }
@@ -150,6 +234,7 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
/// Records scheduling calls for tests. /// Records scheduling calls for tests.
final class MockActivityMonitor: ActivityMonitoring { final class MockActivityMonitor: ActivityMonitoring {
private(set) var startedEvents: [String: [String: Int]] = [:] private(set) var startedEvents: [String: [String: Int]] = [:]
private(set) var startedWindows: [String: (start: Int, end: Int)] = [:]
private(set) var startCallCount = 0 private(set) var startCallCount = 0
private(set) var monitoredNames: [String] = [] 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]) { func stopMonitoring(names: [String]) {
monitoredNames.removeAll(where: names.contains) monitoredNames.removeAll(where: names.contains)
for name in names { for name in names {
startedEvents[name] = nil startedEvents[name] = nil
startedWindows[name] = nil
} }
} }
} }

View File

@@ -35,13 +35,17 @@ enum SampleRules {
] ]
case .limits: case .limits:
let timeKeeper = BlockingRule( let timeKeeper = BlockingRule(
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, name: "Time Keeper",
dailyLimitMinutes: 45) configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
let gateKeeper = BlockingRule( 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( let doomScroll = BlockingRule(
name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay, name: "Doom Scroll",
dailyLimitMinutes: 30) configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)),
days: Weekday.everyDay)
usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18) usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18)
usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2) usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2)
usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30) usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30)
@@ -65,10 +69,9 @@ enum SampleRules {
let end = min(24 * 60 - 1, nowMinutes + 6 * 60) let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
return BlockingRule( return BlockingRule(
name: name, name: name,
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
hardMode: hardMode, hardMode: hardMode,
days: Weekday.everyDay, days: Weekday.everyDay
startMinutes: start,
endMinutes: end
) )
} }
@@ -82,9 +85,8 @@ enum SampleRules {
let end = (start + 8 * 60) % (24 * 60) let end = (start + 8 * 60) % (24 * 60)
return BlockingRule( return BlockingRule(
name: name, name: name,
days: Weekday.everyDay, configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
startMinutes: start, days: Weekday.everyDay
endMinutes: end
) )
} }

View File

@@ -231,18 +231,9 @@ struct AppsHomeView: View {
} }
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
switch rule.kind { // Shared with the detail sheet: limit rules that aren't blocking show
case .schedule: // their daily budget; everything else uses the live status label.
return status.label(relativeTo: now) rule.statusLabel(for: status, 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)
}
} }
// MARK: - Enforcement // MARK: - Enforcement

View File

@@ -89,7 +89,7 @@ struct RuleDetailSheet: View {
Text(rule.name) Text(rule.name)
.font(.headline) .font(.headline)
.accessibilityIdentifier("detailRuleName") .accessibilityIdentifier("detailRuleName")
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))") Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityIdentifier("detailStatusLabel") .accessibilityIdentifier("detailStatusLabel")
@@ -100,26 +100,25 @@ struct RuleDetailSheet: View {
@ViewBuilder @ViewBuilder
private var detailRows: some View { private var detailRows: some View {
switch rule.kind { switch rule.configuration {
case .schedule: case .schedule(let config):
row("During this time", rule.schedule.timeRangeLabel) row("During this time", rule.schedule.timeRangeLabel)
row("On these days", rule.days.summary) row("On these days", rule.days.summary)
row(rule.selectionMode.displayName, appCountLabel) row(config.selectionMode.displayName, appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") // Adult websites is a Schedule-only option (see spec §1).
row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit: case .timeLimit(let config):
row("When I use", appCountLabel) 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("On these days", rule.days.summary)
row("Then block until", "Tomorrow") row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit: case .openLimit(let config):
row("When I open", appCountLabel) 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("On these days", rule.days.summary)
row("Then block until", "Tomorrow") row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
} }
} }

View File

@@ -97,13 +97,13 @@ struct RuleEditorView: View {
Section { Section {
DatePicker( DatePicker(
"From", "From",
selection: timeBinding($draft.startMinutes), selection: timeBinding($draft.scheduleConfig.startMinutes),
displayedComponents: .hourAndMinute displayedComponents: .hourAndMinute
) )
.accessibilityIdentifier("fromTimePicker") .accessibilityIdentifier("fromTimePicker")
DatePicker( DatePicker(
"To", "To",
selection: timeBinding($draft.endMinutes), selection: timeBinding($draft.scheduleConfig.endMinutes),
displayedComponents: .hourAndMinute displayedComponents: .hourAndMinute
) )
.accessibilityIdentifier("toTimePicker") .accessibilityIdentifier("toTimePicker")
@@ -112,7 +112,7 @@ struct RuleEditorView: View {
} }
daysSection daysSection
Section { Section {
Picker("Mode", selection: $draft.selectionMode) { Picker("Mode", selection: $draft.scheduleConfig.selectionMode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode) Text(mode.displayName).tag(mode)
} }
@@ -121,10 +121,14 @@ struct RuleEditorView: View {
.accessibilityIdentifier("selectionModePicker") .accessibilityIdentifier("selectionModePicker")
appListRow appListRow
} header: { } 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) .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: case .timeLimit:
Section { Section {
appListRow appListRow
@@ -133,17 +137,23 @@ struct RuleEditorView: View {
} }
Section { Section {
budgetRow( budgetRow(
value: "\(draft.dailyLimitMinutes)m", value: "\(draft.timeLimitConfig.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper", stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, onIncrement: {
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } draft.timeLimitConfig.dailyLimitMinutes =
min(240, draft.timeLimitConfig.dailyLimitMinutes + 15)
},
onDecrement: {
draft.timeLimitConfig.dailyLimitMinutes =
max(15, draft.timeLimitConfig.dailyLimitMinutes - 15)
}
) )
} header: { } header: {
Text("For this long").textCase(nil) Text("For this long").textCase(nil)
} }
daysSection daysSection
blockUntilSection blockUntilSection
toggleSections hardModeSection
case .openLimit: case .openLimit:
Section { Section {
appListRow appListRow
@@ -152,17 +162,21 @@ struct RuleEditorView: View {
} }
Section { Section {
budgetRow( budgetRow(
value: "\(draft.maxOpens) opens", value: "\(draft.openLimitConfig.maxOpens) opens",
stepperID: "maxOpensStepper", stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, onIncrement: {
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1)
},
onDecrement: {
draft.openLimitConfig.maxOpens = max(1, draft.openLimitConfig.maxOpens - 1)
}
) )
} header: { } header: {
Text("More than").textCase(nil) Text("More than").textCase(nil)
} }
daysSection daysSection
blockUntilSection blockUntilSection
toggleSections hardModeSection
} }
} }
@@ -186,8 +200,8 @@ struct RuleEditorView: View {
} }
} }
@ViewBuilder /// Hard Mode applies to every kind.
private var toggleSections: some View { private var hardModeSection: some View {
Section { Section {
HStack { HStack {
Text("Hard Mode") Text("Hard Mode")
@@ -199,11 +213,15 @@ struct RuleEditorView: View {
} footer: { } footer: {
Text("No unblocks allowed while the rule is blocking.") 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 { Section {
HStack { HStack {
Text("Block Adult Content") Text("Block Adult Content")
Spacer() Spacer()
Toggle("", isOn: $draft.blockAdultContent) Toggle("", isOn: $draft.scheduleConfig.blockAdultContent)
.labelsHidden() .labelsHidden()
.accessibilityIdentifier("adultContentToggle") .accessibilityIdentifier("adultContentToggle")
} }

View File

@@ -19,10 +19,21 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
) )
} }
private var scheduleEnforcement: ScheduleEnforcement {
ScheduleEnforcement(
snapshots: RuleSnapshotStore(),
shields: ManagedSettingsShieldController()
)
}
override func intervalDidStart(for activity: DeviceActivityName) { override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity) super.intervalDidStart(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) { if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
enforcement.handleDayStart(ruleID: ruleID) 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) { if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) {
enforcement.handleOpenSessionEnded(ruleID: ruleID) enforcement.handleOpenSessionEnded(ruleID: ruleID)
DeviceActivityCenter().stopMonitoring([activity]) 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)
} }
} }

View File

@@ -86,10 +86,21 @@ struct AppListModelTests {
@MainActor @MainActor
@Suite("Legacy selection → AppList migration") @Suite("Legacy selection → AppList migration")
struct AppListMigrationTests { 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") @Test("Rules with legacy inline selections get a list named after them")
func createsListsFromLegacySelections() throws { func createsListsFromLegacySelections() throws {
let context = try makeInMemoryContext() 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) context.insert(rule)
try context.save() try context.save()
@@ -108,9 +119,9 @@ struct AppListMigrationTests {
@Test("Rules with identical selections share one list") @Test("Rules with identical selections share one list")
func sharesListForIdenticalSelections() throws { func sharesListForIdenticalSelections() throws {
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2) let first = legacyRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2) let second = legacyRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1) let different = legacyRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
context.insert(first) context.insert(first)
context.insert(second) context.insert(second)
context.insert(different) context.insert(different)
@@ -127,7 +138,7 @@ struct AppListMigrationTests {
@Test("Migration is idempotent and skips selection-less rules") @Test("Migration is idempotent and skips selection-less rules")
func idempotentAndSkipsEmpty() throws { func idempotentAndSkipsEmpty() throws {
let context = try makeInMemoryContext() 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") let empty = BlockingRule(name: "No Apps")
context.insert(legacy) context.insert(legacy)
context.insert(empty) context.insert(empty)
@@ -163,22 +174,24 @@ struct AppListDraftTests {
#expect(other.name == "Other") #expect(other.name == "Other")
} }
@Test("Sanitizing forces Block mode for time- and open-limit rules") @Test("Limit drafts structurally cannot carry a selection mode")
func sanitizedForcesBlockForLimitKinds() { func limitDraftsHaveNoSelectionMode() {
var timeDraft = RuleDraft(kind: .timeLimit) // The sum type makes Block / Allow Only a Schedule-only option: a limit
timeDraft.selectionMode = .allowOnly // draft's configuration has no selection mode to force back to Block.
#expect(timeDraft.sanitized().selectionMode == .block) #expect(RuleDraft(kind: .timeLimit).configuration.scheduleConfig == nil)
#expect(RuleDraft(kind: .openLimit).configuration.scheduleConfig == nil)
var openDraft = RuleDraft(kind: .openLimit) // And the rule built from a limit draft is always Block.
openDraft.selectionMode = .allowOnly let rule = BlockingRule(
#expect(openDraft.sanitized().selectionMode == .block) name: "Time Keeper", configuration: RuleDraft(kind: .timeLimit).configuration)
#expect(rule.selectionMode == .block)
} }
@Test("Sanitizing keeps Allow Only on schedule rules") @Test("Allow Only survives on schedule rules")
func sanitizedKeepsAllowOnlyForSchedule() { func keepsAllowOnlyForSchedule() {
var draft = RuleDraft(kind: .schedule) var draft = RuleDraft(kind: .schedule)
draft.selectionMode = .allowOnly draft.scheduleConfig.selectionMode = .allowOnly
#expect(draft.sanitized().selectionMode == .allowOnly) #expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly)
} }
} }

View 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)
}
}

View File

@@ -54,7 +54,7 @@ struct RuleEnforcerTests {
func skipsTimeLimitRules() { func skipsTimeLimitRules() {
let shields = MockShieldController() let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields) 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) enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
@@ -104,18 +104,61 @@ struct RuleEnforcerTests {
func forwardsSelectionMode() { func forwardsSelectionMode() {
let shields = MockShieldController() let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields) 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) enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedModes[rule.id] == .allowOnly) #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") @Test("The adult-content flag is forwarded to the shield layer")
func forwardsAdultContentFlag() { func forwardsAdultContentFlag() {
let shields = MockShieldController() let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields) 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") let unfiltered = BlockingRule(name: "Plain")
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc) enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
@@ -124,3 +167,105 @@ struct RuleEnforcerTests {
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false) #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:0017: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])
}
}

View File

@@ -36,8 +36,9 @@ struct RuleModelTests {
@Test("Kind and selection mode survive raw storage, with safe fallbacks") @Test("Kind and selection mode survive raw storage, with safe fallbacks")
func enumRoundTrip() { func enumRoundTrip() {
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly) let rule = BlockingRule(
#expect(rule.kind == .openLimit) name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
#expect(rule.kind == .schedule)
#expect(rule.selectionMode == .allowOnly) #expect(rule.selectionMode == .allowOnly)
rule.kindRaw = "garbage" rule.kindRaw = "garbage"
rule.selectionModeRaw = "garbage" rule.selectionModeRaw = "garbage"
@@ -45,12 +46,43 @@ struct RuleModelTests {
#expect(rule.selectionMode == .block) #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") @Test("Rules persist and fetch through SwiftData")
func persistence() throws { func persistence() throws {
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
let rule = BlockingRule( let rule = BlockingRule(
name: "Deep Sleep", hardMode: true, name: "Deep Sleep",
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60 configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)),
hardMode: true,
days: Weekday.everyDay
) )
context.insert(rule) context.insert(rule)
try context.save() try context.save()
@@ -73,7 +105,7 @@ struct RuleDraftTests {
let draft = RuleDraft(kind: .timeLimit) let draft = RuleDraft(kind: .timeLimit)
#expect(draft.name == "Time Keeper") #expect(draft.name == "Time Keeper")
#expect(draft.kind == .timeLimit) #expect(draft.kind == .timeLimit)
#expect(draft.dailyLimitMinutes == 45) #expect(draft.timeLimitConfig.dailyLimitMinutes == 45)
#expect(draft.days == Weekday.weekdays) #expect(draft.days == Weekday.weekdays)
#expect(!draft.hardMode) #expect(!draft.hardMode)
} }
@@ -87,18 +119,32 @@ struct RuleDraftTests {
var draft = RuleDraft(kind: .schedule) var draft = RuleDraft(kind: .schedule)
draft.name = "Locked In" draft.name = "Locked In"
draft.days = Weekday.everyDay draft.days = Weekday.everyDay
draft.startMinutes = 22 * 60 draft.configuration = .schedule(
draft.endMinutes = 6 * 60 ScheduleConfig(
startMinutes: 22 * 60, endMinutes: 6 * 60,
selectionMode: .allowOnly, blockAdultContent: true))
draft.hardMode = true draft.hardMode = true
draft.blockAdultContent = true
draft.selectionMode = .allowOnly
draft.appList = list draft.appList = list
let rule = draft.insertRule(into: context) let rule = draft.insertRule(into: context)
#expect(rule.blockAdultContent) #expect(rule.blockAdultContent)
#expect(rule.selectionMode == .allowOnly)
#expect(RuleDraft(rule: rule) == draft) #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") @Test("Applying a draft updates an existing rule")
func applyToExisting() throws { func applyToExisting() throws {
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
@@ -135,8 +181,8 @@ struct RuleDraftTests {
) )
let draft = RuleDraft(preset: preset) let draft = RuleDraft(preset: preset)
#expect(draft.name == "Deep Sleep") #expect(draft.name == "Deep Sleep")
#expect(draft.startMinutes == 22 * 60) #expect(draft.scheduleConfig.startMinutes == 22 * 60)
#expect(draft.endMinutes == 6 * 60) #expect(draft.scheduleConfig.endMinutes == 6 * 60)
#expect(draft.kind == .schedule) #expect(draft.kind == .schedule)
} }
} }

View File

@@ -63,7 +63,7 @@ struct RuleStatusTests {
@Test("Time-limit rules are never schedule-active") @Test("Time-limit rules are never schedule-active")
func timeLimitNeverActive() { 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) let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(!status.isActive) #expect(!status.isActive)
if case .upcoming = status {} else { if case .upcoming = status {} else {
@@ -109,4 +109,45 @@ struct RuleStatusTests {
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected") #expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused") #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))
}
} }

View File

@@ -25,12 +25,14 @@ struct RuleSnapshotTests {
let snapshot = RuleSnapshot( let snapshot = RuleSnapshot(
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true, id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block", hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: [2, 3], dailyLimitMinutes: 45, maxOpens: 5, selectionData: Data([1]), dayNumbers: [2, 3], startMinutes: 540, endMinutes: 1020,
pausedUntil: nil dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: nil
) )
store.save([snapshot]) store.save([snapshot])
#expect(store.load() == [snapshot]) #expect(store.load() == [snapshot])
#expect(store.snapshot(for: snapshot.id) == 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) #expect(store.snapshot(for: UUID()) == nil)
} }
@@ -39,7 +41,9 @@ struct RuleSnapshotTests {
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1) let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
let rule = BlockingRule( 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(list)
context.insert(rule) context.insert(rule)
rule.appList = list rule.appList = list
@@ -50,6 +54,29 @@ struct RuleSnapshotTests {
#expect(snapshot.selectionData == Data([9])) #expect(snapshot.selectionData == Data([9]))
#expect(snapshot.days == Weekday.weekends) #expect(snapshot.days == Weekday.weekends)
#expect(snapshot.maxOpens == 3) #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) 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") @Test("Minute checkpoints cover every minute up to the budget")
func minuteEvents() { func minuteEvents() {
let events = MonitoringPlan.minuteEvents(forLimit: 45) let events = MonitoringPlan.minuteEvents(forLimit: 45)
@@ -94,13 +137,32 @@ struct RuleSchedulerTests {
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule { private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1) 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(list)
context.insert(rule) context.insert(rule)
rule.appList = list rule.appList = list
return rule 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") @Test("Enabled limit rules with apps start daily monitoring")
func startsMonitoring() throws { func startsMonitoring() throws {
let (scheduler, monitor, store) = makeScheduler() let (scheduler, monitor, store) = makeScheduler()
@@ -124,20 +186,153 @@ struct RuleSchedulerTests {
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true) #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 { func skipsUnmonitorable() throws {
let (scheduler, monitor, _) = makeScheduler() let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext() let context = try makeInMemoryContext()
let scheduleRule = BlockingRule(name: "Work Time") let applessSchedule = BlockingRule(name: "Work Time")
let appless = BlockingRule(name: "Empty", kind: .timeLimit) let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig()))
context.insert(scheduleRule) context.insert(applessSchedule)
context.insert(appless) context.insert(applessLimit)
scheduler.sync(rules: [scheduleRule, appless]) scheduler.sync(rules: [applessSchedule, applessLimit])
#expect(monitor.monitoredNames.isEmpty) #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") @Test("Monitoring stops when a rule is disabled or removed")
func stopsStaleMonitoring() throws { func stopsStaleMonitoring() throws {
let (scheduler, monitor, _) = makeScheduler() let (scheduler, monitor, _) = makeScheduler()
@@ -178,7 +373,12 @@ struct LimitEnforcementTests {
let shields = MockShieldController() let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults()) let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(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( private func snapshot(
@@ -188,6 +388,7 @@ struct LimitEnforcementTests {
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true, id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block", hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue), selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
startMinutes: 0, endMinutes: 0,
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
) )
} }
@@ -254,6 +455,28 @@ struct LimitEnforcementTests {
#expect(shields.shieldedRuleIDs == [snap.id]) #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") @Test("Session end re-shields the rule for the next open")
func sessionEndReshields() { func sessionEndReshields() {
let (enforcement, shields, _, store) = makeEnforcement() let (enforcement, shields, _, store) = makeEnforcement()
@@ -277,3 +500,111 @@ struct LimitEnforcementTests {
#expect(shields.shieldedRuleIDs.isEmpty) #expect(shields.shieldedRuleIDs.isEmpty)
} }
} }
@MainActor
@Suite("Schedule-window enforcement reactions")
struct ScheduleEnforcementTests {
/// Monday 2025-01-06 10:00 UTC sits inside a 09:0017: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:0006: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)
}
}

View File

@@ -79,13 +79,17 @@ struct UsageStatusTests {
let mondayMorning = date(2025, 1, 6, 10, 0) let mondayMorning = date(2025, 1, 6, 10, 0)
private func timeLimitRule(limit: Int = 45) -> BlockingRule { private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, BlockingRule(
dailyLimitMinutes: limit) name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
} }
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule { private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, BlockingRule(
maxOpens: maxOpens) name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
} }
@Test("A time-limit rule with budget left is not active") @Test("A time-limit rule with budget left is not active")
@@ -153,24 +157,51 @@ struct UsageEnforcementTests {
let ledger = MockUsageLedger() let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger) let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule( 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) ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id]) #expect(shields.shieldedRuleIDs == [rule.id])
#expect(shields.appliedModes[rule.id] == .block) #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") @Test("A time-limit rule with budget left is not shielded")
func leavesUnspentLimitAlone() { func leavesUnspentTimeLimitAlone() {
let shields = MockShieldController() let shields = MockShieldController()
let ledger = MockUsageLedger() let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger) let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule( 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) 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) enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty) #expect(shields.shieldedRuleIDs.isEmpty)
@@ -181,9 +212,13 @@ struct UsageEnforcementTests {
@Suite("Usage display strings") @Suite("Usage display strings")
struct UsageDisplayTests { struct UsageDisplayTests {
let timeRule = BlockingRule( 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( 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") @Test("Time-limit rows show minutes used and remaining")
func timeLimitStrings() { func timeLimitStrings() {

View File

@@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase {
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)") 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 { func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()

View File

@@ -13,6 +13,8 @@ struct LimitEnforcement {
let snapshots: RuleSnapshotStore let snapshots: RuleSnapshotStore
let ledger: UsageLedger let ledger: UsageLedger
let shields: ShieldApplying 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 /// Midnight (or monitoring start): fresh budgets. Open-limit rules are
/// proactively shielded on enabled days so the shield can count opens; /// proactively shielded on enabled days so the shield can count opens;
@@ -62,6 +64,7 @@ struct LimitEnforcement {
func handleOpenSessionEnded( func handleOpenSessionEnded(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) { ) {
sessions.endSession(for: ruleID)
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit, snapshot.kind == .openLimit,
!snapshot.isPaused(at: now), !snapshot.isPaused(at: now),
@@ -84,6 +87,13 @@ struct LimitEnforcement {
guard !snapshot.limitReached(given: usage) else { return false } guard !snapshot.limitReached(given: usage) else { return false }
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar) ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
shields.clearShield(ruleID: ruleID) 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 return true
} }
@@ -91,8 +101,10 @@ struct LimitEnforcement {
shields.applyShield( shields.applyShield(
ruleID: snapshot.id, ruleID: snapshot.id,
selectionData: snapshot.selectionData, selectionData: snapshot.selectionData,
// Limit rules are always Block and never engage the adult-content
// filter those are Schedule-only options.
mode: .block, mode: .block,
blockAdultContent: snapshot.blockAdultContent blockAdultContent: false
) )
} }
} }

View File

@@ -12,6 +12,8 @@ enum MonitoringPlan {
private static let dailyPrefix = "rule-" private static let dailyPrefix = "rule-"
private static let sessionPrefix = "open-session-" private static let sessionPrefix = "open-session-"
private static let minutePrefix = "minutes-" 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 /// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
/// minimum schedule interval, so an "open" lasts at most this long before /// 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))) 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 { static func minuteEventName(for minutes: Int) -> String {
minutePrefix + String(minutes) minutePrefix + String(minutes)
} }

View 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)
}
}

View 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 (95 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
}
}

View File

@@ -18,13 +18,23 @@ struct RuleSnapshot: Codable, Equatable {
var selectionModeRaw: String var selectionModeRaw: String
var selectionData: Data? var selectionData: Data?
var dayNumbers: [Int] 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 dailyLimitMinutes: Int
var maxOpens: Int var maxOpens: Int
var pausedUntil: Date? var pausedUntil: Date?
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule } var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
var selectionMode: SelectionMode { SelectionMode(rawValue: selectionModeRaw) ?? .block }
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) } 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 { func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else { guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
return false 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. /// Persistence for the rule mirror in the shared app-group defaults.
final class RuleSnapshotStore { final class RuleSnapshotStore {
private static let key = "ruleSnapshots" private static let key = "ruleSnapshots"

View 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)
}
}
}

View File

@@ -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 | | **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) | | **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)) - **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 - **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 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. 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"). 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" - **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") - **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): Derived status (drives card/detail UI):
- **Active** → countdown to window end: green pill "6h left" - **Active** → countdown to window end: green pill "6h left"
- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h" - **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. blocked" / "Only these apps are allowed" accordingly.
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing 4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
toggle. toggle.
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* 5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
subtitle "Filter adult websites while this rule is active"; trailing **Schedule rules only**)* — subtitle "Filter adult websites while this rule
toggle. Maps to Screen Time's web-content filter is active"; trailing toggle. Maps to Screen Time's web-content filter
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied (`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
and cleared together with the rule's shield. Surfaces in the rule detail 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** 6. **CTA**
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold - Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
interaction (deliberate friction) that fills, then saves and dismisses to 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 ⌃⌄` 4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
(reset point — e.g. tomorrow/next morning). (reset point — e.g. tomorrow/next morning).
5. **Hard Mode** toggle — same as Schedule. 5. **Hard Mode** toggle — same as Schedule.
6. **Block Adult Content** toggle — same as Schedule. 6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)*
7. **Hold to Commit**.
### 3.6 Rule Editor — Open Limit type ### 3.6 Rule Editor — Open Limit type
Not demoed beyond its card. Spec by analogy: "When I open [apps]" / Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" / "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) ### 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 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 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 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 5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown; rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor. 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". should be deliberate. Editing uses a plain "Done".
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time 7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
(minute granularity is fine). (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) ### 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 ```swift
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit } enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
enum SelectionMode: String, Codable { case block, allowOnly } 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 { @Model final class BlockingRule {
var id: UUID var id: UUID
var name: String var name: String
var kind: RuleKind
var isEnabled: Bool var isEnabled: Bool
var hardMode: Bool var hardMode: Bool
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...) var appList: AppList?
var selectionMode: SelectionMode var days: [Int] // 1...7, Calendar weekday numbers
var selectionData: Data // encoded FamilyActivitySelection var pausedUntil: Date?
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 createdAt: 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 `RuleDraft` (the editors' value-type working copy) carries the same
("active", "starts in Xh", "Xh left") is **derived**, not stored. `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 ### 5.3 View inventory
@@ -373,27 +440,59 @@ enum SelectionMode: String, Codable { case block, allowOnly }
### 5.5 Background enforcement architecture (implemented) ### 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 app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule, 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 - **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
enabled limit rules: one repeating 00:0023:59 activity per rule enabled rules:
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold - **Limit rules** — one repeating 00:0023:59 activity per rule
event per budget minute (`minutes-<k>`) over the rule's app list. (`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 Activities restart only when their configuration changes, because a
restart resets threshold accounting. restart resets threshold accounting.
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start - **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
= midnight reset (open-limit rules re-shield so opens can be counted; = midnight reset for limit rules (open-limit rules re-shield so opens can
time-limit shields clear for the fresh budget); each `minutes-<k>` event be counted; time-limit shields clear for the fresh budget); each
records usage and shields at the budget; a finished `open-session-<uuid>` `minutes-<k>` event records usage and shields at the budget; a finished
one-shot re-shields after a granted open. `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 - **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name. button; other shields show the blocking rule's name.
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press - **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, and starts the spends one open in the ledger, lifts the rule's shield, records the session
~15-minute one-shot session (DeviceActivity's minimum interval). 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 - All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
tested from the app test target. tested from the app test target.