Compare commits

...

2 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
22 changed files with 967 additions and 274 deletions

View File

@@ -13,6 +13,20 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
remoteInfo = OpenAppLock;
};
20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
remoteInfo = OpenAppLock;
};
E20000000000000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
@@ -34,22 +48,24 @@
remoteGlobalIDString = C10000000000000000000003;
remoteInfo = OpenAppLockShieldAction;
};
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
remoteInfo = OpenAppLock;
};
20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
remoteInfo = OpenAppLock;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
E10000000000000000000001 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; };
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -59,6 +75,30 @@
B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000001 /* OpenAppLockMonitor */;
};
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
};
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
isa = PBXFileSystemSynchronizedRootGroup;
@@ -106,46 +146,6 @@
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000001 /* OpenAppLockMonitor */;
};
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
};
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXCopyFilesBuildPhase section */
E10000000000000000000001 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -308,8 +308,8 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A2 /* OpenAppLockMonitor */,
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A2 /* OpenAppLockMonitor */,
);
name = OpenAppLockMonitor;
packageProductDependencies = (
@@ -331,8 +331,8 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
);
name = OpenAppLockShieldConfig;
packageProductDependencies = (
@@ -354,8 +354,8 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
);
name = OpenAppLockShieldAction;
packageProductDependencies = (

View File

@@ -89,10 +89,10 @@ extension BlockingRule {
/// use the plain status label.
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
if case .upcoming = status {
switch kind {
switch configuration {
case .schedule: break
case .timeLimit: return "\(dailyLimitMinutes)m / day"
case .openLimit: return "\(maxOpens) opens / day"
case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day"
case .openLimit(let config): return "\(config.maxOpens) opens / day"
}
}
return status.label(relativeTo: now)
@@ -109,10 +109,10 @@ extension BlockingRule {
/// Whether the given usage exhausts this rule's daily budget.
/// Always false for schedule rules they block by the clock.
func limitReached(given usage: RuleUsage) -> Bool {
switch kind {
switch configuration {
case .schedule: false
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
case .openLimit: usage.opensUsed >= maxOpens
case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes
case .openLimit(let config): usage.opensUsed >= config.maxOpens
}
}
}

View File

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

View File

@@ -8,6 +8,13 @@ import SwiftData
/// A recurring screen-time blocking rule.
///
/// The kind-specific options live in a `RuleConfiguration` sum type, exposed
/// through the computed `configuration` bridge below. The raw per-kind columns
/// (`startMinutes`, `selectionModeRaw`, `dailyLimitMinutes`, ) are persistence
/// detail: they are read and written *only* via `configuration`, which keeps a
/// rule from ever carrying another kind's options (e.g. a Time Limit rule can
/// never hold Block Adult Content see `applyConfiguration`).
///
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
/// is independent of time zones at creation. A window whose end is at or before
/// its start (e.g. 22:00 06:00) crosses midnight into the following day.
@@ -19,10 +26,6 @@ final class BlockingRule {
var isEnabled: Bool
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
var hardMode: Bool
/// Engage Screen Time's adult-website filter while this rule is blocking.
/// Inline default so existing stores migrate cleanly.
var blockAdultContent: Bool = false
var selectionModeRaw: String
/// The reusable app list this rule blocks (or allows, in Allow Only mode).
///
/// Deliberately not an `init` parameter: SwiftData relationship properties
@@ -36,51 +39,99 @@ final class BlockingRule {
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
var selectionCount: Int
var dayNumbers: [Int]
var startMinutes: Int
var endMinutes: Int
/// Daily usage budget for `.timeLimit` rules.
var dailyLimitMinutes: Int
/// Daily open budget for `.openLimit` rules.
var maxOpens: Int
/// When set, the rule's current window is suspended (user tapped Unblock).
/// Cleared automatically once the date passes; never set while Hard Mode is active.
var pausedUntil: Date?
var createdAt: Date
// MARK: Raw per-kind storage (access via `configuration`)
/// Schedule-window bounds, minutes from midnight. Meaningful for `.schedule`.
var startMinutes: Int
var endMinutes: Int
/// Schedule-only; forced to `.block` for limit kinds by `applyConfiguration`.
var selectionModeRaw: String
/// Schedule-only; forced to `false` for limit kinds by `applyConfiguration`.
var blockAdultContent: Bool
/// Daily usage budget for `.timeLimit` rules.
var dailyLimitMinutes: Int
/// Daily open budget for `.openLimit` rules.
var maxOpens: Int
init(
id: UUID = UUID(),
name: String,
kind: RuleKind = .schedule,
configuration: RuleConfiguration = .schedule(ScheduleConfig()),
isEnabled: Bool = true,
hardMode: Bool = false,
blockAdultContent: Bool = false,
selectionMode: SelectionMode = .block,
selectionData: Data? = nil,
selectionCount: Int = 0,
days: Set<Weekday> = Weekday.weekdays,
startMinutes: Int = 9 * 60,
endMinutes: Int = 17 * 60,
dailyLimitMinutes: Int = 45,
maxOpens: Int = 5,
pausedUntil: Date? = nil,
createdAt: Date = .now
) {
self.id = id
self.name = name
self.kindRaw = kind.rawValue
self.isEnabled = isEnabled
self.hardMode = hardMode
self.blockAdultContent = blockAdultContent
self.selectionModeRaw = selectionMode.rawValue
self.selectionData = selectionData
self.selectionCount = selectionCount
self.appList = nil
self.selectionData = nil
self.selectionCount = 0
self.dayNumbers = days.map(\.rawValue).sorted()
self.startMinutes = startMinutes
self.endMinutes = endMinutes
self.dailyLimitMinutes = dailyLimitMinutes
self.maxOpens = maxOpens
self.pausedUntil = pausedUntil
self.createdAt = createdAt
// Raw per-kind columns start at the reference defaults, then the
// configuration overwrites the ones that apply to its kind.
self.kindRaw = configuration.kind.rawValue
self.startMinutes = 9 * 60
self.endMinutes = 17 * 60
self.selectionModeRaw = SelectionMode.block.rawValue
self.blockAdultContent = false
self.dailyLimitMinutes = 45
self.maxOpens = 5
applyConfiguration(configuration)
}
/// The kind-specific options of this rule. Reading assembles the sum type
/// from the raw columns; writing routes through `applyConfiguration`, which
/// keeps the schedule-only options off limit rules.
var configuration: RuleConfiguration {
get {
switch kind {
case .schedule:
.schedule(
ScheduleConfig(
startMinutes: startMinutes,
endMinutes: endMinutes,
selectionMode: selectionMode,
blockAdultContent: blockAdultContent))
case .timeLimit:
.timeLimit(TimeLimitConfig(dailyLimitMinutes: dailyLimitMinutes))
case .openLimit:
.openLimit(OpenLimitConfig(maxOpens: maxOpens))
}
}
set { applyConfiguration(newValue) }
}
/// Writes a configuration onto the raw columns. Limit kinds explicitly
/// reset the Schedule-only options so a rule can never carry Block / Allow
/// Only or Block Adult Content unless it is a Schedule rule.
private func applyConfiguration(_ configuration: RuleConfiguration) {
kindRaw = configuration.kind.rawValue
switch configuration {
case .schedule(let config):
startMinutes = config.startMinutes
endMinutes = config.endMinutes
selectionModeRaw = config.selectionMode.rawValue
blockAdultContent = config.blockAdultContent
case .timeLimit(let config):
dailyLimitMinutes = config.dailyLimitMinutes
selectionModeRaw = SelectionMode.block.rawValue
blockAdultContent = false
case .openLimit(let config):
maxOpens = config.maxOpens
selectionModeRaw = SelectionMode.block.rawValue
blockAdultContent = false
}
}
var kind: RuleKind {
@@ -88,9 +139,9 @@ final class BlockingRule {
set { kindRaw = newValue.rawValue }
}
/// How this rule interprets its app list. Always `.block` for limit kinds.
var selectionMode: SelectionMode {
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
set { selectionModeRaw = newValue.rawValue }
SelectionMode(rawValue: selectionModeRaw) ?? .block
}
var days: Set<Weekday> {

View File

@@ -9,58 +9,46 @@ import SwiftData
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model. Hashable so it can drive
/// `navigationDestination(item:)`.
///
/// The kind-specific options live in `configuration`, so each editor branch
/// only ever sees the options that belong to its kind (the Schedule editor
/// gets Block/Allow-Only and Block Adult Content; the limit editors do not).
struct RuleDraft: Hashable {
var name: String
var kind: RuleKind
var days: Set<Weekday>
var startMinutes: Int
var endMinutes: Int
var dailyLimitMinutes: Int
var maxOpens: Int
var hardMode: Bool
var blockAdultContent: Bool
var selectionMode: SelectionMode
/// Reference to the persisted list the rule will use. App lists are
/// managed (created/edited) directly by the picker, so the draft only
/// carries the pointer.
var appList: AppList?
var configuration: RuleConfiguration
var kind: RuleKind { configuration.kind }
/// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) {
self.name = kind.defaultRuleName
self.kind = kind
self.days = Weekday.weekdays
self.startMinutes = 9 * 60
self.endMinutes = 17 * 60
self.dailyLimitMinutes = 45
self.maxOpens = 5
self.hardMode = false
self.blockAdultContent = false
self.selectionMode = .block
self.appList = nil
self.configuration = .default(for: kind)
}
init(rule: BlockingRule) {
self.name = rule.name
self.kind = rule.kind
self.days = rule.days
self.startMinutes = rule.startMinutes
self.endMinutes = rule.endMinutes
self.dailyLimitMinutes = rule.dailyLimitMinutes
self.maxOpens = rule.maxOpens
self.hardMode = rule.hardMode
self.blockAdultContent = rule.blockAdultContent
self.selectionMode = rule.selectionMode
self.appList = rule.appList
self.configuration = rule.configuration
}
init(preset: RulePreset) {
self.init(kind: .schedule)
self.name = preset.name
self.days = preset.days
self.startMinutes = preset.startMinutes
self.endMinutes = preset.endMinutes
self.configuration = .schedule(
ScheduleConfig(startMinutes: preset.startMinutes, endMinutes: preset.endMinutes))
}
/// Writes the draft back onto a rule. The rule (and the chosen list) must
@@ -68,15 +56,9 @@ struct RuleDraft: Hashable {
/// assigned between managed models (see `BlockingRule.appList`).
func apply(to rule: BlockingRule) {
rule.name = name
rule.kind = kind
rule.days = days
rule.startMinutes = startMinutes
rule.endMinutes = endMinutes
rule.dailyLimitMinutes = dailyLimitMinutes
rule.maxOpens = maxOpens
rule.hardMode = hardMode
rule.blockAdultContent = blockAdultContent
rule.selectionMode = selectionMode
rule.configuration = configuration
if rule.appList !== appList {
rule.appList = appList
}
@@ -87,26 +69,47 @@ struct RuleDraft: Hashable {
/// ever written on a managed model.
@discardableResult
func insertRule(into context: ModelContext) -> BlockingRule {
let rule = BlockingRule(name: name, kind: kind)
let rule = BlockingRule(name: name, configuration: configuration)
context.insert(rule)
apply(to: rule)
return rule
}
/// Trims the name (falling back to the kind's default when it is empty)
/// and forces Block mode for time- and open-limit rules Allow Only is a
/// schedule-rule concept.
/// Trims the name, falling back to the kind's default when it is empty.
/// (Block / Allow Only no longer needs sanitizing: the sum type makes it
/// impossible for a limit draft to carry a selection mode at all.)
func sanitized() -> RuleDraft {
var copy = self
let trimmed = name.trimmingCharacters(in: .whitespaces)
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
if kind != .schedule {
copy.selectionMode = .block
}
return copy
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
RuleSchedule(
startMinutes: scheduleConfig.startMinutes,
endMinutes: scheduleConfig.endMinutes,
days: days)
}
}
extension RuleDraft {
/// Typed projections of the active configuration, used to bind editor
/// controls. The getter returns kind defaults when the draft is a
/// different kind; each editor only reads the projection for its own kind,
/// and writing through it repackages the configuration to that kind.
var scheduleConfig: ScheduleConfig {
get { configuration.scheduleConfig ?? ScheduleConfig() }
set { configuration = .schedule(newValue) }
}
var timeLimitConfig: TimeLimitConfig {
get { configuration.timeLimitConfig ?? TimeLimitConfig() }
set { configuration = .timeLimit(newValue) }
}
var openLimitConfig: OpenLimitConfig {
get { configuration.openLimitConfig ?? OpenLimitConfig() }
set { configuration = .openLimit(newValue) }
}
}

View File

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

View File

@@ -24,14 +24,19 @@ final class RuleEnforcer {
/// Day-usage source consulted for limit rules; also exposed to views for
/// the Usage section.
let usageReader: UsageReading
/// Granted-open sessions, so a proactively-gated open-limit rule is left
/// un-shielded while the user is inside a session they paid an open for.
private let openSessions: OpenSessionReading
init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil
scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore()
) {
self.shields = shields
self.usageReader = usage
self.scheduler = scheduler
self.openSessions = openSessions
}
/// The day's usage for a rule (nil for schedule rules, which don't track).
@@ -44,25 +49,55 @@ final class RuleEnforcer {
/// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses.
///
/// Each rule shields its *own* `ManagedSettingsStore`, and Screen Time
/// unions shields across stores, so overlapping rules enforce strictly:
/// an app is blocked if *any* covering rule blocks it (see spec §4.8). A
/// rule is shielded when it is actively blocking (a schedule window is
/// open, or a limit budget is spent) *or* when it is an open-limit rule
/// that must gate its apps so opens can be counted.
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
var active: Set<UUID> = []
var blocking: Set<UUID> = []
var shielded: Set<UUID> = []
for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
let usage = usage(for: rule, at: now, calendar: calendar)
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
active.insert(rule.id)
let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
if isBlocking { blocking.insert(rule.id) }
guard isBlocking || shouldGateOpenLimit(rule, at: now, calendar: calendar) else {
continue
}
shielded.insert(rule.id)
shields.applyShield(
ruleID: rule.id,
selectionData: rule.appList?.selectionData,
// Allow Only is a schedule-rule concept; limit blocks always Block.
mode: rule.kind == .schedule ? rule.selectionMode : .block,
// Allow Only and Block Adult Content are Schedule-only options;
// the model already forces .block / false on limit rules, so we
// can forward the rule's values directly.
mode: rule.selectionMode,
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
shields.clearShields(except: shielded)
// "Blocked Apps" lists only rules whose budget/window is spent not the
// proactive open-limit gate, which surfaces under "Usage" instead.
blockingRuleIDs = blocking
scheduler?.sync(rules: rules, at: now)
}
/// Whether an open-limit rule should carry its proactive gate right now:
/// enabled, scheduled today, not unblocked, and not inside a granted open
/// session (which would otherwise be cut short). Mirrors
/// `LimitEnforcement.handleDayStart` so the foreground and background agree.
private func shouldGateOpenLimit(
_ rule: BlockingRule, at now: Date, calendar: Calendar
) -> Bool {
rule.kind == .openLimit
&& rule.isEnabled
&& rule.pausedUntil == nil
&& rule.isScheduledToday(at: now, calendar: calendar)
&& !openSessions.hasActiveSession(for: rule.id, at: now)
}
}

View File

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

View File

@@ -100,26 +100,25 @@ struct RuleDetailSheet: View {
@ViewBuilder
private var detailRows: some View {
switch rule.kind {
case .schedule:
switch rule.configuration {
case .schedule(let config):
row("During this time", rule.schedule.timeRangeLabel)
row("On these days", rule.days.summary)
row(rule.selectionMode.displayName, appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row(config.selectionMode.displayName, appCountLabel)
// Adult websites is a Schedule-only option (see spec §1).
row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
case .timeLimit(let config):
row("When I use", appCountLabel)
row("For this long", "\(rule.dailyLimitMinutes)m daily")
row("For this long", "\(config.dailyLimitMinutes)m daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
case .openLimit(let config):
row("When I open", appCountLabel)
row("More than", "\(rule.maxOpens) opens daily")
row("More than", "\(config.maxOpens) opens daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}

View File

@@ -97,13 +97,13 @@ struct RuleEditorView: View {
Section {
DatePicker(
"From",
selection: timeBinding($draft.startMinutes),
selection: timeBinding($draft.scheduleConfig.startMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("fromTimePicker")
DatePicker(
"To",
selection: timeBinding($draft.endMinutes),
selection: timeBinding($draft.scheduleConfig.endMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("toTimePicker")
@@ -112,7 +112,7 @@ struct RuleEditorView: View {
}
daysSection
Section {
Picker("Mode", selection: $draft.selectionMode) {
Picker("Mode", selection: $draft.scheduleConfig.selectionMode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
@@ -121,10 +121,14 @@ struct RuleEditorView: View {
.accessibilityIdentifier("selectionModePicker")
appListRow
} header: {
Text(draft.selectionMode == .block ? "Apps are blocked" : "Only these apps are allowed")
Text(draft.scheduleConfig.selectionMode == .block
? "Apps are blocked" : "Only these apps are allowed")
.textCase(nil)
}
toggleSections
hardModeSection
// Block Adult Content is a Schedule-only option: a usage budget
// does not pair with a web-content filter (see spec §1).
adultContentSection
case .timeLimit:
Section {
appListRow
@@ -133,17 +137,23 @@ struct RuleEditorView: View {
}
Section {
budgetRow(
value: "\(draft.dailyLimitMinutes)m",
value: "\(draft.timeLimitConfig.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
onIncrement: {
draft.timeLimitConfig.dailyLimitMinutes =
min(240, draft.timeLimitConfig.dailyLimitMinutes + 15)
},
onDecrement: {
draft.timeLimitConfig.dailyLimitMinutes =
max(15, draft.timeLimitConfig.dailyLimitMinutes - 15)
}
)
} header: {
Text("For this long").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
hardModeSection
case .openLimit:
Section {
appListRow
@@ -152,17 +162,21 @@ struct RuleEditorView: View {
}
Section {
budgetRow(
value: "\(draft.maxOpens) opens",
value: "\(draft.openLimitConfig.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
onIncrement: {
draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1)
},
onDecrement: {
draft.openLimitConfig.maxOpens = max(1, draft.openLimitConfig.maxOpens - 1)
}
)
} header: {
Text("More than").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
hardModeSection
}
}
@@ -186,8 +200,8 @@ struct RuleEditorView: View {
}
}
@ViewBuilder
private var toggleSections: some View {
/// Hard Mode applies to every kind.
private var hardModeSection: some View {
Section {
HStack {
Text("Hard Mode")
@@ -199,11 +213,15 @@ struct RuleEditorView: View {
} footer: {
Text("No unblocks allowed while the rule is blocking.")
}
}
/// Schedule-only: filter adult websites while the rule's window is active.
private var adultContentSection: some View {
Section {
HStack {
Text("Block Adult Content")
Spacer()
Toggle("", isOn: $draft.blockAdultContent)
Toggle("", isOn: $draft.scheduleConfig.blockAdultContent)
.labelsHidden()
.accessibilityIdentifier("adultContentToggle")
}

View File

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

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() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
@@ -104,18 +104,61 @@ struct RuleEnforcerTests {
func forwardsSelectionMode() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Focus", selectionMode: .allowOnly)
let rule = BlockingRule(
name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedModes[rule.id] == .allowOnly)
}
@Test("Open-limit rules are proactively shielded while opens remain")
func proactivelyShieldsOpenLimit() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
// 2 of 5 opens spent: budget not reached, so the rule is not "active",
// but its apps must still be gated so the next open can be counted.
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id])
#expect(shields.appliedModes[rule.id] == .block)
// A proactive gate (opens remaining) is not "Blocked Apps"-blocking.
#expect(enforcer.blockingRuleIDs.isEmpty)
}
@Test("A granted open session is left un-shielded, not re-locked")
func respectsGrantedOpenSession() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let sessions = MockOpenSessionStore()
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
// The user spent an open and is mid-session; re-shielding would cut the
// sanctioned ~15-minute session short.
sessions.activeRuleIDs = [rule.id]
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("The adult-content flag is forwarded to the shield layer")
func forwardsAdultContentFlag() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let filtered = BlockingRule(name: "Clean Mode", blockAdultContent: true)
let filtered = BlockingRule(
name: "Clean Mode", configuration: .schedule(ScheduleConfig(blockAdultContent: true)))
let unfiltered = BlockingRule(name: "Plain")
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
@@ -124,3 +167,105 @@ struct RuleEnforcerTests {
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
}
}
/// Validates the "strictest enforcement wins" model for rules that target the
/// same apps (spec §4.8). Each rule shields its own store and Screen Time
/// unions them, so the unit-level invariant is: every rule that should block
/// applies its own shield, and no rule's shield is suppressed by another.
@MainActor
@Suite("Overlapping rules → strictest enforcement")
struct OverlappingRuleEnforcementTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
}
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
}
@Test("Every rule that should block applies its own shield")
func eachRuleShieldsIndependently() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let schedule = BlockingRule(name: "Work Time") // 09: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")
func enumRoundTrip() {
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly)
#expect(rule.kind == .openLimit)
let rule = BlockingRule(
name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
#expect(rule.kind == .schedule)
#expect(rule.selectionMode == .allowOnly)
rule.kindRaw = "garbage"
rule.selectionModeRaw = "garbage"
@@ -45,12 +46,43 @@ struct RuleModelTests {
#expect(rule.selectionMode == .block)
}
@Test("Limit kinds can never carry Schedule-only options")
func limitKindsHaveNoScheduleOptions() {
let timeLimit = BlockingRule(
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)))
#expect(timeLimit.kind == .timeLimit)
#expect(timeLimit.selectionMode == .block)
#expect(!timeLimit.blockAdultContent)
#expect(timeLimit.dailyLimitMinutes == 30)
let openLimit = BlockingRule(
name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 3)))
#expect(openLimit.kind == .openLimit)
#expect(openLimit.selectionMode == .block)
#expect(!openLimit.blockAdultContent)
#expect(openLimit.maxOpens == 3)
}
@Test("Configuration round-trips through the model's raw storage")
func configurationRoundTrip() {
let config = RuleConfiguration.schedule(
ScheduleConfig(
startMinutes: 22 * 60, endMinutes: 6 * 60,
selectionMode: .allowOnly, blockAdultContent: true))
let rule = BlockingRule(name: "Deep Sleep", configuration: config)
#expect(rule.configuration == config)
#expect(rule.startMinutes == 22 * 60)
#expect(rule.blockAdultContent)
}
@Test("Rules persist and fetch through SwiftData")
func persistence() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: "Deep Sleep", hardMode: true,
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
name: "Deep Sleep",
configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)),
hardMode: true,
days: Weekday.everyDay
)
context.insert(rule)
try context.save()
@@ -73,7 +105,7 @@ struct RuleDraftTests {
let draft = RuleDraft(kind: .timeLimit)
#expect(draft.name == "Time Keeper")
#expect(draft.kind == .timeLimit)
#expect(draft.dailyLimitMinutes == 45)
#expect(draft.timeLimitConfig.dailyLimitMinutes == 45)
#expect(draft.days == Weekday.weekdays)
#expect(!draft.hardMode)
}
@@ -87,18 +119,32 @@ struct RuleDraftTests {
var draft = RuleDraft(kind: .schedule)
draft.name = "Locked In"
draft.days = Weekday.everyDay
draft.startMinutes = 22 * 60
draft.endMinutes = 6 * 60
draft.configuration = .schedule(
ScheduleConfig(
startMinutes: 22 * 60, endMinutes: 6 * 60,
selectionMode: .allowOnly, blockAdultContent: true))
draft.hardMode = true
draft.blockAdultContent = true
draft.selectionMode = .allowOnly
draft.appList = list
let rule = draft.insertRule(into: context)
#expect(rule.blockAdultContent)
#expect(rule.selectionMode == .allowOnly)
#expect(RuleDraft(rule: rule) == draft)
}
@Test("A limit draft cannot carry Schedule-only options")
func limitDraftHasNoScheduleOptions() {
let draft = RuleDraft(kind: .openLimit)
// The configuration is an open-limit case, so there is structurally no
// selection mode or adult-content flag to set.
#expect(draft.configuration.scheduleConfig == nil)
#expect(draft.openLimitConfig.maxOpens == 5)
let rule = BlockingRule(name: draft.name, configuration: draft.configuration)
#expect(!rule.blockAdultContent)
#expect(rule.selectionMode == .block)
}
@Test("Applying a draft updates an existing rule")
func applyToExisting() throws {
let context = try makeInMemoryContext()
@@ -135,8 +181,8 @@ struct RuleDraftTests {
)
let draft = RuleDraft(preset: preset)
#expect(draft.name == "Deep Sleep")
#expect(draft.startMinutes == 22 * 60)
#expect(draft.endMinutes == 6 * 60)
#expect(draft.scheduleConfig.startMinutes == 22 * 60)
#expect(draft.scheduleConfig.endMinutes == 6 * 60)
#expect(draft.kind == .schedule)
}
}

View File

@@ -63,7 +63,7 @@ struct RuleStatusTests {
@Test("Time-limit rules are never schedule-active")
func timeLimitNeverActive() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
@@ -116,7 +116,8 @@ struct RuleStatusTests {
/// 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", kind: .timeLimit, dailyLimitMinutes: 15)
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")
@@ -124,7 +125,8 @@ struct RuleStatusTests {
@Test("Idle open-limit rule shows its daily opens budget")
func openLimitDisplayLabel() {
let rule = BlockingRule(name: "Gate Keeper", kind: .openLimit, maxOpens: 5)
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")
@@ -140,7 +142,8 @@ struct RuleStatusTests {
@Test("A spent time-limit budget still shows the blocked countdown")
func timeLimitBlockingDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15)
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)

View File

@@ -41,7 +41,9 @@ struct RuleSnapshotTests {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
let rule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.weekends, maxOpens: 3)
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 3)),
days: Weekday.weekends)
context.insert(list)
context.insert(rule)
rule.appList = list
@@ -135,7 +137,8 @@ struct RuleSchedulerTests {
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
let context = try makeInMemoryContext()
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1)
let rule = BlockingRule(name: name, kind: kind, days: Weekday.everyDay)
let rule = BlockingRule(
name: name, configuration: .default(for: kind), days: Weekday.everyDay)
context.insert(list)
context.insert(rule)
rule.appList = list
@@ -148,7 +151,9 @@ struct RuleSchedulerTests {
) throws -> BlockingRule {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: name, kind: .schedule, days: days, startMinutes: start, endMinutes: end)
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)
@@ -186,7 +191,7 @@ struct RuleSchedulerTests {
let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext()
let applessSchedule = BlockingRule(name: "Work Time")
let applessLimit = BlockingRule(name: "Empty", kind: .timeLimit)
let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig()))
context.insert(applessSchedule)
context.insert(applessLimit)
@@ -368,7 +373,12 @@ struct LimitEnforcementTests {
let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(defaults: freshDefaults())
return (LimitEnforcement(snapshots: store, ledger: ledger, shields: shields), shields, ledger, store)
// Isolated session store so granted-open writes don't touch the app group.
return (
LimitEnforcement(
snapshots: store, ledger: ledger, shields: shields,
sessions: OpenSessionStore(defaults: freshDefaults())),
shields, ledger, store)
}
private func snapshot(
@@ -445,6 +455,28 @@ struct LimitEnforcementTests {
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("Granting an open records a session marker; ending it clears it")
func openSessionMarkerLifecycle() {
let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(defaults: freshDefaults())
let sessions = OpenSessionStore(defaults: freshDefaults())
let enforcement = LimitEnforcement(
snapshots: store, ledger: ledger, shields: shields, sessions: sessions)
let snap = snapshot(kind: .openLimit, maxOpens: 3)
store.save([snap])
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
#expect(enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
// The granted ~15-minute session is now recorded and live...
#expect(sessions.hasActiveSession(for: snap.id, at: monday))
// ...but expires after the session length.
#expect(!sessions.hasActiveSession(for: snap.id, at: date(2025, 1, 6, 10, 17)))
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
#expect(!sessions.hasActiveSession(for: snap.id, at: monday))
}
@Test("Session end re-shields the rule for the next open")
func sessionEndReshields() {
let (enforcement, shields, _, store) = makeEnforcement()

View File

@@ -79,13 +79,17 @@ struct UsageStatusTests {
let mondayMorning = date(2025, 1, 6, 10, 0)
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
dailyLimitMinutes: limit)
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
}
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
maxOpens: maxOpens)
BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
}
@Test("A time-limit rule with budget left is not active")
@@ -153,24 +157,51 @@ struct UsageEnforcementTests {
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id])
#expect(shields.appliedModes[rule.id] == .block)
// Limit rules never engage the adult-content filter (Schedule-only).
#expect(shields.appliedAdultContentFlags[rule.id] == false)
}
@Test("A limit rule with budget left is not shielded")
func leavesUnspentLimitAlone() {
@Test("A time-limit rule with budget left is not shielded")
func leavesUnspentTimeLimitAlone() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 20)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
// Time limits let the OS meter usage on the unshielded app, so nothing
// is shielded until the budget is spent. (Open limits differ the
// shield is the meter, so they are gated proactively; see
// RuleEnforcerTests.proactivelyShieldsOpenLimit.)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("An open-limit rule not scheduled today is not gated")
func leavesOffDayOpenLimitAlone() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.weekends)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
// mondayMorning is a weekday, so the weekend-only rule does not gate.
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
@@ -181,9 +212,13 @@ struct UsageEnforcementTests {
@Suite("Usage display strings")
struct UsageDisplayTests {
let timeRule = BlockingRule(
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
let openRule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
@Test("Time-limit rows show minutes used and remaining")
func timeLimitStrings() {

View File

@@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase {
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
}
/// Block Adult Content is a Schedule-only option: the Time Limit editor must
/// not offer the toggle, and the rule's detail must not show the row.
func testTimeLimitOmitsAdultContent() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
// Scroll to the bottom of the form, where Hard Mode lives. The adult
// toggle (which would sit beside it on a Schedule rule) is absent.
app.staticTexts["ruleEditorTitle"].waitToAppear()
app.swipeUp()
app.switches["hardModeToggle"].waitToAppear()
XCTAssertFalse(
app.switches["adultContentToggle"].exists,
"Time-limit rules must not offer Block Adult Content"
)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-Time Keeper"].waitToAppear().tap()
app.element("detailRuleName").waitToAppear()
XCTAssertFalse(
app.element("detailRow-Adult websites").exists,
"Time-limit detail must not show the Adult websites row"
)
}
func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()

View File

@@ -13,6 +13,8 @@ struct LimitEnforcement {
let snapshots: RuleSnapshotStore
let ledger: UsageLedger
let shields: ShieldApplying
/// Granted-open session bookkeeping shared with the foreground enforcer.
var sessions = OpenSessionStore()
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
/// proactively shielded on enabled days so the shield can count opens;
@@ -62,6 +64,7 @@ struct LimitEnforcement {
func handleOpenSessionEnded(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
sessions.endSession(for: ruleID)
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now),
@@ -84,6 +87,13 @@ struct LimitEnforcement {
guard !snapshot.limitReached(given: usage) else { return false }
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
shields.clearShield(ruleID: ruleID)
// Mark the session so neither enforcement path re-shields the app until
// it ends (+1 minute matches the one-shot activity's padding).
if let expiry = calendar.date(
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
{
sessions.startSession(for: ruleID, until: expiry)
}
return true
}
@@ -91,8 +101,10 @@ struct LimitEnforcement {
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
// Limit rules are always Block and never engage the adult-content
// filter those are Schedule-only options.
mode: .block,
blockAdultContent: snapshot.blockAdultContent
blockAdultContent: false
)
}
}

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

@@ -19,7 +19,7 @@ offered (Opal's "New Rule" sheet):
| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point |
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) |
Common attributes across all types:
**Common attributes** — present for *every* rule kind:
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
@@ -28,14 +28,26 @@ Common attributes across all types:
independently of any rule. When editing a rule the user picks an existing
list or creates a new one; editing a list affects every rule that uses it.
Deleting a list detaches it from its rules (they fall back to "no apps").
- **Selection mode** — **Block** (block the list) or **Allow Only** (block
everything except the list). The mode belongs to the *rule*, not the list.
Only **Schedule** rules offer the choice; Time Limit and Open Limit rules
are always Block (a usage budget over "everything except X" is not
meaningful, and drafts of those kinds are sanitized back to Block).
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
**Per-kind options** — each kind carries only the options that make sense for
it. The data model expresses this as a sum type (`RuleConfiguration`, see §5.2)
so a kind structurally cannot hold another kind's options:
- **Schedule** — a recurring **time window** (`From`/`To`, may cross midnight),
a **Selection mode** (**Block** the list, or **Allow Only** = block
everything except it; the mode belongs to the *rule*, not the list), and
**Block Adult Content** (engage Screen Time's adult-website filter while the
window is active).
- **Time Limit** — a **daily minutes budget**.
- **Open Limit** — a **daily opens budget**.
Selection mode and Block Adult Content are **Schedule-only**: a usage budget
over "everything except X" is not meaningful, and engaging a web-content
filter when a *usage* budget is spent does not fit the feature. Time Limit and
Open Limit rules are always Block and never touch the adult-content filter.
Derived status (drives card/detail UI):
- **Active** → countdown to window end: green pill "6h left"
- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h"
@@ -174,12 +186,13 @@ Sections (each an inset rounded group with a small icon + caption header):
blocked" / "Only these apps are allowed" accordingly.
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
toggle.
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)*
subtitle "Filter adult websites while this rule is active"; trailing
toggle. Maps to Screen Time's web-content filter
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
**Schedule rules only**)* — subtitle "Filter adult websites while this rule
is active"; trailing toggle. Maps to Screen Time's web-content filter
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
and cleared together with the rule's shield. Surfaces in the rule detail
as an "Adult websites | Blocked/Allowed" row.
as an "Adult websites | Blocked/Allowed" row. Time Limit and Open Limit
editors do **not** offer this toggle (see §1, Per-kind options).
6. **CTA**
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
interaction (deliberate friction) that fills, then saves and dismisses to
@@ -198,14 +211,13 @@ Same chrome (back / title / rename). Sections:
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
(reset point — e.g. tomorrow/next morning).
5. **Hard Mode** toggle — same as Schedule.
6. **Block Adult Content** toggle — same as Schedule.
7. **Hold to Commit**.
6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)*
### 3.6 Rule Editor — Open Limit type
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
Hard Mode / Block Adult Content / Hold to Commit.
Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)*
### 3.7 App Picker (shared component — also used in onboarding & timer)
@@ -276,7 +288,20 @@ Full-height sheet:
work the same with an opens budget; while opens remain, their apps stay
shielded with an "Open" button on the shield (each press spends one open
and lifts the shield for up to 15 minutes — the DeviceActivity minimum
interval).
interval). Because the shield is what *counts* opens, an enabled open-limit
rule scheduled today is shielded **proactively from the start of the day,
even before any opens are spent** — by *both* the background
(`LimitEnforcement.handleDayStart`) and the foreground
(`RuleEnforcer.refresh`) paths, so a freshly created open-limit rule gates
its apps immediately and the gate survives the app being foregrounded. The
one exception is a **granted "Open" session**: pressing Open lifts the
shield for ~15 minutes, recorded as an expiry in the shared
`OpenSessionStore`; while that session is live, neither path re-shields the
rule (so the sanctioned session is never cut short), and the monitor
re-shields when the session's one-shot activity ends. Unlike a *spent*
budget, this proactive gate does **not** put the rule in "Blocked Apps"
(which lists only rules whose budget is exhausted); it shows under "Usage"
with its remaining opens.
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.
@@ -285,6 +310,26 @@ Full-height sheet:
should be deliberate. Editing uses a plain "Done".
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
(minute granularity is fine).
8. **Overlapping rules — strictest enforcement wins.** When several rules
target the same app, the app is blocked if **any** of them is currently
blocking it; rules never cancel each other out. This is structural rather
than a resolved decision: each rule owns its own `ManagedSettingsStore`
(`rule-<uuid>`), Screen Time **unions** shields across all stores, and a
rule only ever writes/clears *its own* store. Consequences:
- An open-limit and a time-limit rule on the same app each block via their
own store, so whichever's budget is spent **first** blocks the app,
regardless of the other's remaining budget.
- An **Allow-Only** schedule cannot punch a hole for an app that another
rule blocks: `.all(except:)` is itself a *shield* directive ("block
everything except these"), not a whitelist that lifts other stores'
shields. So if a schedule "allows" an app but a time limit blocks it, the
time-limit block stands.
- A soft **unblock** pauses only the one rule it was invoked on; other rules
blocking the same app keep it blocked.
There is deliberately **no** central merge of selections into a single
shield set — such a merge would be the only place a block could be
accidentally dropped.
---
@@ -309,36 +354,58 @@ Full-height sheet:
### 5.2 Data model (SwiftData)
Replace the template `AppListProfile` with:
The domain currency is a **sum type** so a rule can only hold the options that
belong to its kind — illegal states (e.g. an Open Limit rule with a time
window, or a Time Limit rule with Block Adult Content) are unrepresentable:
```swift
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
enum SelectionMode: String, Codable { case block, allowOnly }
enum RuleConfiguration: Hashable, Sendable {
case schedule(ScheduleConfig)
case timeLimit(TimeLimitConfig)
case openLimit(OpenLimitConfig)
var kind: RuleKind { }
}
struct ScheduleConfig: Hashable, Sendable { // Schedule-only options
var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00
var endMinutes: Int // may be start (crosses midnight)
var selectionMode: SelectionMode
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
}
struct TimeLimitConfig: Hashable, Sendable { var dailyLimitMinutes: Int }
struct OpenLimitConfig: Hashable, Sendable { var maxOpens: Int }
```
The kind-common attributes (`name`, `days`, `hardMode`, `isEnabled`,
`appList`, `pausedUntil`) live alongside the configuration:
```swift
@Model final class BlockingRule {
var id: UUID
var name: String
var kind: RuleKind
var isEnabled: Bool
var hardMode: Bool
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
var selectionMode: SelectionMode
var selectionData: Data // encoded FamilyActivitySelection
var days: [Int] // 1...7, Calendar weekday numbers
// schedule
var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00
var endMinutes: Int // may be < start (crosses midnight)
// time limit
var dailyLimitMinutes: Int?
var resetPoint: String? // "tomorrow" | "nextMorning"
// open limit
var maxOpens: Int?
var appList: AppList?
var days: [Int] // 1...7, Calendar weekday numbers
var pausedUntil: Date?
var createdAt: Date
// The kind-specific options, exposed as a computed bridge over the
// model's raw stored columns:
var configuration: RuleConfiguration { get set }
var kind: RuleKind { configuration.kind }
}
```
`FamilyActivitySelection` is `Codable` → store as `Data`. Status
("active", "starts in Xh", "Xh left") is **derived**, not stored.
`RuleDraft` (the editors' value-type working copy) carries the same
`configuration` + common fields, so each editor only renders its kind's
options. `BlockingRule` persists the configuration as flat columns and the
cross-process `RuleSnapshot` mirror keeps its flat wire shape; both are
read/written exclusively through the sum type. `FamilyActivitySelection` is
`Codable` → stored on the `AppList` as `Data`. Status ("active", "starts in
Xh", "Xh left") is **derived**, not stored.
### 5.3 View inventory
@@ -373,10 +440,11 @@ enum SelectionMode: String, Codable { case block, allowOnly }
### 5.5 Background enforcement architecture (implemented)
- **App group** `group.dev.bchen.OpenAppLock` shares three stores between the
- **App group** `group.dev.bchen.OpenAppLock` shares four stores between the
app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
per-day minutes/opens), and the shield-store tracking list.
per-day minutes/opens), `OpenSessionStore` (per-rule expiry of a granted
"Open" session), and the shield-store tracking list.
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
enabled rules:
- **Limit rules** — one repeating 00:0023:59 activity per rule
@@ -410,13 +478,21 @@ enum SelectionMode: String, Codable { case block, allowOnly }
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.
net and is the source of truth whenever the app runs. To keep that net
consistent with the background, `refresh` applies the **same** open-limit
proactive gate as `handleDayStart`: an enabled, scheduled-today, un-paused
open-limit rule is shielded even before its budget is spent, *unless* the
`OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name.
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, and starts the
~15-minute one-shot session (DeviceActivity's minimum interval).
spends one open in the ledger, lifts the rule's shield, records the session
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
(DeviceActivity's minimum interval); the monitor clears that record when the
session ends.
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
tested from the app test target.