Compare commits
9 Commits
e91d8344a9
...
fix/open-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 31830cb141 | |||
| fc0f518608 | |||
| 52802a9984 | |||
| 9092221d32 | |||
| 1652c2f410 | |||
| 8e3b8b70f5 | |||
| cb9b2e8950 | |||
| 93f2eceb3a | |||
| 05d48a9f34 |
78
AGENTS.md
78
AGENTS.md
@@ -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.
|
||||||
|
|||||||
@@ -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 */;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
OpenAppLock/Logic/UsageDisplay.swift
Normal file
40
OpenAppLock/Logic/UsageDisplay.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
OpenAppLock/Models/AppList.swift
Normal file
47
OpenAppLock/Models/AppList.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
/// app's defaults (9–5 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
45
OpenAppLock/Services/AppListMigration.swift
Normal file
45
OpenAppLock/Services/AppListMigration.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
268
OpenAppLock/Services/RuleScheduler.swift
Normal file
268
OpenAppLock/Services/RuleScheduler.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
179
OpenAppLock/Views/AppLists/AppListEditorView.swift
Normal file
179
OpenAppLock/Views/AppLists/AppListEditorView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
158
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal file
158
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ",")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
62
OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Normal file
62
OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
OpenAppLockMonitor/Info.plist
Normal file
13
OpenAppLockMonitor/Info.plist
Normal 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>
|
||||||
12
OpenAppLockMonitor/OpenAppLockMonitor.entitlements
Normal file
12
OpenAppLockMonitor/OpenAppLockMonitor.entitlements
Normal 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>
|
||||||
13
OpenAppLockShieldAction/Info.plist
Normal file
13
OpenAppLockShieldAction/Info.plist
Normal 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>
|
||||||
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal file
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal 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>
|
||||||
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal file
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
OpenAppLockShieldConfig/Info.plist
Normal file
13
OpenAppLockShieldConfig/Info.plist
Normal 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>
|
||||||
12
OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements
Normal file
12
OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements
Normal 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>
|
||||||
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal file
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
249
OpenAppLockTests/AppListTests.swift
Normal file
249
OpenAppLockTests/AppListTests.swift
Normal 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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// RuleConfigurationTests.swift
|
||||||
|
// OpenAppLockTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import OpenAppLock
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("RuleConfiguration sum type")
|
||||||
|
struct RuleConfigurationTests {
|
||||||
|
@Test("Each case reports its kind")
|
||||||
|
func kindDerivation() {
|
||||||
|
#expect(RuleConfiguration.schedule(ScheduleConfig()).kind == .schedule)
|
||||||
|
#expect(RuleConfiguration.timeLimit(TimeLimitConfig()).kind == .timeLimit)
|
||||||
|
#expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Defaults match the reference app's new-rule defaults")
|
||||||
|
func defaults() {
|
||||||
|
let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig
|
||||||
|
#expect(schedule?.startMinutes == 9 * 60)
|
||||||
|
#expect(schedule?.endMinutes == 17 * 60)
|
||||||
|
#expect(schedule?.selectionMode == .block)
|
||||||
|
#expect(schedule?.blockAdultContent == false)
|
||||||
|
|
||||||
|
#expect(RuleConfiguration.default(for: .timeLimit).timeLimitConfig?.dailyLimitMinutes == 45)
|
||||||
|
#expect(RuleConfiguration.default(for: .openLimit).openLimitConfig?.maxOpens == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Typed projections only unwrap the matching case")
|
||||||
|
func projections() {
|
||||||
|
let schedule = RuleConfiguration.schedule(ScheduleConfig(blockAdultContent: true))
|
||||||
|
#expect(schedule.scheduleConfig?.blockAdultContent == true)
|
||||||
|
#expect(schedule.timeLimitConfig == nil)
|
||||||
|
#expect(schedule.openLimitConfig == nil)
|
||||||
|
|
||||||
|
let openLimit = RuleConfiguration.openLimit(OpenLimitConfig(maxOpens: 7))
|
||||||
|
#expect(openLimit.openLimitConfig?.maxOpens == 7)
|
||||||
|
#expect(openLimit.scheduleConfig == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ struct RuleEnforcerTests {
|
|||||||
func skipsTimeLimitRules() {
|
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:00–17:00, active now
|
||||||
|
let timeLimit = timeLimitRule()
|
||||||
|
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // spent → blocking
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
|
// Neither cancels the other: both carry their own shield.
|
||||||
|
#expect(shields.shieldedRuleIDs == [schedule.id, timeLimit.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The first limit to be spent blocks, whatever the other's budget")
|
||||||
|
func firstSpentLimitBlocks() {
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let ledger = MockUsageLedger()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
|
let openLimit = openLimitRule(maxOpens: 5)
|
||||||
|
let timeLimit = timeLimitRule(limit: 45)
|
||||||
|
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1) // opens remain
|
||||||
|
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // budget spent
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
|
// Time-limit blocks (spent) while the open-limit still gates its turnstile.
|
||||||
|
#expect(shields.shieldedRuleIDs == [openLimit.id, timeLimit.id])
|
||||||
|
// Only the spent budget counts as "Blocked Apps"; the gate shows in Usage.
|
||||||
|
#expect(enforcer.blockingRuleIDs == [timeLimit.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A time limit blocks even during an open-limit's granted session")
|
||||||
|
func timeLimitBlocksDuringGrantedOpen() {
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let ledger = MockUsageLedger()
|
||||||
|
let sessions = MockOpenSessionStore()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
|
||||||
|
let openLimit = openLimitRule(maxOpens: 5)
|
||||||
|
let timeLimit = timeLimitRule(limit: 45)
|
||||||
|
sessions.activeRuleIDs = [openLimit.id] // user is mid-open on the shared app
|
||||||
|
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1)
|
||||||
|
// The metered minutes during the open push the time limit over budget.
|
||||||
|
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45)
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
|
// The open-limit stays lifted (its session is sanctioned), but the time
|
||||||
|
// limit shields the app anyway — strictest wins.
|
||||||
|
#expect(shields.shieldedRuleIDs == [timeLimit.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Spent opens reset the next day: re-gated, not blocked")
|
||||||
|
func opensResetNextDay() {
|
||||||
|
let suite = "overlap-tests-\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let ledger = UsageLedger(defaults: defaults)
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
|
let rule = openLimitRule(maxOpens: 5)
|
||||||
|
let yesterday = date(2025, 1, 5, 10, 0)
|
||||||
|
let today = date(2025, 1, 6, 10, 0)
|
||||||
|
for _ in 0..<5 {
|
||||||
|
ledger.recordOpen(for: rule.id, onDayContaining: yesterday, calendar: utc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday: budget exhausted → blocking.
|
||||||
|
enforcer.refresh(rules: [rule], at: yesterday, calendar: utc)
|
||||||
|
#expect(enforcer.blockingRuleIDs == [rule.id])
|
||||||
|
|
||||||
|
// Today: fresh budget → not blocking, but the turnstile is back up.
|
||||||
|
enforcer.refresh(rules: [rule], at: today, calendar: utc)
|
||||||
|
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||||||
|
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ struct RuleModelTests {
|
|||||||
|
|
||||||
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
610
OpenAppLockTests/SchedulingTests.swift
Normal file
610
OpenAppLockTests/SchedulingTests.swift
Normal 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:00–17:00 weekday window.
|
||||||
|
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||||
|
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||||
|
|
||||||
|
private func makeEnforcement() -> (ScheduleEnforcement, MockShieldController, RuleSnapshotStore) {
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||||
|
return (ScheduleEnforcement(snapshots: store, shields: shields), shields, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshot(
|
||||||
|
start: Int = 9 * 60, end: Int = 17 * 60, days: Set<Weekday> = Weekday.everyDay,
|
||||||
|
isEnabled: Bool = true, mode: SelectionMode = .block, pausedUntil: Date? = nil
|
||||||
|
) -> RuleSnapshot {
|
||||||
|
RuleSnapshot(
|
||||||
|
id: UUID(), name: "Work Time", kindRaw: RuleKind.schedule.rawValue,
|
||||||
|
isEnabled: isEnabled, hardMode: false, blockAdultContent: false,
|
||||||
|
selectionModeRaw: mode.rawValue, selectionData: Data([1]),
|
||||||
|
dayNumbers: days.map(\.rawValue), startMinutes: start, endMinutes: end,
|
||||||
|
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: pausedUntil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reconcile shields a rule whose window is active now")
|
||||||
|
func shieldsActiveWindow() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot()
|
||||||
|
store.save([snap])
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reconcile clears a rule whose window is over")
|
||||||
|
func clearsInactiveWindow() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot()
|
||||||
|
store.save([snap])
|
||||||
|
shields.applyShield(
|
||||||
|
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: mondayEvening, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reconcile forwards the rule's selection mode")
|
||||||
|
func forwardsSelectionMode() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot(mode: .allowOnly)
|
||||||
|
store.save([snap])
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.appliedModes[snap.id] == .allowOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A disabled schedule rule is never shielded")
|
||||||
|
func skipsDisabled() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot(isEnabled: false)
|
||||||
|
store.save([snap])
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A paused (unblocked) schedule rule is left clear")
|
||||||
|
func skipsPaused() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot(pausedUntil: date(2025, 1, 6, 17, 0))
|
||||||
|
store.save([snap])
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A midnight-crossing window is active in the early morning")
|
||||||
|
func shieldsCrossingWindowAfterMidnight() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
// 22:00–06:00 every day; 02:00 belongs to the window that started the
|
||||||
|
// previous evening (so the previous day must be enabled — it is).
|
||||||
|
let snap = snapshot(start: 22 * 60, end: 6 * 60)
|
||||||
|
store.save([snap])
|
||||||
|
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 6, 2, 0), calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A weekday window is inactive on the weekend")
|
||||||
|
func clearsOnDisabledDay() {
|
||||||
|
let (enforcement, shields, store) = makeEnforcement()
|
||||||
|
let snap = snapshot(days: Weekday.weekdays)
|
||||||
|
store.save([snap])
|
||||||
|
// 2025-01-11 is a Saturday.
|
||||||
|
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 11, 10, 0), calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
257
OpenAppLockTests/UsageTests.swift
Normal file
257
OpenAppLockTests/UsageTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
114
OpenAppLockUITests/AppListUITests.swift
Normal file
114
OpenAppLockUITests/AppListUITests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
49
OpenAppLockUITests/UsageUITests.swift
Normal file
49
OpenAppLockUITests/UsageUITests.swift
Normal 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
19
Shared/AppGroup.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
110
Shared/LimitEnforcement.swift
Normal file
110
Shared/LimitEnforcement.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Shared/MonitoringPlan.swift
Normal file
85
Shared/MonitoringPlan.swift
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Shared/OpenSessionStore.swift
Normal file
59
Shared/OpenSessionStore.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// OpenSessionStore.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Read access to in-progress granted "Open" sessions, keyed by rule.
|
||||||
|
protocol OpenSessionReading: AnyObject {
|
||||||
|
/// Whether a granted open for `ruleID` is still running at `now`.
|
||||||
|
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records when a granted "Open" expires, per rule, in the shared app-group
|
||||||
|
/// defaults. Pressing the shield's "Open" button lifts an open-limit rule's
|
||||||
|
/// shield for ~15 minutes (`MonitoringPlan.openSessionMinutes`); this marker
|
||||||
|
/// lets the foreground enforcer leave that one rule un-shielded for the life of
|
||||||
|
/// the session instead of re-locking the app mid-session. The monitor clears it
|
||||||
|
/// when the session's one-shot activity ends.
|
||||||
|
final class OpenSessionStore: OpenSessionReading {
|
||||||
|
private static let key = "openSessionExpiry"
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasActiveSession(for ruleID: UUID, at now: Date = .now) -> Bool {
|
||||||
|
guard let expiry = expiries[ruleID.uuidString] else { return false }
|
||||||
|
return Date(timeIntervalSince1970: expiry) > now
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a granted open for `ruleID` running until `expiry`.
|
||||||
|
func startSession(for ruleID: UUID, until expiry: Date) {
|
||||||
|
var map = expiries
|
||||||
|
map[ruleID.uuidString] = expiry.timeIntervalSince1970
|
||||||
|
defaults.set(map, forKey: Self.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends a granted open (its one-shot activity fired, or it is being reset).
|
||||||
|
func endSession(for ruleID: UUID) {
|
||||||
|
var map = expiries
|
||||||
|
map[ruleID.uuidString] = nil
|
||||||
|
defaults.set(map, forKey: Self.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expiries: [String: TimeInterval] {
|
||||||
|
defaults.dictionary(forKey: Self.key) as? [String: TimeInterval] ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory granted sessions for tests and UI-test launches.
|
||||||
|
final class MockOpenSessionStore: OpenSessionReading {
|
||||||
|
var activeRuleIDs: Set<UUID> = []
|
||||||
|
|
||||||
|
func hasActiveSession(for ruleID: UUID, at now: Date) -> Bool {
|
||||||
|
activeRuleIDs.contains(ruleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Shared/RuleConfiguration.swift
Normal file
90
Shared/RuleConfiguration.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// RuleConfiguration.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The kind-specific options of a rule, modelled as a sum type so a rule can
|
||||||
|
/// only carry the options that belong to its kind. This makes illegal states
|
||||||
|
/// unrepresentable: an Open Limit rule cannot hold a time window, a Time Limit
|
||||||
|
/// rule cannot hold a Block/Allow-Only mode, and neither limit kind can hold
|
||||||
|
/// Block Adult Content — those are Schedule-only options.
|
||||||
|
///
|
||||||
|
/// Kind-common attributes (name, days, hardMode, isEnabled, appList,
|
||||||
|
/// pausedUntil) live on the owning `BlockingRule` / `RuleDraft`, not here.
|
||||||
|
enum RuleConfiguration: Hashable, Sendable {
|
||||||
|
case schedule(ScheduleConfig)
|
||||||
|
case timeLimit(TimeLimitConfig)
|
||||||
|
case openLimit(OpenLimitConfig)
|
||||||
|
|
||||||
|
var kind: RuleKind {
|
||||||
|
switch self {
|
||||||
|
case .schedule: .schedule
|
||||||
|
case .timeLimit: .timeLimit
|
||||||
|
case .openLimit: .openLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default configuration for a brand-new rule of the given kind, using
|
||||||
|
/// the reference app's defaults (9–5 schedule, 45m/day, 5 opens/day).
|
||||||
|
static func `default`(for kind: RuleKind) -> RuleConfiguration {
|
||||||
|
switch kind {
|
||||||
|
case .schedule: .schedule(ScheduleConfig())
|
||||||
|
case .timeLimit: .timeLimit(TimeLimitConfig())
|
||||||
|
case .openLimit: .openLimit(OpenLimitConfig())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleConfig: ScheduleConfig? {
|
||||||
|
if case .schedule(let config) = self { config } else { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeLimitConfig: TimeLimitConfig? {
|
||||||
|
if case .timeLimit(let config) = self { config } else { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var openLimitConfig: OpenLimitConfig? {
|
||||||
|
if case .openLimit(let config) = self { config } else { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule-rule options: a recurring time window, how the app list is
|
||||||
|
/// interpreted, and whether the adult-website filter engages while the window
|
||||||
|
/// is active. A window whose end is at or before its start crosses midnight.
|
||||||
|
struct ScheduleConfig: Hashable, Sendable {
|
||||||
|
var startMinutes: Int
|
||||||
|
var endMinutes: Int
|
||||||
|
var selectionMode: SelectionMode
|
||||||
|
var blockAdultContent: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
startMinutes: Int = 9 * 60,
|
||||||
|
endMinutes: Int = 17 * 60,
|
||||||
|
selectionMode: SelectionMode = .block,
|
||||||
|
blockAdultContent: Bool = false
|
||||||
|
) {
|
||||||
|
self.startMinutes = startMinutes
|
||||||
|
self.endMinutes = endMinutes
|
||||||
|
self.selectionMode = selectionMode
|
||||||
|
self.blockAdultContent = blockAdultContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time Limit option: a daily cumulative-usage budget in minutes.
|
||||||
|
struct TimeLimitConfig: Hashable, Sendable {
|
||||||
|
var dailyLimitMinutes: Int
|
||||||
|
|
||||||
|
init(dailyLimitMinutes: Int = 45) {
|
||||||
|
self.dailyLimitMinutes = dailyLimitMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open Limit option: a daily budget of app opens.
|
||||||
|
struct OpenLimitConfig: Hashable, Sendable {
|
||||||
|
var maxOpens: Int
|
||||||
|
|
||||||
|
init(maxOpens: Int = 5) {
|
||||||
|
self.maxOpens = maxOpens
|
||||||
|
}
|
||||||
|
}
|
||||||
114
Shared/RuleSnapshot.swift
Normal file
114
Shared/RuleSnapshot.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Shared/ScheduleEnforcement.swift
Normal file
37
Shared/ScheduleEnforcement.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// ScheduleEnforcement.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Background reaction for schedule (time-window) rules. The monitor extension
|
||||||
|
/// calls `reconcile` at each window boundary; it recomputes the rule's live
|
||||||
|
/// schedule state from its snapshot and applies or clears the shield to match.
|
||||||
|
///
|
||||||
|
/// This intentionally mirrors what `RuleEnforcer.refresh` does for schedule
|
||||||
|
/// rules in the foreground, so the background and foreground paths never
|
||||||
|
/// disagree. Recomputing (rather than blindly shielding on start / clearing on
|
||||||
|
/// end) also makes the two activities of a midnight-crossing window — and any
|
||||||
|
/// late or duplicated interval callback — converge on the correct state.
|
||||||
|
struct ScheduleEnforcement {
|
||||||
|
let snapshots: RuleSnapshotStore
|
||||||
|
let shields: ShieldApplying
|
||||||
|
|
||||||
|
func reconcile(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
|
||||||
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.kind == .schedule else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if snapshot.isEnabled, !snapshot.isPaused(at: now),
|
||||||
|
snapshot.schedule.isActive(at: now, calendar: calendar) {
|
||||||
|
shields.applyShield(
|
||||||
|
ruleID: snapshot.id,
|
||||||
|
selectionData: snapshot.selectionData,
|
||||||
|
mode: snapshot.selectionMode,
|
||||||
|
blockAdultContent: snapshot.blockAdultContent
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
shields.clearShield(ruleID: snapshot.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
44
Shared/ShieldLookup.swift
Normal 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
93
Shared/UsageLedger.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:00–23:59 activity per rule
|
||||||
|
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||||
|
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||||
|
- **Schedule rules** — one (or, for windows that cross midnight, two)
|
||||||
|
repeating window activit(ies) per rule matching the rule's
|
||||||
|
`From…To` window (`sched-<uuid>` and, for the post-midnight half,
|
||||||
|
`sched2-<uuid>`). These carry no events; they exist purely to wake the
|
||||||
|
monitor at the window edges so shields engage **in the background even
|
||||||
|
when the app is closed**. A window that ends exactly at midnight, or is
|
||||||
|
shorter than DeviceActivity's 15-minute minimum interval, may fail to
|
||||||
|
register (`intervalTooShort`) and falls back to the foreground loop.
|
||||||
|
Activities restart only when their configuration changes, because a
|
||||||
|
restart resets threshold accounting.
|
||||||
|
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
|
||||||
|
= midnight reset 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?"
|
||||||
|
|||||||
Reference in New Issue
Block a user