Compare commits

...

9 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
1652c2f410 feat: enforce time and open limits in the background via Screen Time extensions
- Shared/ layer compiled into the app and three new extension targets:
  rule snapshots in the app group, the usage ledger, monitoring-plan
  naming, and LimitEnforcement (shared, unit-tested event reactions)
- RuleScheduler mirrors rules to the app group and reconciles
  DeviceActivity monitoring: one daily 00:00-23:59 activity per limit
  rule, with a cumulative usage-threshold event per budget minute for
  time limits; activities restart only when their configuration changes
  (a restart resets threshold accounting)
- OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets,
  records usage minutes, shields at the budget, re-shields when a
  granted open session ends
- OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times
  today' with an 'Open (Y left)' secondary button
- OpenAppLockShieldAction: an Open press spends one open, lifts the
  rule's shield, and starts the ~15-minute one-shot session
- extensions are classic NSExtension app extensions (the ExtensionKit
  product type expects an @main entry and made the app fail to install);
  shield-store tracking moved to app-group defaults so the app and
  extensions see one consistent set
- maps iOS 26's new .approvedWithDataAccess authorization status to
  approved (it previously fell through @unknown default to notDetermined)
- shares the OpenAppLock scheme (Xcode dropped the autocreated one when
  targets were added)
2026-06-12 21:06:08 -04:00
8e3b8b70f5 feat: track limit budgets in a usage ledger and surface them in a Usage section
- RuleUsage + UsageLedger: per-rule, per-day minutes/opens in app-group
  defaults (monotonic minutes, incrementing opens, midnight rollover by
  day-keying); MockUsageLedger for tests and seeded UI scenarios
- usage-aware status: a limit rule whose daily budget is spent on an
  enabled day is active (blocking) until next midnight; Hard Mode gating
  and app-list locking honor it; soft unblock pauses until midnight
- RuleEnforcer shields spent limit rules (always Block mode) while the
  app runs
- new Usage section under Blocked Apps: '18m of 45m used today · 27m
  left', '2 of 5 opens today · 3 opens left', 'Blocked until tomorrow'
- new 'limits' seed scenario + UI tests
2026-06-12 20:22:48 -04:00
cb9b2e8950 refactor: two-screen app-list editor with native List layout
Screen 1 is a plain List (name field, the apps the list contains via
FamilyControls token Labels, and an Edit Apps button); screen 2 pushes
Apple's FamilyActivityPicker whose Save applies the selection back.
Documents that the picker silently drops selections without real
FamilyControls authorization (mocked in -ui-testing launches).
2026-06-12 20:15:12 -04:00
93f2eceb3a feat: introduce reusable app lists for rules
- AppList @Model with launch migration from legacy inline selections
  (rules with identical selections share one list; idempotent)
- rules point at one app list; Block/Allow Only belongs to the rule and
  is offered only in the Schedule editor (limit kinds sanitize to Block)
- app-list picker sheet (select / create / edit / delete with in-use
  protection) replaces the per-rule selection sheet
- Hard Mode locks app-list editing while any hard rule is blocking
- SwiftData stability: relationships are only wired between managed
  models, and unit tests share one in-memory container (fresh context +
  data wipe per test) — per-test container creation trapped
  intermittently inside SwiftData
2026-06-12 20:02:35 -04:00
05d48a9f34 fix: use concrete tertiary color for row chevrons inside tinted buttons 2026-06-12 18:43:59 -04:00
58 changed files with 4718 additions and 345 deletions

View File

@@ -10,18 +10,32 @@ feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
``` ```
OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData) OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData)
Models/ BlockingRule (@Model), RuleDraft, RuleKind, Models/ BlockingRule + AppList (@Model), RuleDraft,
Weekday, RulePreset RulePreset
Logic/ Pure, heavily unit-tested: Logic/ Pure, heavily unit-tested:
RuleSchedule (window math, incl. overnight), RuleStatus (derived status + labels, usage-aware),
RuleStatus (derived status + labels), RulePolicy (Hard Mode gating, unblock/pause,
RulePolicy (Hard Mode gating, unblock/pause) app-list lock), UsageDisplay (Usage-section text)
Services/ ScreenTimeAuthorization (FamilyControls behind a Services/ ScreenTimeAuthorization (FamilyControls behind a
protocol + mock), ShieldController (ManagedSettings protocol + mock), RuleEnforcer (rules → shields),
shields + adult-content web filter + mock), RuleScheduler (rules → DeviceActivity monitoring),
RuleEnforcer (active rules → shields), AppListMigration, LaunchConfiguration +
LaunchConfiguration + SampleRules (UI-test harness) SampleRules (UI-test harness)
Views/ Native SwiftUI screens (see docs spec §6) Views/ Native SwiftUI screens (see docs spec §6)
Shared/ Compiled into the app AND all three extensions:
RuleKind, Weekday, RuleSchedule, AppGroup,
UsageLedger (per-day minutes/opens),
RuleSnapshot(+Store) (rule mirror in the app
group), MonitoringPlan (activity/event naming),
LimitEnforcement (shared event reactions),
ShieldController, ShieldLookup
OpenAppLockMonitor/ DeviceActivityMonitor extension: midnight resets,
usage-minute checkpoints → shield at the limit,
open-session expiry
OpenAppLockShieldConfig/ ShieldConfiguration extension: "Opened X of N" +
Open button on open-limit shields
OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open,
lifts the shield, starts the ~15-min session
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
target defaults to MainActor isolation) target defaults to MainActor isolation)
OpenAppLockUITests/ XCUITest flows (see harness below) OpenAppLockUITests/ XCUITest flows (see harness below)
@@ -98,23 +112,51 @@ them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`. `allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.
Gotchas learned the hard way: Gotchas learned the hard way:
- **SwiftData relationships**: never assign a relationship property (e.g.
`rule.appList`) inside a model's `init` or on un-inserted instances —
insert both models into a context first, then wire them.
- **SwiftData container churn**: repeatedly creating `ModelContainer`s for
this schema traps intermittently (EXC_BREAKPOINT inside SwiftData's
configuration setup), which Xcode shows as a test "hang" paused at a
breakpoint. Unit tests must go through `makeInMemoryContext()`
(TestSupport.swift): one shared container per process, fresh context +
data wipe per test.
- Identifiers on SwiftUI containers need `.accessibilityElement(children: - Identifiers on SwiftUI containers need `.accessibilityElement(children:
.combine)` (or a Button/control) to be queryable. .combine)` (or a Button/control) to be queryable.
- List/Form section headers render uppercased unless `.textCase(nil)` — - List/Form section headers render uppercased unless `.textCase(nil)` —
tests assert exact header strings. tests assert exact header strings.
- Inside tinted Button rows, hierarchical `.primary`/`.secondary` foreground - Inside tinted Button rows, hierarchical `.primary`/`.secondary`/`.tertiary`
styles resolve to the tint; use concrete `Color.primary`/`Color.secondary`. foreground styles resolve to the tint (e.g. blue chevrons); use concrete
`Color.primary`/`Color.secondary`/`Color(.tertiaryLabel)`.
- The unblock confirmation dialog is queried via `app.sheets.buttons[...]` - The unblock confirmation dialog is queried via `app.sheets.buttons[...]`
(a bare `buttons["Unblock"]` is ambiguous with the row label). (a bare `buttons["Unblock"]` is ambiguous with the row label).
## Known gaps / next steps ## Known gaps / next steps
- **No DeviceActivityMonitor extension yet**: shields apply/clear only while - **On-device verification of limit enforcement is pending.** The
the app runs (launch, foreground 30s loop). Background window transitions, DeviceActivity monitor + shield extensions and the app group are in place,
and any real enforcement for Time Limit / Open Limit rules (usage but real blocking/usage tracking is only observable on a device (the
thresholds), require adding the extension target + app group. simulator neither tracks usage nor renders custom shields). Verify: time
- Time Limit / Open Limit rules are fully modeled, editable, and displayed, limits accrue in the Usage section and block at the budget; open-limit
but not enforced (see above). apps shield immediately with an "Open (N left)" button; an open lasts
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
- **Schedule-rule background transitions** are now backed by DeviceActivity:
`RuleScheduler` registers a repeating window activity per schedule rule
(`sched-<uuid>`, plus `sched2-<uuid>` for midnight-crossing windows) and the
monitor extension recomputes + applies/clears the shield on interval
start/end. The foreground 30s loop remains as the reconciliation safety net
because interval callbacks are unreliable. On-device verification of the
background transition is still pending (the simulator does not deliver
DeviceActivity callbacks).
- `FamilyActivityPicker` shows few apps on the simulator; fine on device. - `FamilyActivityPicker` shows few apps on the simulator; fine on device.
- `FamilyActivityPicker` **silently ignores selections** (binding never
updates, rows still show checkmarks) unless real FamilyControls
authorization has been granted — in `-ui-testing` launches authorization
is mocked, so picker selections can never be asserted in UI tests. To
verify selection flows manually on the simulator, launch without
`-ui-testing`, complete onboarding, and approve the system Screen Time
prompts ("Allow with Passcode" works on the simulator).
- Distribution (App Store) requires Apple's approval for the Family Controls - Distribution (App Store) requires Apple's approval for the Family Controls
entitlement; development builds work with the dev entitlement. entitlement **for the app and each extension bundle ID**
(`dev.bchen.OpenAppLock`, `.Monitor`, `.ShieldConfig`, `.ShieldAction`);
development builds work with the dev entitlement.

View File

@@ -6,6 +6,12 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenAppLockMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = { 20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@@ -21,14 +27,78 @@
remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D; remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D;
remoteInfo = OpenAppLock; remoteInfo = OpenAppLock;
}; };
E20000000000000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = C10000000000000000000001;
remoteInfo = OpenAppLockMonitor;
};
E20000000000000000000002 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = C10000000000000000000002;
remoteInfo = OpenAppLockShieldConfig;
};
E20000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
proxyType = 1;
remoteGlobalIDString = C10000000000000000000003;
remoteInfo = OpenAppLockShieldAction;
};
/* 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; };
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B10000000000000000000001 /* OpenAppLockMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; };
B10000000000000000000002 /* OpenAppLockShieldConfig.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldConfig.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;
@@ -45,6 +115,35 @@
path = OpenAppLockUITests; path = OpenAppLockUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA00000000000000000000A1 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
AA00000000000000000000A2 /* OpenAppLockMonitor */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */,
);
path = OpenAppLockMonitor;
sourceTree = "<group>";
};
AA00000000000000000000A3 /* OpenAppLockShieldConfig */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */,
);
path = OpenAppLockShieldConfig;
sourceTree = "<group>";
};
AA00000000000000000000A4 /* OpenAppLockShieldAction */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */,
);
path = OpenAppLockShieldAction;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -69,6 +168,27 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D12000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D12000000000000000000002 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D12000000000000000000003 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -76,6 +196,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
20A7EDD02E47B7CD0097608D /* OpenAppLock */, 20A7EDD02E47B7CD0097608D /* OpenAppLock */,
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A2 /* OpenAppLockMonitor */,
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
20A7EDE02E47B7CF0097608D /* OpenAppLockTests */, 20A7EDE02E47B7CF0097608D /* OpenAppLockTests */,
20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */, 20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */,
20A7EDCF2E47B7CD0097608D /* Products */, 20A7EDCF2E47B7CD0097608D /* Products */,
@@ -88,6 +212,9 @@
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */, 20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */,
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */, 20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */,
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */, 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */,
B10000000000000000000001 /* OpenAppLockMonitor.appex */,
B10000000000000000000002 /* OpenAppLockShieldConfig.appex */,
B10000000000000000000003 /* OpenAppLockShieldAction.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -102,13 +229,18 @@
20A7EDCA2E47B7CD0097608D /* Sources */, 20A7EDCA2E47B7CD0097608D /* Sources */,
20A7EDCB2E47B7CD0097608D /* Frameworks */, 20A7EDCB2E47B7CD0097608D /* Frameworks */,
20A7EDCC2E47B7CD0097608D /* Resources */, 20A7EDCC2E47B7CD0097608D /* Resources */,
E10000000000000000000001 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
E30000000000000000000001 /* PBXTargetDependency */,
E30000000000000000000002 /* PBXTargetDependency */,
E30000000000000000000003 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
20A7EDD02E47B7CD0097608D /* OpenAppLock */, 20A7EDD02E47B7CD0097608D /* OpenAppLock */,
AA00000000000000000000A1 /* Shared */,
); );
name = OpenAppLock; name = OpenAppLock;
packageProductDependencies = ( packageProductDependencies = (
@@ -163,6 +295,75 @@
productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */; productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing"; productType = "com.apple.product-type.bundle.ui-testing";
}; };
C10000000000000000000001 /* OpenAppLockMonitor */ = {
isa = PBXNativeTarget;
buildConfigurationList = E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */;
buildPhases = (
D11000000000000000000001 /* Sources */,
D12000000000000000000001 /* Frameworks */,
D13000000000000000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A2 /* OpenAppLockMonitor */,
);
name = OpenAppLockMonitor;
packageProductDependencies = (
);
productName = OpenAppLockMonitor;
productReference = B10000000000000000000001 /* OpenAppLockMonitor.appex */;
productType = "com.apple.product-type.app-extension";
};
C10000000000000000000002 /* OpenAppLockShieldConfig */ = {
isa = PBXNativeTarget;
buildConfigurationList = E40000000000000000000002 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldConfig" */;
buildPhases = (
D11000000000000000000002 /* Sources */,
D12000000000000000000002 /* Frameworks */,
D13000000000000000000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
);
name = OpenAppLockShieldConfig;
packageProductDependencies = (
);
productName = OpenAppLockShieldConfig;
productReference = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */;
productType = "com.apple.product-type.app-extension";
};
C10000000000000000000003 /* OpenAppLockShieldAction */ = {
isa = PBXNativeTarget;
buildConfigurationList = E40000000000000000000003 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldAction" */;
buildPhases = (
D11000000000000000000003 /* Sources */,
D12000000000000000000003 /* Frameworks */,
D13000000000000000000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
);
name = OpenAppLockShieldAction;
packageProductDependencies = (
);
productName = OpenAppLockShieldAction;
productReference = B10000000000000000000003 /* OpenAppLockShieldAction.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -203,6 +404,9 @@
20A7EDCD2E47B7CD0097608D /* OpenAppLock */, 20A7EDCD2E47B7CD0097608D /* OpenAppLock */,
20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */, 20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */,
20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */, 20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */,
C10000000000000000000001 /* OpenAppLockMonitor */,
C10000000000000000000002 /* OpenAppLockShieldConfig */,
C10000000000000000000003 /* OpenAppLockShieldAction */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -229,6 +433,27 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D13000000000000000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D13000000000000000000002 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D13000000000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -253,6 +478,27 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D11000000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D11000000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D11000000000000000000003 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -266,6 +512,21 @@
target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */; target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */;
targetProxy = 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */; targetProxy = 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */;
}; };
E30000000000000000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C10000000000000000000001 /* OpenAppLockMonitor */;
targetProxy = E20000000000000000000001 /* PBXContainerItemProxy */;
};
E30000000000000000000002 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
targetProxy = E20000000000000000000002 /* PBXContainerItemProxy */;
};
E30000000000000000000003 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
targetProxy = E20000000000000000000003 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -569,6 +830,168 @@
}; };
name = Release; name = Release;
}; };
E50000000000000000000011 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockMonitor/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
E50000000000000000000012 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockMonitor/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
E50000000000000000000021 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
E50000000000000000000022 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
E50000000000000000000031 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
E50000000000000000000032 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -608,6 +1031,33 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E50000000000000000000011 /* Debug */,
E50000000000000000000012 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E40000000000000000000002 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldConfig" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E50000000000000000000021 /* Debug */,
E50000000000000000000022 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E40000000000000000000003 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldAction" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E50000000000000000000031 /* Debug */,
E50000000000000000000032 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 20A7EDC62E47B7CD0097608D /* Project object */; rootObject = 20A7EDC62E47B7CD0097608D /* Project object */;

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
BuildableName = "OpenAppLock.app"
BlueprintName = "OpenAppLock"
ReferencedContainer = "container:OpenAppLock.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20A7EDDC2E47B7CF0097608D"
BuildableName = "OpenAppLockTests.xctest"
BlueprintName = "OpenAppLockTests"
ReferencedContainer = "container:OpenAppLock.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20A7EDE62E47B7CF0097608D"
BuildableName = "OpenAppLockUITests.xctest"
BlueprintName = "OpenAppLockUITests"
ReferencedContainer = "container:OpenAppLock.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
BuildableName = "OpenAppLock.app"
BlueprintName = "OpenAppLock"
ReferencedContainer = "container:OpenAppLock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
BuildableName = "OpenAppLock.app"
BlueprintName = "OpenAppLock"
ReferencedContainer = "container:OpenAppLock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -8,58 +8,88 @@ import Foundation
/// Gates every mutation of a rule. This is where Hard Mode is enforced: /// Gates every mutation of a rule. This is where Hard Mode is enforced:
/// while a hard-mode rule is actively blocking, nothing about it can be /// while a hard-mode rule is actively blocking, nothing about it can be
/// weakened until the window ends. /// weakened until the window ends.
///
/// Limit rules block on spent usage rather than the clock, so their gates
/// take the day's `RuleUsage`; passing nil treats them as not blocking.
enum RulePolicy { enum RulePolicy {
/// True while the rule is actively blocking with Hard Mode on. /// True while the rule is actively blocking with Hard Mode on.
static func isHardLocked( static func isHardLocked(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
rule.hardMode && rule.status(at: now, calendar: calendar).isActive rule.hardMode && rule.status(at: now, calendar: calendar, usage: usage).isActive
} }
static func canEdit( static func canEdit(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
static func canDisable( static func canDisable(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
static func canDelete( static func canDelete(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
/// Whether the user may lift the current block early ("Unblock"). /// Whether the user may lift the current block early ("Unblock").
/// Requires an active window and Hard Mode off. /// Requires an active block and Hard Mode off.
static func canUnblock( static func canUnblock(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
rule.status(at: now, calendar: calendar).isActive && !rule.hardMode rule.status(at: now, calendar: calendar, usage: usage).isActive && !rule.hardMode
} }
/// Hard Mode can always be turned on, but never off while the rule is /// Hard Mode can always be turned on, but never off while the rule is
/// actively blocking that is the whole point of a hard block. /// actively blocking that is the whole point of a hard block.
static func canTurnOffHardMode( static func canTurnOffHardMode(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
/// Pauses the rule's current window. Returns false (and changes nothing) /// App lists feed active shields, so while any hard-mode rule is actively
/// when unblocking is not allowed. /// blocking, no list may be edited or deleted changing a list would be a
/// back door out of the hard block. Creating new lists and picking lists
/// for other rules stay allowed; they cannot weaken an active block.
static func canEditAppLists(
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!rules.contains {
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
}
}
/// Pauses the rule's current block. Returns false (and changes nothing)
/// when unblocking is not allowed. Schedule rules re-arm at their next
/// window; limit rules re-arm at midnight with the next day's budget.
@discardableResult @discardableResult
static func unblock( static func unblock(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current _ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
guard canUnblock(rule, at: now, calendar: calendar), guard canUnblock(rule, usage: usage, at: now, calendar: calendar) else { return false }
let window = rule.schedule.activeWindow(containing: now, calendar: calendar) switch rule.kind {
case .schedule:
guard let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
else { return false } else { return false }
rule.pausedUntil = window.end rule.pausedUntil = window.end
case .timeLimit, .openLimit:
guard let midnight = calendar.nextMidnight(after: now) else { return false }
rule.pausedUntil = midnight
}
return true return true
} }
} }

View File

@@ -45,14 +45,25 @@ enum RuleStatus: Equatable, Sendable {
} }
extension BlockingRule { extension BlockingRule {
/// Live status of this rule. Schedule rules derive it from their time window; /// Live status of this rule. Schedule rules derive it from their time
/// time/open-limit rules report upcoming based on their enabled days only /// window. Time/open-limit rules derive it from the day's usage: once the
/// (their blocking is triggered by usage, not the clock). /// budget is spent on an enabled day they are active (blocking) until the
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus { /// next midnight; without usage data they report upcoming.
func status(
at now: Date = .now, calendar: Calendar = .current, usage: RuleUsage? = nil
) -> RuleStatus {
guard isEnabled else { return .disabled } guard isEnabled else { return .disabled }
guard !days.isEmpty else { return .dormant } guard !days.isEmpty else { return .dormant }
guard kind == .schedule else { guard kind == .schedule else {
if let usage, isScheduledToday(at: now, calendar: calendar),
limitReached(given: usage),
let midnight = calendar.nextMidnight(after: now) {
if let pausedUntil, pausedUntil > now {
return .paused(until: min(pausedUntil, midnight))
}
return .active(until: midnight)
}
guard let next = schedule.nextStart(after: now, calendar: calendar) else { guard let next = schedule.nextStart(after: now, calendar: calendar) else {
return .dormant return .dormant
} }
@@ -70,4 +81,46 @@ 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`.
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
return false
}
return days.contains(weekday)
}
/// 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 configuration {
case .schedule: false
case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes
case .openLimit(let config): usage.opensUsed >= config.maxOpens
}
}
}
extension Calendar {
/// The first instant of the day after the one containing `date` the
/// "Tomorrow" reset point for spent limit budgets.
func nextMidnight(after date: Date) -> Date? {
self.date(byAdding: .day, value: 1, to: startOfDay(for: date))
}
} }

View File

@@ -0,0 +1,40 @@
//
// UsageDisplay.swift
// OpenAppLock
//
import Foundation
/// Strings for the home screen's Usage section. Used values clamp to the
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
enum UsageDisplay {
/// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.configuration {
case .schedule:
""
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"
}
}
/// "27m left" / "3 opens left", or the blocked/unblocked state once the
/// budget is spent.
static func remainingLabel(for rule: BlockingRule, usage: RuleUsage, isPaused: Bool) -> String {
guard !rule.limitReached(given: usage) else {
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
}
switch rule.configuration {
case .schedule:
return ""
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

@@ -0,0 +1,47 @@
//
// AppList.swift
// OpenAppLock
//
import Foundation
import SwiftData
/// A named, reusable selection of apps/categories/websites. Rules point at a
/// list, so editing the list affects every rule that uses it. Deleting a list
/// detaches it from its rules (they fall back to "no apps").
@Model
final class AppList {
@Attribute(.unique) var id: UUID
var name: String
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until apps are picked.
var selectionData: Data?
/// Denormalized count of selected apps/categories/domains for display.
var selectionCount: Int
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \BlockingRule.appList)
var rules: [BlockingRule] = []
init(
id: UUID = UUID(),
name: String,
selectionData: Data? = nil,
selectionCount: Int = 0,
createdAt: Date = .now
) {
self.id = id
self.name = name
self.selectionData = selectionData
self.selectionCount = selectionCount
self.createdAt = createdAt
}
/// Whether any rule currently points at this list (guards deletion).
static func isInUse(_ list: AppList, context: ModelContext) -> Bool {
let listID = list.id
let descriptor = FetchDescriptor<BlockingRule>(
predicate: #Predicate { $0.appList?.id == listID }
)
return ((try? context.fetchCount(descriptor)) ?? 0) > 0
}
}

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,60 +26,112 @@ 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. /// The reusable app list this rule blocks (or allows, in Allow Only mode).
/// Inline default so existing stores migrate cleanly. ///
var blockAdultContent: Bool = false /// Deliberately not an `init` parameter: SwiftData relationship properties
var selectionModeRaw: String /// must only be assigned once both models are inserted in a context
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps. /// writing them on unmanaged instances traps intermittently inside
/// SwiftData (EXC_BREAKPOINT on the next insert/save).
var appList: AppList?
/// Legacy inline selection, superseded by `appList`. Kept only so
/// `AppListMigration` can read pre-app-list stores; always nil afterwards.
var selectionData: Data? var selectionData: Data?
/// Denormalized count of selected apps/categories/domains for display. /// 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 {
@@ -80,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

@@ -4,87 +4,80 @@
// //
import Foundation import Foundation
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 /// Reference to the persisted list the rule will use. App lists are
var selectionMode: SelectionMode /// managed (created/edited) directly by the picker, so the draft only
var selectionData: Data? /// carries the pointer.
var selectionCount: Int 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.appList = nil
self.selectionMode = .block self.configuration = .default(for: kind)
self.selectionData = nil
self.selectionCount = 0
} }
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.appList = rule.appList
self.selectionMode = rule.selectionMode self.configuration = rule.configuration
self.selectionData = rule.selectionData
self.selectionCount = rule.selectionCount
} }
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
/// already be inserted in a context: SwiftData relationships may only be
/// 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 {
rule.selectionData = selectionData rule.appList = appList
rule.selectionCount = selectionCount }
} }
func makeRule() -> BlockingRule { /// Creates and inserts a new rule from this draft. The rule is inserted
let rule = BlockingRule(name: name, kind: kind) /// *before* the draft is applied so the app-list relationship is only
/// ever written on a managed model.
@discardableResult
func insertRule(into context: ModelContext) -> BlockingRule {
let rule = BlockingRule(name: name, configuration: configuration)
context.insert(rule)
apply(to: rule) apply(to: rule)
return rule return rule
} }
/// Trims the name and falls back to the kind's default when it is empty, /// Trims the name, falling back to the kind's default when it is empty.
/// so a cleared name field can never produce an unnamed rule. /// (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 { func sanitized() -> RuleDraft {
var copy = self var copy = self
let trimmed = name.trimmingCharacters(in: .whitespaces) let trimmed = name.trimmingCharacters(in: .whitespaces)
@@ -93,6 +86,30 @@ struct RuleDraft: Hashable {
} }
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

@@ -4,5 +4,9 @@
<dict> <dict>
<key>com.apple.developer.family-controls</key> <key>com.apple.developer.family-controls</key>
<true/> <true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.bchen.OpenAppLock</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -21,7 +21,7 @@ struct OpenAppLockApp: App {
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding") UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
} }
let schema = Schema([BlockingRule.self]) let schema = Schema([BlockingRule.self, AppList.self])
let modelConfiguration = ModelConfiguration( let modelConfiguration = ModelConfiguration(
schema: schema, isStoredInMemoryOnly: config.isUITesting schema: schema, isStoredInMemoryOnly: config.isUITesting
) )
@@ -31,9 +31,14 @@ struct OpenAppLockApp: App {
fatalError("Could not create ModelContainer: \(error)") fatalError("Could not create ModelContainer: \(error)")
} }
let usageLedger: UsageReading = config.isUITesting ? MockUsageLedger() : UsageLedger()
if let scenario = config.seedScenario { if let scenario = config.seedScenario {
SampleRules.seed(scenario, into: container.mainContext) SampleRules.seed(
scenario, into: container.mainContext, usage: usageLedger as? MockUsageLedger
)
} }
AppListMigration.run(in: container.mainContext)
let authProvider: AuthorizationProviding = let authProvider: AuthorizationProviding =
config.isUITesting config.isUITesting
@@ -45,7 +50,15 @@ struct OpenAppLockApp: App {
let shields: ShieldApplying = let shields: ShieldApplying =
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController() config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
_enforcer = State(initialValue: RuleEnforcer(shields: shields)) 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,
openSessions: openSessions))
} }
var body: some Scene { var body: some Scene {

View File

@@ -0,0 +1,45 @@
//
// AppListMigration.swift
// OpenAppLock
//
import Foundation
import SwiftData
/// One-time launch migration from the legacy model where every rule carried
/// its own inline `FamilyActivitySelection` to shared, reusable app lists.
///
/// Each rule that still has inline selection data gets a list named after it;
/// rules with byte-identical selections share a single list. The inline copy
/// is cleared afterwards, which also makes the migration idempotent.
enum AppListMigration {
static func run(in context: ModelContext) {
let descriptor = FetchDescriptor<BlockingRule>(
predicate: #Predicate { $0.selectionData != nil }
)
guard let legacyRules = try? context.fetch(descriptor), !legacyRules.isEmpty else {
return
}
var listsBySelection: [Data: AppList] = [:]
for rule in legacyRules.sorted(by: { $0.createdAt < $1.createdAt }) {
guard rule.appList == nil, let selectionData = rule.selectionData else { continue }
let list: AppList
if let existing = listsBySelection[selectionData] {
list = existing
} else {
list = AppList(
name: "\(rule.name) Apps",
selectionData: selectionData,
selectionCount: rule.selectionCount
)
context.insert(list)
listsBySelection[selectionData] = list
}
rule.appList = list
rule.selectionData = nil
rule.selectionCount = 0
}
try? context.save()
}
}

View File

@@ -13,6 +13,10 @@ struct LaunchConfiguration: Equatable {
case standard case standard
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule. /// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
case hardModeActive = "hard-mode-active" case hardModeActive = "hard-mode-active"
/// Limit rules with seeded usage: "Time Keeper" (18m of 45m),
/// "Gate Keeper" (2 of 5 opens), and "Doom Scroll" (budget spent
/// blocked until tomorrow).
case limits
} }
var isUITesting = false var isUITesting = false

View File

@@ -7,40 +7,97 @@ import Foundation
import Observation import Observation
/// Turns the current set of rules into shield state: schedule rules with an /// Turns the current set of rules into shield state: schedule rules with an
/// active, un-paused window are shielded; everything else is cleared. /// active, un-paused window are shielded, and limit rules whose daily budget
/// is spent (per the usage ledger) are shielded until midnight; everything
/// else is cleared.
/// ///
/// Time-limit and open-limit rules need a DeviceActivity monitor extension to /// Background transitions (and usage tracking itself) belong to the
/// react to usage thresholds in the background; they are persisted and shown /// DeviceActivity monitor extension; this keeps shields correct while the
/// in the UI but not yet enforced here. /// app runs.
@Observable @Observable
final class RuleEnforcer { final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = [] private(set) var blockingRuleIDs: Set<UUID> = []
private let shields: ShieldApplying private let shields: ShieldApplying
/// Mirrors rules to the app group and keeps DeviceActivity monitoring in
/// step; nil in UI-test launches.
private let scheduler: RuleScheduler?
/// 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) { init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore()
) {
self.shields = shields 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).
func usage(
for rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> RuleUsage? {
guard rule.kind != .schedule else { return nil }
return usageReader.usage(for: rule.id, onDayContaining: now, calendar: calendar)
} }
/// 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
} }
guard rule.kind == .schedule, let usage = usage(for: rule, at: now, calendar: calendar)
rule.status(at: now, calendar: calendar).isActive let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive
else { continue } if isBlocking { blocking.insert(rule.id) }
active.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.selectionData, selectionData: rule.appList?.selectionData,
// 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, 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)
}
/// 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

@@ -0,0 +1,268 @@
//
// RuleScheduler.swift
// OpenAppLock
//
import DeviceActivity
import FamilyControls
import Foundation
/// Abstracts `DeviceActivityCenter` so scheduling can be unit-tested.
protocol ActivityMonitoring: AnyObject {
/// Starts (or replaces) an always-on, midnight-to-midnight repeating
/// activity. `eventMinutes` maps event names to cumulative usage
/// thresholds (in minutes) over the rule's selection.
func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws
/// Starts (or replaces) a repeating window activity spanning
/// `intervalStartMinutes``intervalEndMinutes` (minutes from midnight),
/// carrying no events used to wake the monitor at a schedule rule's
/// window edges so its shield engages in the background.
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws
func stopMonitoring(names: [String])
var monitoredNames: [String] { get }
}
/// Mirrors rules into the shared snapshot store and reconciles
/// DeviceActivity monitoring with the enabled limit rules: each one gets a
/// daily activity (time limits with one usage checkpoint per budget minute).
/// Activities are only restarted when their configuration changes a
/// restart resets checkpoint accounting to "usage from now on".
final class RuleScheduler {
private static let fingerprintsKey = "monitoringFingerprints"
private let monitor: ActivityMonitoring
private let snapshots: RuleSnapshotStore
private let defaults: UserDefaults
init(
monitor: ActivityMonitoring,
snapshots: RuleSnapshotStore = RuleSnapshotStore(),
defaults: UserDefaults = AppGroup.defaults
) {
self.monitor = monitor
self.snapshots = snapshots
self.defaults = defaults
}
func sync(rules: [BlockingRule], at now: Date = .now) {
snapshots.save(rules.map(RuleSnapshot.init))
var fingerprints = storedFingerprints
var desiredNames: Set<String> = []
for rule in rules {
// A rule must be enabled, have days, and have apps to be monitored.
guard rule.isEnabled, !rule.days.isEmpty,
let selectionData = rule.appList?.selectionData
else { continue }
switch rule.kind {
case .timeLimit, .openLimit:
let name = MonitoringPlan.dailyActivityName(for: rule.id)
desiredNames.insert(name)
let events =
rule.kind == .timeLimit
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
: [:]
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
+ "\(selectionData.hashValue)"
guard needsRestart(name, fingerprint, in: fingerprints) else { continue }
start(name: name) {
try monitor.startDailyMonitoring(
name: name, selectionData: selectionData, eventMinutes: events)
} onStarted: { fingerprints[name] = fingerprint }
case .schedule:
// A window activity encodes only its interval (no events, no
// selection); days, mode and apps are read fresh by reconcile()
// at each callback, so only a start/end change needs a restart.
let fingerprint = "schedule|\(rule.startMinutes)|\(rule.endMinutes)"
for window in scheduleWindows(for: rule) {
desiredNames.insert(window.name)
guard needsRestart(window.name, fingerprint, in: fingerprints) else { continue }
start(name: window.name) {
try monitor.startWindowMonitoring(
name: window.name,
intervalStartMinutes: window.start,
intervalEndMinutes: window.end)
} onStarted: { fingerprints[window.name] = fingerprint }
}
}
}
let stale = monitor.monitoredNames.filter {
(MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil
|| MonitoringPlan.ruleID(fromScheduleWindowName: $0) != nil)
&& !desiredNames.contains($0)
}
if !stale.isEmpty {
monitor.stopMonitoring(names: stale)
for name in stale {
fingerprints[name] = nil
}
}
storedFingerprints = fingerprints
}
/// Whether `name` should be (re)started: its configuration changed, or the
/// system isn't actually monitoring it (e.g. a prior start threw).
private func needsRestart(
_ name: String, _ fingerprint: String, in fingerprints: [String: String]
) -> Bool {
fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
}
/// Runs a best-effort `startMonitoring` call. Monitoring throws on the
/// simulator, when authorization is missing, and when the activity cap or
/// minimum interval is exceeded; the next sync retries.
private func start(name: String, _ body: () throws -> Void, onStarted: () -> Void) {
do {
try body()
onStarted()
} catch {
// Best-effort; the foreground reconciliation loop is the safety net.
}
}
/// The DeviceActivity window activities for a schedule rule. Normal windows
/// map to one activity; midnight-crossing windows split into an evening half
/// (to 23:59) and a morning half (from 00:00); a `start == end` window is
/// treated as all-day.
private func scheduleWindows(for rule: BlockingRule) -> [(name: String, start: Int, end: Int)] {
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
let endOfDay = 24 * 60 - 1
let start = rule.startMinutes
let end = rule.endMinutes
if start < end {
return [(name: primary, start: start, end: end)]
}
if start == end {
return [(name: primary, start: 0, end: endOfDay)]
}
var windows = [(name: primary, start: start, end: endOfDay)]
if end > 0 {
windows.append((name: late, start: 0, end: end))
}
return windows
}
private var storedFingerprints: [String: String] {
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
}
}
extension RuleSnapshot {
init(rule: BlockingRule) {
self.init(
id: rule.id,
name: rule.name,
kindRaw: rule.kindRaw,
isEnabled: rule.isEnabled,
hardMode: rule.hardMode,
blockAdultContent: rule.blockAdultContent,
selectionModeRaw: rule.selectionModeRaw,
selectionData: rule.appList?.selectionData,
dayNumbers: rule.dayNumbers,
startMinutes: rule.startMinutes,
endMinutes: rule.endMinutes,
dailyLimitMinutes: rule.dailyLimitMinutes,
maxOpens: rule.maxOpens,
pausedUntil: rule.pausedUntil
)
}
}
/// Real DeviceActivity scheduling. Each daily activity repeats from midnight
/// to 23:59 with usage-threshold events over the rule's selection.
final class DeviceActivityCenterMonitor: ActivityMonitoring {
private let center = DeviceActivityCenter()
var monitoredNames: [String] {
center.activities.map(\.rawValue)
}
func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws {
let selection = AppSelectionCodec.decode(selectionData)
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 0, minute: 0),
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: true
)
let events = Dictionary(
uniqueKeysWithValues: eventMinutes.map { eventName, minutes in
(
DeviceActivityEvent.Name(eventName),
DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: minutes)
)
)
}
)
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
}
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws {
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(
hour: intervalStartMinutes / 60, minute: intervalStartMinutes % 60),
intervalEnd: DateComponents(
hour: intervalEndMinutes / 60, minute: intervalEndMinutes % 60),
repeats: true
)
try center.startMonitoring(DeviceActivityName(name), during: schedule)
}
func stopMonitoring(names: [String]) {
center.stopMonitoring(names.map { DeviceActivityName($0) })
}
}
/// Records scheduling calls for tests.
final class MockActivityMonitor: ActivityMonitoring {
private(set) var startedEvents: [String: [String: Int]] = [:]
private(set) var startedWindows: [String: (start: Int, end: Int)] = [:]
private(set) var startCallCount = 0
private(set) var monitoredNames: [String] = []
func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws {
startCallCount += 1
startedEvents[name] = eventMinutes
if !monitoredNames.contains(name) {
monitoredNames.append(name)
}
}
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws {
startCallCount += 1
startedWindows[name] = (intervalStartMinutes, intervalEndMinutes)
if !monitoredNames.contains(name) {
monitoredNames.append(name)
}
}
func stopMonitoring(names: [String]) {
monitoredNames.removeAll(where: names.contains)
for name in names {
startedEvents[name] = nil
startedWindows[name] = nil
}
}
}

View File

@@ -12,16 +12,50 @@ enum SampleRules {
static func seed( static func seed(
_ scenario: LaunchConfiguration.SeedScenario, _ scenario: LaunchConfiguration.SeedScenario,
into context: ModelContext, into context: ModelContext,
usage: MockUsageLedger? = nil,
now: Date = .now, now: Date = .now,
calendar: Calendar = .current calendar: Calendar = .current
) { ) {
// Shared list so seeded rules demonstrate the app-list UI. The count
// is display-only; UI tests never decode real tokens.
let distractions = AppList(name: "Distractions", selectionCount: 3)
context.insert(distractions)
let rules: [BlockingRule]
switch scenario { switch scenario {
case .standard: case .standard:
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar)) rules = [
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar)) activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
upcomingRule(named: "Sleep", now: now, calendar: calendar),
]
case .hardModeActive: case .hardModeActive:
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar)) rules = [
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar)) activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar),
upcomingRule(named: "Sleep", now: now, calendar: calendar),
]
case .limits:
let timeKeeper = BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
let gateKeeper = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
let doomScroll = BlockingRule(
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)
rules = [timeKeeper, gateKeeper, doomScroll]
}
// Relationships are wired only after both sides are managed
// (see BlockingRule.appList).
for rule in rules {
context.insert(rule)
rule.appList = distractions
} }
} }
@@ -35,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
) )
} }
@@ -52,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

@@ -24,7 +24,7 @@ protocol AuthorizationProviding {
struct FamilyControlsAuthorizationProvider: AuthorizationProviding { struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
var currentStatus: ScreenTimeAuthorizationStatus { var currentStatus: ScreenTimeAuthorizationStatus {
switch AuthorizationCenter.shared.authorizationStatus { switch AuthorizationCenter.shared.authorizationStatus {
case .approved: .approved case .approved, .approvedWithDataAccess: .approved
case .denied: .denied case .denied: .denied
case .notDetermined: .notDetermined case .notDetermined: .notDetermined
@unknown default: .notDetermined @unknown default: .notDetermined

View File

@@ -0,0 +1,179 @@
//
// AppListEditorView.swift
// OpenAppLock
//
import FamilyControls
import SwiftData
import SwiftUI
/// Creates or edits an app list. A plain List (consistent with the rest of
/// the app) holds the name field and the apps currently in the list; "Edit
/// Apps" pushes Apple's Screen Time picker, whose Save applies the new
/// selection back here. The navigation-bar checkmark persists the list.
struct AppListEditorView: View {
/// Nil creates a new list; otherwise edits (and saves into) the given one.
let list: AppList?
var onComplete: (AppList) -> Void
@Environment(\.modelContext) private var modelContext
@State private var name: String
@State private var selection: FamilyActivitySelection
@State private var pickingApps = false
init(list: AppList?, onComplete: @escaping (AppList) -> Void) {
self.list = list
self.onComplete = onComplete
self._name = State(initialValue: list?.name ?? "")
self._selection = State(initialValue: AppSelectionCodec.decode(list?.selectionData))
}
var body: some View {
List {
Section {
TextField("List Name", text: $name)
.submitLabel(.done)
.accessibilityIdentifier("appListNameField")
} header: {
Text("Name").textCase(nil)
}
Section {
if AppSelectionCodec.count(of: selection) == 0 {
Text("No apps yet. Edit Apps to choose what this list includes.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptySelectionLabel")
} else {
selectionRows
}
Button {
pickingApps = true
} label: {
Label("Edit Apps", systemImage: "checklist")
}
.accessibilityIdentifier("editAppsButton")
} header: {
HStack {
Text("Apps").textCase(nil)
Spacer()
Text(countLabel).textCase(nil)
}
}
}
.navigationTitle(list == nil ? "New List" : "Edit List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .confirm) {
save()
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Save List")
.accessibilityIdentifier("saveAppListButton")
}
}
.navigationDestination(isPresented: $pickingApps) {
AppPickerScreen(selection: $selection)
}
}
/// Rows for everything the selection contains. FamilyControls' Label
/// initializers resolve the opaque tokens to icon + name.
@ViewBuilder
private var selectionRows: some View {
ForEach(Array(selection.applicationTokens), id: \.self) { token in
Label(token)
}
ForEach(Array(selection.categoryTokens), id: \.self) { token in
Label(token)
}
ForEach(Array(selection.webDomainTokens), id: \.self) { token in
Label(token)
}
}
private var countLabel: String {
let count = AppSelectionCodec.count(of: selection)
return count == 1 ? "1 App" : "\(count) Apps"
}
private func save() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
let resolvedName = trimmed.isEmpty ? "Untitled List" : trimmed
let data = AppSelectionCodec.encode(selection)
let count = AppSelectionCodec.count(of: selection)
if let list {
list.name = resolvedName
list.selectionData = data
list.selectionCount = count
onComplete(list)
} else {
let created = AppList(name: resolvedName, selectionData: data, selectionCount: count)
modelContext.insert(created)
onComplete(created)
}
}
}
/// Screen 2: Apple's Screen Time picker. Save applies the working selection
/// back to the editor and pops; the back swipe discards it.
private struct AppPickerScreen: View {
@Binding var selection: FamilyActivitySelection
@Environment(\.dismiss) private var dismiss
@State private var working: FamilyActivitySelection
init(selection: Binding<FamilyActivitySelection>) {
self._selection = selection
self._working = State(initialValue: selection.wrappedValue)
}
var body: some View {
FamilyActivityPicker(selection: $working)
.navigationTitle("Edit Apps")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .confirm) {
saveSelection()
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Save Apps")
.accessibilityIdentifier("confirmSelectionButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
saveSelection()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal)
.padding(.bottom, 8)
}
}
private var selectionSummary: String {
let count = AppSelectionCodec.count(of: working)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
}
private func saveSelection() {
selection = working
dismiss()
}
}

View File

@@ -0,0 +1,158 @@
//
// AppListPickerSheet.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// Chooses the app list a rule uses: saved lists (tap to select), an Edit
/// affordance per list, and a "New List" flow. Lists are persisted directly,
/// independent of any rule, so creating one here never depends on the rule
/// draft being committed.
struct AppListPickerSheet: View {
@Binding var selected: AppList?
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \AppList.createdAt) private var lists: [AppList]
@Query private var rules: [BlockingRule]
@State private var editingList: AppList?
@State private var creatingList = false
@State private var deletionBlocked = false
/// While any hard-mode rule is actively blocking (by the clock or a spent
/// limit budget), lists are read-only editing one would be a back door
/// out of the hard block.
private var listsLocked: Bool {
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
}
var body: some View {
NavigationStack {
List {
Section {
if lists.isEmpty {
Text("No app lists yet. Create one to choose which apps this rule affects.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptyAppListsLabel")
} else {
ForEach(lists) { list in
listRow(list)
}
}
} header: {
Text("Your App Lists").textCase(nil)
} footer: {
if listsLocked {
Label(
"Hard Mode is on — app lists are locked until the block ends.",
systemImage: "lock.fill"
)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("appListsLockedNotice")
}
}
Section {
Button {
creatingList = true
} label: {
Label("New List", systemImage: "plus")
}
.accessibilityIdentifier("newAppListButton")
}
}
.navigationTitle("App List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeAppListPickerButton")
}
}
.navigationDestination(isPresented: $creatingList) {
AppListEditorView(list: nil) { created in
selected = created
creatingList = false
}
}
.navigationDestination(item: $editingList) { list in
AppListEditorView(list: list) { _ in
editingList = nil
}
}
.alert("This list is in use", isPresented: $deletionBlocked) {
Button("OK", role: .cancel) {}
} message: {
Text("Remove it from the rules that use it before deleting.")
}
}
}
private func listRow(_ list: AppList) -> some View {
HStack {
Button {
selected = list
dismiss()
} label: {
HStack {
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(list.name)
.foregroundStyle(Color.primary)
Text(list.appCountLabel)
.font(.caption)
.foregroundStyle(Color.secondary)
}
}
}
.accessibilityIdentifier("appListRow-\(list.name)")
Spacer()
if !listsLocked {
Button("Edit") {
editingList = list
}
.font(.subheadline)
.accessibilityIdentifier("editAppListButton-\(list.name)")
}
}
.buttonStyle(.borderless)
.swipeActions {
if !listsLocked {
Button("Delete", role: .destructive) {
delete(list)
}
}
}
}
private func isSelected(_ list: AppList) -> Bool {
selected?.id == list.id
}
private func delete(_ list: AppList) {
guard !AppList.isInUse(list, context: modelContext) else {
deletionBlocked = true
return
}
if isSelected(list) {
selected = nil
}
modelContext.delete(list)
}
}
extension AppList {
/// "4 Apps" / "1 App" label shared by the picker, editor, and detail rows.
var appCountLabel: String {
selectionCount == 1 ? "1 App" : "\(selectionCount) Apps"
}
}

View File

@@ -50,7 +50,7 @@ struct AppsHomeView: View {
) { ) {
Button("Unblock", role: .destructive) { Button("Unblock", role: .destructive) {
if let rule = unblockCandidate { if let rule = unblockCandidate {
RulePolicy.unblock(rule) RulePolicy.unblock(rule, usage: enforcer.usage(for: rule))
refreshEnforcement() refreshEnforcement()
} }
unblockCandidate = nil unblockCandidate = nil
@@ -74,15 +74,22 @@ struct AppsHomeView: View {
private func ruleList(now: Date) -> some View { private func ruleList(now: Date) -> some View {
List { List {
blockedSection(now: now) blockedSection(now: now)
usageSection(now: now)
rulesSection(now: now) rulesSection(now: now)
} }
} }
/// Status with the day's usage folded in, so limit rules whose budget is
/// spent count as actively blocking.
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
}
// MARK: - Blocked Apps // MARK: - Blocked Apps
@ViewBuilder @ViewBuilder
private func blockedSection(now: Date) -> some View { private func blockedSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive } let blocking = rules.filter { liveStatus(for: $0, now: now).isActive }
Section { Section {
if blocking.isEmpty { if blocking.isEmpty {
Text("Nothing is blocked right now.") Text("Nothing is blocked right now.")
@@ -100,7 +107,7 @@ struct AppsHomeView: View {
private func blockedRow(for rule: BlockingRule, now: Date) -> some View { private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
Button { Button {
if RulePolicy.canUnblock(rule, at: now) { if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
unblockCandidate = rule unblockCandidate = rule
} else { } else {
hardModeBlockedAttempt = true hardModeBlockedAttempt = true
@@ -120,6 +127,53 @@ struct AppsHomeView: View {
.accessibilityIdentifier("blockedTile-\(rule.name)") .accessibilityIdentifier("blockedTile-\(rule.name)")
} }
// MARK: - Usage
/// Live tracking for every limit rule scheduled today: how much of the
/// daily budget is spent and what remains.
@ViewBuilder
private func usageSection(now: Date) -> some View {
let tracked = rules.filter {
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
}
if !tracked.isEmpty {
Section {
ForEach(tracked) { rule in
usageRow(for: rule, now: now)
}
} header: {
Text("Usage").textCase(nil)
}
}
}
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let isPaused =
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
return HStack {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(UsageDisplay.subtitle(for: rule, usage: usage))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
.font(.subheadline)
.foregroundStyle(
rule.limitReached(given: usage) && !isPaused
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("usageRow-\(rule.name)")
}
// MARK: - Rules // MARK: - Rules
@ViewBuilder @ViewBuilder
@@ -147,7 +201,7 @@ struct AppsHomeView: View {
} }
private func ruleRow(for rule: BlockingRule, now: Date) -> some View { private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
let status = rule.status(at: now) let status = liveStatus(for: rule, now: now)
return Button { return Button {
detailRule = rule detailRule = rule
} label: { } label: {
@@ -173,19 +227,13 @@ struct AppsHomeView: View {
} }
private func blockSummary(for rule: BlockingRule) -> String { private func blockSummary(for rule: BlockingRule) -> String {
let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps" "\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
return "\(rule.selectionMode.displayName) · \(apps)"
} }
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.
status.label(relativeTo: now) rule.statusLabel(for: status, relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
}
} }
// MARK: - Enforcement // MARK: - Enforcement
@@ -195,7 +243,9 @@ struct AppsHomeView: View {
rules.map { rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|" "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|" + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
+ "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" + "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
+ "\($0.appList?.selectionCount ?? 0)|"
+ "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
} }
.joined(separator: ",") .joined(separator: ",")
} }

View File

@@ -1,90 +0,0 @@
//
// AppSelectionSheet.swift
// OpenAppLock
//
import FamilyControls
import SwiftUI
/// App/category/website selection wrapping Apple's `FamilyActivityPicker`,
/// presented as a plain sheet with a segmented Block / Allow Only control.
struct AppSelectionSheet: View {
@Binding var draft: RuleDraft
@Environment(\.dismiss) private var dismiss
@State private var selection: FamilyActivitySelection
@State private var mode: SelectionMode
init(draft: Binding<RuleDraft>) {
self._draft = draft
self._selection = State(
initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData)
)
self._mode = State(initialValue: draft.wrappedValue.selectionMode)
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
}
.navigationTitle("Selected")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier("selectionBackButton")
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
save()
}
.accessibilityIdentifier("confirmSelectionButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal)
.padding(.bottom, 8)
}
}
}
private var selectionSummary: String {
let count = AppSelectionCodec.count(of: selection)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
}
private func save() {
draft.selectionData = AppSelectionCodec.encode(selection)
draft.selectionCount = AppSelectionCodec.count(of: selection)
draft.selectionMode = mode
dismiss()
}
}

View File

@@ -54,7 +54,7 @@ struct NewRuleSheet: View {
mode: .create, mode: .create,
draft: draft, draft: draft,
onCommit: { committed in onCommit: { committed in
modelContext.insert(committed.makeRule()) committed.insertRule(into: modelContext)
dismiss() dismiss()
} }
) )
@@ -80,7 +80,7 @@ struct NewRuleSheet: View {
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(.tertiary) .foregroundStyle(Color(.tertiaryLabel))
} }
} }
.accessibilityIdentifier("ruleKind-\(kind.rawValue)") .accessibilityIdentifier("ruleKind-\(kind.rawValue)")

View File

@@ -14,6 +14,7 @@ struct RuleDetailSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@State private var isEditing = false @State private var isEditing = false
@State private var pendingDeletion = false @State private var pendingDeletion = false
@@ -50,13 +51,14 @@ struct RuleDetailSheet: View {
} }
private func detailList(now: Date) -> some View { private func detailList(now: Date) -> some View {
let status = rule.status(at: now) let usage = enforcer.usage(for: rule, at: now)
let status = rule.status(at: now, usage: usage)
return List { return List {
Section { Section {
detailRows detailRows
} }
Section { Section {
if RulePolicy.canEdit(rule, at: now) { if RulePolicy.canEdit(rule, usage: usage, at: now) {
Button { Button {
isEditing = true isEditing = true
} label: { } label: {
@@ -87,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")
@@ -98,32 +100,32 @@ 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")
} }
} }
private var appCountLabel: String { private var appCountLabel: String {
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps" guard let list = rule.appList else { return "No apps" }
return "\(list.name) · \(list.appCountLabel)"
} }
private func row(_ label: String, _ value: String) -> some View { private func row(_ label: String, _ value: String) -> some View {

View File

@@ -74,7 +74,7 @@ struct RuleEditorView: View {
} }
} }
.sheet(isPresented: $showingAppPicker) { .sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft) AppListPickerSheet(selected: $draft.appList)
} }
} }
@@ -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,49 +112,71 @@ struct RuleEditorView: View {
} }
daysSection daysSection
Section { Section {
selectedAppsRow Picker("Mode", selection: $draft.scheduleConfig.selectionMode) {
} header: { ForEach(SelectionMode.allCases, id: \.self) { mode in
Text("Apps are blocked").textCase(nil) Text(mode.displayName).tag(mode)
} }
toggleSections }
.pickerStyle(.segmented)
.accessibilityIdentifier("selectionModePicker")
appListRow
} header: {
Text(draft.scheduleConfig.selectionMode == .block
? "Apps are blocked" : "Only these apps are allowed")
.textCase(nil)
}
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 {
selectedAppsRow appListRow
} header: { } header: {
Text("When I use").textCase(nil) Text("When I use").textCase(nil)
} }
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 {
selectedAppsRow appListRow
} header: { } header: {
Text("When I open").textCase(nil) Text("When I open").textCase(nil)
} }
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
} }
} }
@@ -178,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")
@@ -191,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")
} }
@@ -204,24 +230,29 @@ struct RuleEditorView: View {
} }
} }
private var selectedAppsRow: some View { private var appListRow: some View {
Button { Button {
showingAppPicker = true showingAppPicker = true
} label: { } label: {
HStack { HStack {
Text("Selected Apps") Text("App List")
.foregroundStyle(Color.primary) .foregroundStyle(Color.primary)
Spacer() Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps") Text(appListLabel)
.foregroundStyle(Color.secondary) .foregroundStyle(Color.secondary)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(.tertiary) .foregroundStyle(Color(.tertiaryLabel))
} }
} }
.accessibilityIdentifier("selectedAppsRow") .accessibilityIdentifier("selectedAppsRow")
} }
private var appListLabel: String {
guard let list = draft.appList else { return "Choose" }
return "\(list.name) · \(list.appCountLabel)"
}
private func budgetRow( private func budgetRow(
value: String, value: String,
stepperID: String, stepperID: String,

View File

@@ -0,0 +1,62 @@
//
// DeviceActivityMonitorExtension.swift
// OpenAppLockMonitor
//
import DeviceActivity
import Foundation
/// Background half of limit-rule enforcement. The app schedules a daily
/// activity per limit rule (with per-minute usage checkpoints for time
/// limits); this extension reacts: it resets shields at midnight, records
/// usage minutes, blocks at the budget, and ends granted open sessions.
final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
private var enforcement: LimitEnforcement {
LimitEnforcement(
snapshots: RuleSnapshotStore(),
ledger: UsageLedger(),
shields: ManagedSettingsShieldController()
)
}
private var scheduleEnforcement: ScheduleEnforcement {
ScheduleEnforcement(
snapshots: RuleSnapshotStore(),
shields: ManagedSettingsShieldController()
)
}
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
enforcement.handleDayStart(ruleID: ruleID)
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
// A schedule window opened: shield it (the recompute honours days,
// pause and the midnight-crossing rule).
scheduleEnforcement.reconcile(ruleID: ruleID)
}
}
override func intervalDidEnd(for activity: DeviceActivityName) {
super.intervalDidEnd(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) {
enforcement.handleOpenSessionEnded(ruleID: ruleID)
DeviceActivityCenter().stopMonitoring([activity])
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
// A schedule window closed (or its evening half ended at 23:59):
// recompute so a still-active window stays shielded and a finished
// one clears.
scheduleEnforcement.reconcile(ruleID: ruleID)
}
}
override func eventDidReachThreshold(
_ event: DeviceActivityEvent.Name, activity: DeviceActivityName
) {
super.eventDidReachThreshold(event, activity: activity)
guard let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue),
let minutes = MonitoringPlan.minutes(fromEventName: event.rawValue)
else { return }
enforcement.handleUsageMinutes(minutes, ruleID: ruleID)
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.deviceactivity.monitor-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.bchen.OpenAppLock</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettings.shield-action-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShieldActionExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.bchen.OpenAppLock</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,101 @@
//
// ShieldActionExtension.swift
// OpenAppLockShieldAction
//
import DeviceActivity
import Foundation
import ManagedSettings
/// Handles shield button presses. The secondary "Open" button on an
/// open-limit shield spends one open, lifts the rule's shield, and starts a
/// one-shot DeviceActivity session after which the monitor extension
/// re-shields.
final class ShieldActionExtension: ShieldActionDelegate {
override func handle(
action: ShieldAction, for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
completionHandler(.close)
case .secondaryButtonPressed:
completionHandler(handleOpenPress(applicationToken: application))
case .firstSecondarySubmenuItemPressed, .secondSecondarySubmenuItemPressed,
.thirdSecondarySubmenuItemPressed:
// Our shields define no submenu items.
completionHandler(.close)
@unknown default:
completionHandler(.close)
}
}
override func handle(
action: ShieldAction, for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
completionHandler(.close)
}
override func handle(
action: ShieldAction, for category: ActivityCategoryToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .secondaryButtonPressed:
if let snapshot = ShieldLookup.openLimitSnapshot(
containingCategory: category, in: RuleSnapshotStore().load()) {
completionHandler(grantOpen(ruleID: snapshot.id))
} else {
completionHandler(.close)
}
default:
completionHandler(.close)
}
}
private func handleOpenPress(applicationToken: ApplicationToken) -> ShieldActionResponse {
guard
let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: applicationToken, in: RuleSnapshotStore().load())
else { return .close }
return grantOpen(ruleID: snapshot.id)
}
private func grantOpen(ruleID: UUID) -> ShieldActionResponse {
let enforcement = LimitEnforcement(
snapshots: RuleSnapshotStore(),
ledger: UsageLedger(),
shields: ManagedSettingsShieldController()
)
guard enforcement.handleOpenRequest(ruleID: ruleID) else {
// Out of opens; keep the shield up (it re-renders with the
// exhausted message on next presentation).
return .defer
}
startOpenSession(ruleID: ruleID)
return .none
}
/// Times the granted open with a one-shot activity. One extra minute over
/// the advertised session keeps the interval above DeviceActivity's
/// 15-minute minimum.
private func startOpenSession(ruleID: UUID) {
let calendar = Calendar.current
let now = Date.now
guard
let end = calendar.date(
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
else { return }
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: now),
intervalEnd: calendar.dateComponents(components, from: end),
repeats: false
)
try? DeviceActivityCenter().startMonitoring(
DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)),
during: schedule
)
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettingsUI.shield-configuration-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShieldConfigurationExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.bchen.OpenAppLock</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,85 @@
//
// ShieldConfigurationExtension.swift
// OpenAppLockShieldConfig
//
import ManagedSettings
import ManagedSettingsUI
import UIKit
/// Customizes the system shield. Apps under an open-limit rule show how many
/// opens were used and offer an "Open" secondary button while opens remain;
/// everything else gets a plain blocked shield named after its rule.
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
override func configuration(shielding application: Application) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
return configuration(forApplicationToken: token)
}
override func configuration(
shielding application: Application, in category: ActivityCategory
) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
return configuration(forApplicationToken: token)
}
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil)
}
override func configuration(
shielding webDomain: WebDomain, in category: ActivityCategory
) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil)
}
private func configuration(
forApplicationToken token: ApplicationToken
) -> ShieldConfiguration {
let snapshots = RuleSnapshotStore().load()
guard
let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: token, in: snapshots)
else {
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
return blockedConfiguration(ruleName: name)
}
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
guard remaining > 0 else {
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "No opens left today — the block lifts tomorrow.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
)
}
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: .init(
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
color: .systemBlue)
)
}
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: ruleName ?? "Blocked", color: .label),
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
)
}
}

View File

@@ -0,0 +1,249 @@
//
// AppListTests.swift
// OpenAppLockTests
//
import Foundation
import SwiftData
import Testing
@testable import OpenAppLock
// Note: every test wires `rule.appList` only after both models are inserted
// SwiftData relationships must not be written on unmanaged instances
// (see BlockingRule.appList).
@MainActor
@Suite("AppList model & relationship")
struct AppListModelTests {
@Test("App lists persist and fetch through SwiftData")
func persistence() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionData: Data([1, 2]), selectionCount: 2)
context.insert(list)
try context.save()
let fetched = try context.fetch(FetchDescriptor<AppList>())
#expect(fetched.count == 1)
let saved = try #require(fetched.first)
#expect(saved.name == "Distractions")
#expect(saved.selectionCount == 2)
#expect(saved.selectionData == Data([1, 2]))
}
@Test("Deleting a list detaches it from its rules")
func deletingListDetachesRules() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions")
let rule = BlockingRule(name: "Work Time")
context.insert(list)
context.insert(rule)
rule.appList = list
try context.save()
context.delete(list)
try context.save()
let rules = try context.fetch(FetchDescriptor<BlockingRule>())
#expect(rules.count == 1)
#expect(rules.first?.appList == nil)
}
@Test("Deleting a rule keeps its list")
func deletingRuleKeepsList() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions")
let rule = BlockingRule(name: "Work Time")
context.insert(list)
context.insert(rule)
rule.appList = list
try context.save()
context.delete(rule)
try context.save()
let lists = try context.fetch(FetchDescriptor<AppList>())
#expect(lists.count == 1)
}
@Test("Lists report whether any rule uses them")
func usageQuery() throws {
let context = try makeInMemoryContext()
let used = AppList(name: "Used")
let unused = AppList(name: "Unused")
let rule = BlockingRule(name: "Work Time")
context.insert(used)
context.insert(unused)
context.insert(rule)
rule.appList = used
try context.save()
#expect(AppList.isInUse(used, context: context))
#expect(!AppList.isInUse(unused, context: context))
}
}
@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")
rule.selectionData = Data([1])
rule.selectionCount = 3
context.insert(rule)
try context.save()
AppListMigration.run(in: context)
let lists = try context.fetch(FetchDescriptor<AppList>())
#expect(lists.count == 1)
let list = try #require(rule.appList)
#expect(list.name == "Work Time Apps")
#expect(list.selectionData == Data([1]))
#expect(list.selectionCount == 3)
// The legacy inline copy is cleared so migration never re-runs.
#expect(rule.selectionData == nil)
}
@Test("Rules with identical selections share one list")
func sharesListForIdenticalSelections() throws {
let context = try makeInMemoryContext()
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)
try context.save()
AppListMigration.run(in: context)
let lists = try context.fetch(FetchDescriptor<AppList>())
#expect(lists.count == 2)
#expect(first.appList === second.appList)
#expect(first.appList !== different.appList)
}
@Test("Migration is idempotent and skips selection-less rules")
func idempotentAndSkipsEmpty() throws {
let context = try makeInMemoryContext()
let legacy = legacyRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
let empty = BlockingRule(name: "No Apps")
context.insert(legacy)
context.insert(empty)
try context.save()
AppListMigration.run(in: context)
AppListMigration.run(in: context)
let lists = try context.fetch(FetchDescriptor<AppList>())
#expect(lists.count == 1)
#expect(empty.appList == nil)
}
}
@MainActor
@Suite("Rule drafts with app lists")
struct AppListDraftTests {
@Test("Drafts carry the rule's app list and apply it back")
func draftCarriesAppList() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionCount: 4)
let rule = BlockingRule(name: "Work Time")
context.insert(list)
context.insert(rule)
rule.appList = list
var draft = RuleDraft(rule: rule)
#expect(draft.appList === list)
draft.name = "Other"
let other = draft.insertRule(into: context)
#expect(other.appList === list)
#expect(other.name == "Other")
}
@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)
// 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("Allow Only survives on schedule rules")
func keepsAllowOnlyForSchedule() {
var draft = RuleDraft(kind: .schedule)
draft.scheduleConfig.selectionMode = .allowOnly
#expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly)
}
}
@MainActor
@Suite("App-list editing under Hard Mode")
struct AppListEditingPolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
@Test("App lists are locked while any hard-mode rule is actively blocking")
func lockedDuringHardModeSession() {
let hard = BlockingRule(name: "Locked In", hardMode: true)
let soft = BlockingRule(name: "Work Time")
#expect(
!RulePolicy.canEditAppLists(rules: [hard, soft], at: mondayDuringWork, calendar: utc))
}
@Test("App lists stay editable when no hard-mode rule is blocking")
func editableWithoutHardSession() {
let softActive = BlockingRule(name: "Work Time")
let hardInactive = BlockingRule(name: "Locked In", hardMode: true)
#expect(
RulePolicy.canEditAppLists(
rules: [softActive, hardInactive], at: mondayEvening, calendar: utc))
#expect(RulePolicy.canEditAppLists(rules: [], at: mondayDuringWork, calendar: utc))
}
@Test("A disabled hard-mode rule does not lock app lists")
func disabledHardRuleDoesNotLock() {
let rule = BlockingRule(name: "Locked In", isEnabled: false, hardMode: true)
#expect(RulePolicy.canEditAppLists(rules: [rule], at: mondayDuringWork, calendar: utc))
}
}
@MainActor
@Suite("Enforcement reads selections through app lists")
struct AppListEnforcementTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
@Test("The rule's app-list selection reaches the shield layer")
func forwardsAppListSelection() throws {
let context = try makeInMemoryContext()
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let list = AppList(name: "Distractions", selectionData: Data([1, 2, 3]))
let rule = BlockingRule(name: "Work Time")
context.insert(list)
context.insert(rule)
rule.appList = list
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3]))
}
}

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,16 +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 container = try ModelContainer( let context = try makeInMemoryContext()
for: BlockingRule.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = container.mainContext
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()
@@ -77,31 +105,51 @@ 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)
} }
@Test("Draft → rule → draft round-trips every field") @Test("Draft → rule → draft round-trips every field")
func roundTrip() { func roundTrip() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionCount: 3)
context.insert(list)
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.appList = list
draft.selectionMode = .allowOnly
draft.selectionCount = 3
let rule = draft.makeRule() 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() { func applyToExisting() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(name: "Old Name") let rule = BlockingRule(name: "Old Name")
context.insert(rule)
var draft = RuleDraft(rule: rule) var draft = RuleDraft(rule: rule)
draft.name = "New Name" draft.name = "New Name"
draft.hardMode = true draft.hardMode = true
@@ -133,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

@@ -0,0 +1,610 @@
//
// SchedulingTests.swift
// OpenAppLockTests
//
import Foundation
import SwiftData
import Testing
@testable import OpenAppLock
private func freshDefaults() -> UserDefaults {
let name = "scheduling-tests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defaults.removePersistentDomain(forName: name)
return defaults
}
@MainActor
@Suite("Rule snapshots")
struct RuleSnapshotTests {
@Test("Snapshots round-trip through the shared store")
func storeRoundTrip() throws {
let store = RuleSnapshotStore(defaults: freshDefaults())
let snapshot = RuleSnapshot(
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: [2, 3], startMinutes: 540, endMinutes: 1020,
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: nil
)
store.save([snapshot])
#expect(store.load() == [snapshot])
#expect(store.snapshot(for: snapshot.id) == snapshot)
#expect(store.snapshot(for: snapshot.id)?.startMinutes == 540)
#expect(store.snapshot(for: snapshot.id)?.endMinutes == 1020)
#expect(store.snapshot(for: UUID()) == nil)
}
@Test("Snapshots mirror rules and their app lists")
func mirrorsRule() throws {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
let rule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 3)),
days: Weekday.weekends)
context.insert(list)
context.insert(rule)
rule.appList = list
let snapshot = RuleSnapshot(rule: rule)
#expect(snapshot.id == rule.id)
#expect(snapshot.kind == .openLimit)
#expect(snapshot.selectionData == Data([9]))
#expect(snapshot.days == Weekday.weekends)
#expect(snapshot.maxOpens == 3)
#expect(snapshot.startMinutes == rule.startMinutes)
#expect(snapshot.endMinutes == rule.endMinutes)
}
@Test("Snapshots saved before the window fields decode with a zeroed window")
func decodesLegacySnapshotWithoutWindowFields() throws {
// A blob shaped like a pre-schedule-window snapshot: no start/endMinutes.
let id = UUID()
let legacy = """
[{"id":"\(id.uuidString)","name":"Old","kindRaw":"timeLimit","isEnabled":true,\
"hardMode":false,"blockAdultContent":false,"selectionModeRaw":"block",\
"dayNumbers":[2,3],"dailyLimitMinutes":45,"maxOpens":5}]
"""
let defaults = freshDefaults()
defaults.set(Data(legacy.utf8), forKey: "ruleSnapshots")
let store = RuleSnapshotStore(defaults: defaults)
let loaded = store.load()
#expect(loaded.count == 1)
#expect(loaded.first?.id == id)
#expect(loaded.first?.startMinutes == 0)
#expect(loaded.first?.endMinutes == 0)
#expect(loaded.first?.dailyLimitMinutes == 45)
}
}
@MainActor
@Suite("Monitoring plan")
struct MonitoringPlanTests {
@Test("Activity names round-trip rule IDs")
func nameRoundTrip() {
let id = UUID()
#expect(
MonitoringPlan.ruleID(
fromDailyActivityName: MonitoringPlan.dailyActivityName(for: id)) == id)
#expect(
MonitoringPlan.ruleID(
fromSessionActivityName: MonitoringPlan.sessionActivityName(for: id)) == id)
#expect(MonitoringPlan.ruleID(fromDailyActivityName: "garbage") == nil)
#expect(
MonitoringPlan.ruleID(
fromSessionActivityName: MonitoringPlan.dailyActivityName(for: id)) == nil)
}
@Test("Schedule-window activity names round-trip rule IDs")
func scheduleWindowNameRoundTrip() {
let id = UUID()
let primary = MonitoringPlan.scheduleWindowName(for: id)
let late = MonitoringPlan.scheduleWindowLateName(for: id)
#expect(primary != late)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: primary) == id)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: late) == id)
// Daily / session / garbage names are not schedule-window names.
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: MonitoringPlan.dailyActivityName(for: id)) == nil)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: "garbage") == nil)
// Schedule-window names are not mistaken for daily activities.
#expect(MonitoringPlan.ruleID(fromDailyActivityName: primary) == nil)
#expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil)
}
@Test("Minute checkpoints cover every minute up to the budget")
func minuteEvents() {
let events = MonitoringPlan.minuteEvents(forLimit: 45)
#expect(events.count == 45)
#expect(events[MonitoringPlan.minuteEventName(for: 1)] == 1)
#expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45)
#expect(MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 17)) == 17)
#expect(MonitoringPlan.minutes(fromEventName: "nope") == nil)
}
}
@MainActor
@Suite("Rule scheduler → DeviceActivity")
struct RuleSchedulerTests {
private func makeScheduler() -> (RuleScheduler, MockActivityMonitor, RuleSnapshotStore) {
let monitor = MockActivityMonitor()
let store = RuleSnapshotStore(defaults: freshDefaults())
return (RuleScheduler(monitor: monitor, snapshots: store), monitor, store)
}
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, configuration: .default(for: kind), days: Weekday.everyDay)
context.insert(list)
context.insert(rule)
rule.appList = list
return rule
}
private func scheduleRule(
name: String, start: Int, end: Int, days: Set<Weekday> = Weekday.everyDay,
withApps: Bool = true
) throws -> BlockingRule {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: name,
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
days: days)
context.insert(rule)
if withApps {
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
context.insert(list)
rule.appList = list
}
return rule
}
@Test("Enabled limit rules with apps start daily monitoring")
func startsMonitoring() throws {
let (scheduler, monitor, store) = makeScheduler()
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
scheduler.sync(rules: [rule])
let name = MonitoringPlan.dailyActivityName(for: rule.id)
#expect(monitor.monitoredNames == [name])
#expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes)
#expect(store.snapshot(for: rule.id) != nil)
}
@Test("Open-limit rules monitor the day without usage checkpoints")
func openLimitHasNoCheckpoints() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try limitRule(kind: .openLimit, name: "Gate Keeper")
scheduler.sync(rules: [rule])
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true)
}
@Test("App-less rules (schedule or limit) are not monitored")
func skipsUnmonitorable() throws {
let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext()
let applessSchedule = BlockingRule(name: "Work Time")
let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig()))
context.insert(applessSchedule)
context.insert(applessLimit)
scheduler.sync(rules: [applessSchedule, applessLimit])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("A non-crossing schedule rule registers one window activity")
func schedulesNonCrossingWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 9 * 60)
#expect(monitor.startedWindows[primary]?.end == 17 * 60)
// A schedule window carries no usage-threshold events.
#expect(monitor.startedEvents[primary] == nil)
}
@Test("A dayless schedule rule is not monitored")
func skipsDaylessSchedule() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "No Days", start: 9 * 60, end: 17 * 60, days: [])
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("A midnight-crossing schedule rule registers two window activities")
func schedulesCrossingWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(Set(monitor.monitoredNames) == [primary, late])
// The evening half runs to the end of the day; the morning half from midnight.
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
#expect(monitor.startedWindows[late]?.start == 0)
#expect(monitor.startedWindows[late]?.end == 6 * 60)
}
@Test("A window ending exactly at midnight registers a single evening activity")
func scheduleWindowEndingAtMidnight() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Late", start: 22 * 60, end: 0)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
#expect(monitor.startedWindows[late] == nil)
}
@Test("A 24-hour window registers a single all-day activity")
func scheduleFullDayWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Always", start: 0, end: 0)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 0)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
}
@Test("Switching a window from crossing to non-crossing stops the morning half")
func dropsLateActivityWhenWindowStopsCrossing() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(Set(monitor.monitoredNames) == [primary, late])
// Now a normal daytime window the post-midnight half must be stopped.
rule.startMinutes = 9 * 60
rule.endMinutes = 17 * 60
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[late] == nil)
}
@Test("Changing only the days does not restart the window activity")
func keepsWindowWhenOnlyDaysChange() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(
name: "Work Time", start: 9 * 60, end: 17 * 60, days: Weekday.weekdays)
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
// The window interval is unchanged, so the DeviceActivity activity that
// only encodes start/end need not restart reconcile() reads days fresh.
rule.days = Weekday.everyDay
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
}
@Test("Disabling a schedule rule stops its window monitoring")
func stopsScheduleWindowWhenDisabled() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
#expect(!monitor.monitoredNames.isEmpty)
rule.isEnabled = false
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("Changing a schedule window restarts monitoring")
func restartsOnWindowChange() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
rule.endMinutes = 18 * 60
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 2)
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.startedWindows[primary]?.end == 18 * 60)
}
@Test("Monitoring stops when a rule is disabled or removed")
func stopsStaleMonitoring() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
scheduler.sync(rules: [rule])
rule.isEnabled = false
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames.isEmpty)
rule.isEnabled = true
scheduler.sync(rules: [rule])
scheduler.sync(rules: [])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("Unchanged rules are not restarted (checkpoints would reset)")
func avoidsRestartChurn() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
scheduler.sync(rules: [rule])
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
rule.dailyLimitMinutes = 60
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 2)
}
}
@MainActor
@Suite("Limit enforcement reactions")
struct LimitEnforcementTests {
let monday = date(2025, 1, 6, 10, 0)
private func makeEnforcement() -> (LimitEnforcement, MockShieldController, UsageLedger, RuleSnapshotStore) {
let shields = MockShieldController()
let ledger = UsageLedger(defaults: freshDefaults())
let store = RuleSnapshotStore(defaults: freshDefaults())
// 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(
kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil
) -> RuleSnapshot {
RuleSnapshot(
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
startMinutes: 0, endMinutes: 0,
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
)
}
@Test("Day start shields open-limit rules so opens can be counted")
func dayStartShieldsOpenLimit() {
let (enforcement, shields, _, store) = makeEnforcement()
let snap = snapshot(kind: .openLimit)
store.save([snap])
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("Day start clears time-limit shields for the fresh budget")
func dayStartClearsTimeLimit() {
let (enforcement, shields, _, store) = makeEnforcement()
let snap = snapshot(kind: .timeLimit)
store.save([snap])
shields.applyShield(
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Usage checkpoints record minutes and shield at the limit")
func usageCheckpointsShieldAtLimit() {
let (enforcement, shields, ledger, store) = makeEnforcement()
let snap = snapshot(kind: .timeLimit, limit: 45)
store.save([snap])
enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc)
#expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20)
#expect(shields.shieldedRuleIDs.isEmpty)
enforcement.handleUsageMinutes(45, ruleID: snap.id, now: monday, calendar: utc)
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("An Open press spends one open and lifts the shield")
func openRequestSpendsAndLifts() {
let (enforcement, shields, ledger, store) = makeEnforcement()
let snap = snapshot(kind: .openLimit, maxOpens: 2)
store.save([snap])
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
#expect(enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
#expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).opensUsed == 1)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Open presses are refused once opens are exhausted")
func openRequestRefusedWhenExhausted() {
let (enforcement, shields, ledger, store) = makeEnforcement()
let snap = snapshot(kind: .openLimit, maxOpens: 1)
store.save([snap])
ledger.recordOpen(for: snap.id, onDayContaining: monday, calendar: utc)
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
#expect(!enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
#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()
let snap = snapshot(kind: .openLimit)
store.save([snap])
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("A paused (unblocked) rule is left alone until midnight")
func pausedRuleLeftAlone() {
let (enforcement, shields, _, store) = makeEnforcement()
let snap = snapshot(kind: .openLimit, pausedUntil: date(2025, 1, 7, 0, 0))
store.save([snap])
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
#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

@@ -4,9 +4,45 @@
// //
import Foundation import Foundation
import SwiftData
@testable import OpenAppLock @testable import OpenAppLock
/// One in-memory ModelContainer shared by the whole test process.
///
/// Repeatedly creating containers for this schema (inverse relationships)
/// trips an intermittent EXC_BREAKPOINT inside SwiftData's configuration
/// setup observed both in test runs and in a sequential snippet that made
/// six containers. Tests therefore share a single container; isolation comes
/// from a fresh ModelContext plus a data wipe per test.
@MainActor
private let sharedTestContainer: ModelContainer = {
do {
return try ModelContainer(
for: Schema([BlockingRule.self, AppList.self]),
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
} catch {
fatalError("Could not create the shared test ModelContainer: \(error)")
}
}()
/// Fresh, empty SwiftData context for model tests. The wipe deletes object
/// by object batch `delete(model:)` refuses to fire the appList nullify
/// inverse ("Constraint trigger violation").
@MainActor
func makeInMemoryContext() throws -> ModelContext {
let context = ModelContext(sharedTestContainer)
for rule in try context.fetch(FetchDescriptor<BlockingRule>()) {
context.delete(rule)
}
for list in try context.fetch(FetchDescriptor<AppList>()) {
context.delete(list)
}
try context.save()
return context
}
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless /// Fixed UTC gregorian calendar so schedule math is deterministic regardless
/// of the machine running the tests. /// of the machine running the tests.
let utc: Calendar = { let utc: Calendar = {

View File

@@ -0,0 +1,257 @@
//
// UsageTests.swift
// OpenAppLockTests
//
import Foundation
import Testing
@testable import OpenAppLock
@MainActor
@Suite("Usage ledger")
struct UsageLedgerTests {
private func makeLedger() -> UsageLedger {
let suiteName = "usage-tests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return UsageLedger(defaults: defaults)
}
let monday = date(2025, 1, 6, 10, 0)
let tuesday = date(2025, 1, 7, 10, 0)
@Test("Day keys are calendar dates")
func dayKey() {
#expect(UsageLedger.dayKey(for: monday, calendar: utc) == "2025-01-06")
#expect(UsageLedger.dayKey(for: date(2025, 12, 31, 23, 59), calendar: utc) == "2025-12-31")
}
@Test("Usage defaults to zero and round-trips")
func roundTrip() {
let ledger = makeLedger()
let ruleID = UUID()
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc) == RuleUsage())
ledger.setUsage(
RuleUsage(minutesUsed: 12, opensUsed: 3),
for: ruleID, onDayContaining: monday, calendar: utc
)
let read = ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc)
#expect(read.minutesUsed == 12)
#expect(read.opensUsed == 3)
}
@Test("Recorded minutes are monotonic per day")
func monotonicMinutes() {
let ledger = makeLedger()
let ruleID = UUID()
ledger.recordMinutesUsed(10, for: ruleID, onDayContaining: monday, calendar: utc)
ledger.recordMinutesUsed(7, for: ruleID, onDayContaining: monday, calendar: utc)
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 10)
ledger.recordMinutesUsed(25, for: ruleID, onDayContaining: monday, calendar: utc)
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 25)
}
@Test("Opens increment per day")
func opensIncrement() {
let ledger = makeLedger()
let ruleID = UUID()
ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc)
ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc)
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).opensUsed == 2)
}
@Test("Usage is separated by day and rule")
func separation() {
let ledger = makeLedger()
let first = UUID()
let second = UUID()
ledger.recordMinutesUsed(30, for: first, onDayContaining: monday, calendar: utc)
#expect(ledger.usage(for: first, onDayContaining: tuesday, calendar: utc) == RuleUsage())
#expect(ledger.usage(for: second, onDayContaining: monday, calendar: utc) == RuleUsage())
}
}
@MainActor
@Suite("Usage-aware rule status")
struct UsageStatusTests {
let mondayMorning = date(2025, 1, 6, 10, 0)
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
}
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
}
@Test("A time-limit rule with budget left is not active")
func budgetLeftNotActive() {
let rule = timeLimitRule()
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 20))
#expect(!status.isActive)
}
@Test("A time-limit rule blocks until midnight once its budget is spent")
func spentBudgetBlocksUntilMidnight() {
let rule = timeLimitRule(limit: 45)
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 45))
#expect(status == .active(until: date(2025, 1, 7, 0, 0)))
}
@Test("An open-limit rule blocks once opens are exhausted")
func exhaustedOpensBlock() {
let rule = openLimitRule(maxOpens: 5)
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(opensUsed: 5))
#expect(status == .active(until: date(2025, 1, 7, 0, 0)))
}
@Test("A spent budget on a disabled day does not block")
func disabledDayDoesNotBlock() {
let rule = timeLimitRule()
rule.days = Weekday.weekends
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 99))
#expect(!status.isActive)
}
@Test("Unblocking a limit-blocked rule pauses it until midnight")
func unblockPausesUntilMidnight() {
let rule = timeLimitRule(limit: 45)
let usage = RuleUsage(minutesUsed: 45)
#expect(RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc))
#expect(RulePolicy.unblock(rule, usage: usage, at: mondayMorning, calendar: utc))
#expect(rule.pausedUntil == date(2025, 1, 7, 0, 0))
#expect(
rule.status(at: mondayMorning, calendar: utc, usage: usage)
== .paused(until: date(2025, 1, 7, 0, 0)))
}
@Test("Hard Mode locks a limit-blocked rule and its app lists")
func hardModeLocksLimitBlock() {
let rule = timeLimitRule(limit: 45)
rule.hardMode = true
let usage = RuleUsage(minutesUsed: 45)
#expect(RulePolicy.isHardLocked(rule, usage: usage, at: mondayMorning, calendar: utc))
#expect(!RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc))
#expect(
!RulePolicy.canEditAppLists(
rules: [rule], usageFor: { _ in usage }, at: mondayMorning, calendar: utc))
}
}
@MainActor
@Suite("Enforcement of limit rules")
struct UsageEnforcementTests {
let mondayMorning = date(2025, 1, 6, 10, 0)
@Test("A spent time-limit rule is shielded in Block mode")
func shieldsSpentTimeLimit() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
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 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: "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)
}
}
@MainActor
@Suite("Usage display strings")
struct UsageDisplayTests {
let timeRule = BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
let openRule = BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
@Test("Time-limit rows show minutes used and remaining")
func timeLimitStrings() {
let usage = RuleUsage(minutesUsed: 18)
#expect(UsageDisplay.subtitle(for: timeRule, usage: usage) == "18m of 45m used today")
#expect(UsageDisplay.remainingLabel(for: timeRule, usage: usage, isPaused: false) == "27m left")
}
@Test("Open-limit rows show opens used and remaining")
func openLimitStrings() {
let usage = RuleUsage(opensUsed: 2)
#expect(UsageDisplay.subtitle(for: openRule, usage: usage) == "2 of 5 opens today")
#expect(UsageDisplay.remainingLabel(for: openRule, usage: usage, isPaused: false) == "3 opens left")
let oneLeft = RuleUsage(opensUsed: 4)
#expect(UsageDisplay.remainingLabel(for: openRule, usage: oneLeft, isPaused: false) == "1 open left")
}
@Test("Spent budgets read as blocked, or unblocked while paused")
func exhaustedStrings() {
let spent = RuleUsage(minutesUsed: 45)
#expect(
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: false)
== "Blocked until tomorrow")
#expect(
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: true)
== "Unblocked until tomorrow")
#expect(UsageDisplay.subtitle(for: timeRule, usage: spent) == "45m of 45m used today")
}
@Test("Overshoot clamps to the budget")
func overshootClamps() {
let over = RuleUsage(minutesUsed: 60)
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
}
}

View File

@@ -0,0 +1,114 @@
//
// AppListUITests.swift
// OpenAppLockUITests
//
import XCTest
/// App lists: the editor's App List row, the picker (select / create / edit),
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown.
final class AppListUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testScheduleEditorOffersModeChoice() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.staticTexts["ruleEditorTitle"].waitToAppear()
app.swipeUp()
app.element("selectionModePicker").waitToAppear()
XCTAssertTrue(app.staticTexts["Apps are blocked"].exists)
}
func testLimitEditorsAreBlockOnly() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
app.element("selectedAppsRow").waitToAppear()
XCTAssertFalse(
app.element("selectionModePicker").exists,
"Time-limit rules are always Block; the mode picker must not appear"
)
// Back to the rule-type list, then check the open-limit editor too.
let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
edge.press(forDuration: 0.05, thenDragTo: middle)
app.buttons["ruleKind-openLimit"].waitToAppear().tap()
app.element("selectedAppsRow").waitToAppear()
XCTAssertFalse(app.element("selectionModePicker").exists)
}
func testCreateAppListFlowSelectsNewList() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
app.element("selectedAppsRow").waitToAppear().tap()
// Fresh install: no lists yet, so the picker offers creation.
app.element("emptyAppListsLabel").waitToAppear()
app.buttons["newAppListButton"].tap()
let nameField = app.textFields["appListNameField"].waitToAppear()
nameField.tap()
nameField.typeText("Focus Apps\n")
// Screen 1 lists the (empty) selection; Edit Apps pushes the Screen
// Time picker, whose Save returns here.
app.element("emptySelectionLabel").waitToAppear()
app.buttons["editAppsButton"].tap()
app.element("selectionCountLabel").waitToAppear()
app.buttons["confirmSelectionButton"].tap()
app.buttons["saveAppListButton"].waitToAppear().tap()
// Saving pops back to the picker with the new list selected.
app.element("appListRow-Focus Apps").waitToAppear()
app.buttons["closeAppListPickerButton"].tap()
// The editor row now reports the chosen list.
let row = app.element("selectedAppsRow").waitToAppear()
XCTAssertTrue(row.label.contains("Focus Apps"), "Got: \(row.label)")
}
func testDetailShowsAppListName() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Block").waitToAppear()
XCTAssertTrue(row.label.contains("Distractions"), "Got: \(row.label)")
}
func testHardModeSessionLocksAppListEditing() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
// "Sleep" is soft and editable even while "Locked In" hard-blocks.
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
app.element("selectedAppsRow").waitToAppear().tap()
app.element("appListRow-Distractions").waitToAppear()
app.element("appListsLockedNotice").waitToAppear()
XCTAssertFalse(
app.buttons["editAppListButton-Distractions"].exists,
"App lists must be read-only while a Hard Mode rule is blocking"
)
}
func testAppListsEditableWithoutHardSession() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
app.element("selectedAppsRow").waitToAppear().tap()
app.buttons["editAppListButton-Distractions"].waitToAppear()
XCTAssertFalse(app.element("appListsLockedNotice").exists)
}
}

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

@@ -0,0 +1,49 @@
//
// UsageUITests.swift
// OpenAppLockUITests
//
import XCTest
/// The Usage section under Blocked Apps seeded with limit rules at various
/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens,
/// "Doom Scroll" spent blocked).
final class UsageUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testUsageSectionShowsTimeAndOpenBudgets() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists)
let timeRow = app.element("usageRow-Time Keeper").waitToAppear()
XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)")
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
let openRow = app.element("usageRow-Gate Keeper").waitToAppear()
XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)")
XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)")
}
func testSpentBudgetShowsAsBlockedUntilTomorrow() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear()
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)")
// A spent budget is a real block: the rule surfaces in Blocked Apps.
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists)
}
func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap()
app.staticTexts["nothingBlockedLabel"].waitToAppear()
let row = app.element("usageRow-Doom Scroll").waitToAppear()
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")
}
}

19
Shared/AppGroup.swift Normal file
View File

@@ -0,0 +1,19 @@
//
// AppGroup.swift
// OpenAppLock
//
import Foundation
/// The app group shared by the app and its Screen Time extensions. Usage
/// tracking and rule snapshots live in its UserDefaults suite so the
/// DeviceActivity monitor and shield extensions can read and write them.
enum AppGroup {
static let identifier = "group.dev.bchen.OpenAppLock"
/// Shared defaults; falls back to standard defaults when the group
/// container is unavailable (e.g. entitlement not provisioned yet).
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

View File

@@ -0,0 +1,110 @@
//
// LimitEnforcement.swift
// OpenAppLock
//
import Foundation
/// Shared reactions to Screen Time events for limit rules, driven by the
/// snapshot store and usage ledger. The DeviceActivity monitor and shield
/// extensions call these; keeping the logic here makes it unit-testable from
/// the app target.
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;
/// time-limit rules start the day unshielded.
func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
!snapshot.isPaused(at: now)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
switch snapshot.kind {
case .schedule:
break
case .openLimit:
if snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
case .timeLimit:
if snapshot.limitReached(given: usage),
snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
}
}
/// A cumulative usage checkpoint fired for a time-limit rule.
func handleUsageMinutes(
_ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar)
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .timeLimit,
!snapshot.isPaused(at: now),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
if snapshot.limitReached(given: usage) {
shield(snapshot)
}
}
/// The wall-clock session granted by an "Open" press ended; the shield
/// returns so the next open costs another press.
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),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
shield(snapshot)
}
/// "Open" pressed on the shield. Spends one open and lifts the rule's
/// shield when the budget allows; returns whether a session was granted.
@discardableResult
func handleOpenRequest(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) -> Bool {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now)
else { return false }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
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
}
private func shield(_ snapshot: RuleSnapshot) {
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: false
)
}
}

View File

@@ -0,0 +1,85 @@
//
// MonitoringPlan.swift
// OpenAppLock
//
import Foundation
/// Naming conventions and event layouts shared by the app (which starts
/// DeviceActivity monitoring) and the monitor extension (which decodes what
/// fired).
enum MonitoringPlan {
private static let dailyPrefix = "rule-"
private static let sessionPrefix = "open-session-"
private static let minutePrefix = "minutes-"
private static let scheduleWindowPrefix = "sched-"
private static let scheduleWindowLatePrefix = "sched2-"
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
/// minimum schedule interval, so an "open" lasts at most this long before
/// the shield returns.
static let openSessionMinutes = 15
/// The always-on, midnight-to-midnight activity tracking a rule's day.
static func dailyActivityName(for ruleID: UUID) -> String {
dailyPrefix + ruleID.uuidString
}
/// The one-shot activity timing a granted open.
static func sessionActivityName(for ruleID: UUID) -> String {
sessionPrefix + ruleID.uuidString
}
static func ruleID(fromDailyActivityName name: String) -> UUID? {
guard name.hasPrefix(dailyPrefix) else { return nil }
return UUID(uuidString: String(name.dropFirst(dailyPrefix.count)))
}
static func ruleID(fromSessionActivityName name: String) -> UUID? {
guard name.hasPrefix(sessionPrefix) else { return nil }
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
}
/// The primary window activity for a schedule rule. A second
/// (`scheduleWindowLateName`) covers the post-midnight half of a window
/// that crosses midnight, since DeviceActivity can't express an interval
/// whose end is earlier than its start.
static func scheduleWindowName(for ruleID: UUID) -> String {
scheduleWindowPrefix + ruleID.uuidString
}
static func scheduleWindowLateName(for ruleID: UUID) -> String {
scheduleWindowLatePrefix + ruleID.uuidString
}
static func ruleID(fromScheduleWindowName name: String) -> UUID? {
if name.hasPrefix(scheduleWindowLatePrefix) {
return UUID(uuidString: String(name.dropFirst(scheduleWindowLatePrefix.count)))
}
if name.hasPrefix(scheduleWindowPrefix) {
return UUID(uuidString: String(name.dropFirst(scheduleWindowPrefix.count)))
}
return nil
}
static func minuteEventName(for minutes: Int) -> String {
minutePrefix + String(minutes)
}
static func minutes(fromEventName name: String) -> Int? {
guard name.hasPrefix(minutePrefix) else { return nil }
return Int(name.dropFirst(minutePrefix.count))
}
/// Cumulative-usage checkpoints for a time-limit rule: one event per
/// minute up to the budget so remaining time can be displayed live; the
/// final one doubles as the block trigger. (Budgets cap at 240 minutes,
/// comfortably inside DeviceActivity's event capacity.)
static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] {
Dictionary(
uniqueKeysWithValues: (1...max(1, limitMinutes)).map {
(minuteEventName(for: $0), $0)
}
)
}
}

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

114
Shared/RuleSnapshot.swift Normal file
View File

@@ -0,0 +1,114 @@
//
// RuleSnapshot.swift
// OpenAppLock
//
import Foundation
/// Codable mirror of a rule, written to the app group by the app whenever
/// rules change so the Screen Time extensions (which cannot open the
/// SwiftData store) know what to enforce.
struct RuleSnapshot: Codable, Equatable {
var id: UUID
var name: String
var kindRaw: String
var isEnabled: Bool
var hardMode: Bool
var blockAdultContent: Bool
var selectionModeRaw: String
var selectionData: Data?
var dayNumbers: [Int]
/// Schedule-window bounds, minutes from midnight (mirrors `BlockingRule`).
/// Only meaningful for `.schedule` rules; limit rules carry 0/0.
var startMinutes: Int
var endMinutes: Int
var dailyLimitMinutes: Int
var maxOpens: Int
var pausedUntil: Date?
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
var selectionMode: SelectionMode { SelectionMode(rawValue: selectionModeRaw) ?? .block }
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
/// The recurring time window this rule blocks, for schedule rules.
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
return false
}
return days.contains(weekday)
}
/// Whether the given usage exhausts this rule's daily budget.
func limitReached(given usage: RuleUsage) -> Bool {
switch kind {
case .schedule: false
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
case .openLimit: usage.opensUsed >= maxOpens
}
}
/// Whether the user unblocked this rule for the rest of the day.
func isPaused(at now: Date) -> Bool {
guard let pausedUntil else { return false }
return pausedUntil > now
}
}
extension RuleSnapshot {
private enum CodingKeys: String, CodingKey {
case id, name, kindRaw, isEnabled, hardMode, blockAdultContent
case selectionModeRaw, selectionData, dayNumbers, startMinutes, endMinutes
case dailyLimitMinutes, maxOpens, pausedUntil
}
/// Decodes tolerantly so snapshots written before `startMinutes`/`endMinutes`
/// existed still load (defaulting the window to 0) instead of failing the
/// whole batch which would blind the extensions until the app reopened.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
kindRaw = try container.decode(String.self, forKey: .kindRaw)
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
hardMode = try container.decode(Bool.self, forKey: .hardMode)
blockAdultContent = try container.decode(Bool.self, forKey: .blockAdultContent)
selectionModeRaw = try container.decode(String.self, forKey: .selectionModeRaw)
selectionData = try container.decodeIfPresent(Data.self, forKey: .selectionData)
dayNumbers = try container.decode([Int].self, forKey: .dayNumbers)
startMinutes = try container.decodeIfPresent(Int.self, forKey: .startMinutes) ?? 0
endMinutes = try container.decodeIfPresent(Int.self, forKey: .endMinutes) ?? 0
dailyLimitMinutes = try container.decode(Int.self, forKey: .dailyLimitMinutes)
maxOpens = try container.decode(Int.self, forKey: .maxOpens)
pausedUntil = try container.decodeIfPresent(Date.self, forKey: .pausedUntil)
}
}
/// Persistence for the rule mirror in the shared app-group defaults.
final class RuleSnapshotStore {
private static let key = "ruleSnapshots"
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
func save(_ snapshots: [RuleSnapshot]) {
guard let data = try? JSONEncoder().encode(snapshots) else { return }
defaults.set(data, forKey: Self.key)
}
func load() -> [RuleSnapshot] {
guard let data = defaults.data(forKey: Self.key),
let snapshots = try? JSONDecoder().decode([RuleSnapshot].self, from: data)
else { return [] }
return snapshots
}
func snapshot(for ruleID: UUID) -> RuleSnapshot? {
load().first { $0.id == ruleID }
}
}

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

@@ -13,18 +13,22 @@ protocol ShieldApplying: AnyObject {
func applyShield( func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) )
/// Clears the shield of a single rule (used by the extensions for day
/// resets and granted opens).
func clearShield(ruleID: UUID)
/// Clears every shield except those for the given rule IDs. Covers rules /// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running. /// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>) func clearShields(except activeRuleIDs: Set<UUID>)
} }
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names /// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
/// are tracked in UserDefaults because ManagedSettings cannot enumerate them. /// are tracked in the shared app-group defaults (ManagedSettings cannot
/// enumerate stores) so the app and extensions see one consistent set.
final class ManagedSettingsShieldController: ShieldApplying { final class ManagedSettingsShieldController: ShieldApplying {
private static let trackedIDsKey = "shieldedRuleIDs" private static let trackedIDsKey = "shieldedRuleIDs"
private let defaults: UserDefaults private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults self.defaults = defaults
} }
@@ -50,11 +54,15 @@ final class ManagedSettingsShieldController: ShieldApplying {
track(ruleID: ruleID) track(ruleID: ruleID)
} }
func clearShields(except activeRuleIDs: Set<UUID>) { func clearShield(ruleID: UUID) {
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
store(for: ruleID).clearAllSettings() store(for: ruleID).clearAllSettings()
untrack(ruleID: ruleID) untrack(ruleID: ruleID)
} }
func clearShields(except activeRuleIDs: Set<UUID>) {
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
clearShield(ruleID: ruleID)
}
} }
private func store(for ruleID: UUID) -> ManagedSettingsStore { private func store(for ruleID: UUID) -> ManagedSettingsStore {
@@ -82,6 +90,7 @@ final class MockShieldController: ShieldApplying {
private(set) var shieldedRuleIDs: Set<UUID> = [] private(set) var shieldedRuleIDs: Set<UUID> = []
private(set) var appliedModes: [UUID: SelectionMode] = [:] private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:] private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:]
func applyShield( func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
@@ -89,6 +98,14 @@ final class MockShieldController: ShieldApplying {
shieldedRuleIDs.insert(ruleID) shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode appliedModes[ruleID] = mode
appliedAdultContentFlags[ruleID] = blockAdultContent appliedAdultContentFlags[ruleID] = blockAdultContent
appliedSelectionData[ruleID] = selectionData
}
func clearShield(ruleID: UUID) {
shieldedRuleIDs.remove(ruleID)
appliedModes[ruleID] = nil
appliedAdultContentFlags[ruleID] = nil
appliedSelectionData[ruleID] = nil
} }
func clearShields(except activeRuleIDs: Set<UUID>) { func clearShields(except activeRuleIDs: Set<UUID>) {
@@ -97,6 +114,7 @@ final class MockShieldController: ShieldApplying {
appliedAdultContentFlags = appliedAdultContentFlags.filter { appliedAdultContentFlags = appliedAdultContentFlags.filter {
activeRuleIDs.contains($0.key) activeRuleIDs.contains($0.key)
} }
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
} }
} }

44
Shared/ShieldLookup.swift Normal file
View File

@@ -0,0 +1,44 @@
//
// ShieldLookup.swift
// OpenAppLock
//
import FamilyControls
import Foundation
import ManagedSettings
/// Matches shielded tokens back to the rule that shields them, so the shield
/// UI can offer "Open" with the right counts for open-limit rules.
enum ShieldLookup {
static func openLimitSnapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
static func openLimitSnapshot(
containingCategory token: ActivityCategoryToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.categoryTokens.contains(token)
}
}
/// Any enabled rule whose selection includes the application (for naming
/// plain blocked shields).
static func snapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
}

93
Shared/UsageLedger.swift Normal file
View File

@@ -0,0 +1,93 @@
//
// UsageLedger.swift
// OpenAppLock
//
import Foundation
/// What a limit rule has consumed on a given day. Written by the
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
/// read by the app for display and enforcement.
struct RuleUsage: Codable, Equatable {
var minutesUsed = 0
var opensUsed = 0
}
/// Read access to per-rule, per-day usage.
protocol UsageReading: AnyObject {
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
}
extension UsageReading {
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
usage(for: ruleID, onDayContaining: date, calendar: .current)
}
}
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
/// and rule. Old days are simply ignored; midnight needs no reset step.
final class UsageLedger: UsageReading {
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
/// "2026-06-12" calendar-date key so budgets roll over at midnight.
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
}
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
else { return RuleUsage() }
return usage
}
func setUsage(
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
guard let data = try? JSONEncoder().encode(usage) else { return }
defaults.set(data, forKey: key(ruleID, date, calendar))
}
/// Threshold events report cumulative totals, so minutes only move up.
func recordMinutesUsed(
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.minutesUsed = max(usage.minutesUsed, minutes)
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
}
@discardableResult
func recordOpen(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.opensUsed += 1
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
return usage
}
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
}
}
/// Seedable in-memory usage for tests and UI-test scenarios.
final class MockUsageLedger: UsageReading {
var usageByRule: [UUID: RuleUsage] = [:]
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
) -> RuleUsage {
usageByRule[ruleID] ?? RuleUsage()
}
}

View File

@@ -19,14 +19,35 @@ 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
- **App selection** — apps, categories, and websites; selection mode is either **Block** (block selected) or **Allow Only** (block everything except selected) - **App List** *(OpenAppLock refinement)* — each rule points to exactly one
**App List**: a named, reusable selection of apps/categories/websites stored
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").
- **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"
@@ -67,6 +88,17 @@ Dark theme throughout (near-black background, very dark green tint).
icons. Each icon has a lock badge overlay and a teal/green rounded-rect icons. Each icon has a lock badge overlay and a teal/green rounded-rect
outline; caption "Unblock" under the icon. Tapping unblocks (with friction outline; caption "Unblock" under the icon. Tapping unblocks (with friction
if hard mode — not demoed). if hard mode — not demoed).
*(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day
also appear here, blocked until midnight.
3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section
directly below Blocked Apps showing live tracking for every enabled
Time/Open Limit rule scheduled today:
- Time Limit row: subtitle "18m of 45m used today", trailing "27m left";
when spent: "Blocked until tomorrow" (red).
- Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left";
when spent: "Blocked until tomorrow" (red).
Usage numbers come from the shared app-group ledger written by the
DeviceActivity monitor and shield-action extensions.
3. **Rules** — header row: "Rules " (leading, tappable to a full list, 3. **Rules** — header row: "Rules " (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet. sheet.
@@ -147,15 +179,20 @@ Sections (each an inset rounded group with a small icon + caption header):
row of 7 circular toggles `S M T W T F S`; selected = filled white circle row of 7 circular toggles `S M T W T F S`; selected = filled white circle
with black letter, unselected = dark circle. with black letter, unselected = dark circle.
3. **🛡 Apps are blocked** 3. **🛡 Apps are blocked**
- Row: `Selected Apps``N Apps ` — pushes the App Picker. - Row: `App List``<list name> · N Apps ` (or `Choose ` when none) —
presents the App List picker.
- A segmented `Block | Allow Only` row (Schedule editor only) chooses how
the rule interprets its list; the section header reads "Apps are
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
@@ -174,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)
@@ -209,8 +245,22 @@ Full-height sheet:
> parties cannot enumerate installed apps; the system-sanctioned route is > parties cannot enumerate installed apps; the system-sanctioned route is
> `FamilyActivityPicker` (FamilyControls), which provides its own > `FamilyActivityPicker` (FamilyControls), which provides its own
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should > category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker, and > embed `FamilyActivityPicker`** instead of cloning Opal's custom picker.
> keep the `Block`/`Allow Only` segmented control as our own wrapper state. >
> **App Lists (OpenAppLock):** the selection itself lives on a reusable
> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`).
> The editor's App List row presents a picker sheet listing saved lists
> (checkmark on the rule's current list; tap to select), an Edit affordance
> per list, and a "New List" flow — a name field plus an embedded
> `FamilyActivityPicker`. The `Block`/`Allow Only` segmented control lives in
> the Schedule rule editor (it is rule state, not list state). Legacy rules
> that stored an inline selection are migrated at launch: one list per
> distinct selection (rules sharing identical selection data share a list),
> named "<rule name> Apps". Lists in use by a rule cannot be deleted from the
> picker. While any **Hard Mode** rule is actively blocking, all lists are
> read-only — the picker hides Edit/Delete and shows a lock notice — because
> editing a list would be a back door out of the hard block. Creating new
> lists and selecting lists for other rules remain available.
--- ---
@@ -230,6 +280,28 @@ Full-height sheet:
4. **Time-limit rules** — accumulate usage daily across the selected apps; 4. **Time-limit rules** — accumulate usage daily across the selected apps;
on crossing the threshold, shield until the `Until` reset point on crossing the threshold, shield until the `Until` reset point
(e.g. tomorrow), then reset the budget. (e.g. tomorrow), then reset the budget.
*(OpenAppLock specifics)*: usage lives in a per-rule, per-day **usage
ledger** in the app group. A limit rule's derived status becomes
`active(until: next midnight)` once the ledger reports the budget spent on
an enabled day — it then surfaces in Blocked Apps, Hard Mode gating
applies, and a soft unblock pauses it until midnight. Open-limit rules
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). 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.
@@ -238,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.
--- ---
@@ -262,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 selectionData: Data // encoded FamilyActivitySelection
var days: [Int] // 1...7, Calendar weekday numbers var days: [Int] // 1...7, Calendar weekday numbers
// schedule var pausedUntil: Date?
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
@@ -324,7 +438,65 @@ enum SelectionMode: String, Codable { case block, allowOnly }
6. Preset gallery content + polish (gradients, photos, haptics, live 6. Preset gallery content + polish (gradients, photos, haptics, live
countdown timers). countdown timers).
### 5.5 Out of scope (seen in video, not part of "rules") ### 5.5 Background enforcement architecture (implemented)
- **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), `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
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
event per budget minute (`minutes-<k>`) over the rule's app list.
- **Schedule rules** — one (or, for windows that cross midnight, two)
repeating window activit(ies) per rule matching the rule's
`From…To` window (`sched-<uuid>` and, for the post-midnight half,
`sched2-<uuid>`). These carry no events; they exist purely to wake the
monitor at the window edges so shields engage **in the background even
when the app is closed**. A window that ends exactly at midnight, or is
shorter than DeviceActivity's 15-minute minimum interval, may fail to
register (`intervalTooShort`) and falls back to the foreground loop.
Activities restart only when their configuration changes, because a
restart resets threshold accounting.
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
= midnight reset for limit rules (open-limit rules re-shield so opens can
be counted; time-limit shields clear for the fresh budget); each
`minutes-<k>` event records usage and shields at the budget; a finished
`open-session-<uuid>` one-shot re-shields after a granted open. For
schedule-window activities (`sched-`/`sched2-`), **both** interval start
and interval end **recompute** the rule's live schedule state from its
snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the
midnight-crossing rule) and apply or clear the shield accordingly — the
same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths
agree.
- **Reliability posture** — DeviceActivity interval callbacks are
"first device use after the boundary", are known to fire late or be
skipped (device asleep, OS regressions on iOS 17/18/26), and a shield
written over an app the user already has open may not visibly engage until
that app is relaunched (a long-standing Screen Time platform limitation).
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
(launch + 30 s foreground loop) is retained as the reconciliation safety
net and is the source of truth whenever the app runs. To keep that net
consistent with the background, `refresh` applies the **same** open-limit
proactive gate as `handleDayStart`: an enabled, scheduled-today, un-paused
open-limit rule is shielded even before its budget is spent, *unless* the
`OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name.
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, 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.
### 5.6 Out of scope (seen in video, not part of "rules")
- Onboarding flow, paywall ("You know Opal works. Make it permanent."), - Onboarding flow, paywall ("You know Opal works. Make it permanent."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?" Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"