Compare commits

...

5 Commits

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

View File

@@ -10,18 +10,32 @@ feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
```
OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData)
Models/ BlockingRule (@Model), RuleDraft, RuleKind,
Weekday, RulePreset
Models/ BlockingRule + AppList (@Model), RuleDraft,
RulePreset
Logic/ Pure, heavily unit-tested:
RuleSchedule (window math, incl. overnight),
RuleStatus (derived status + labels),
RulePolicy (Hard Mode gating, unblock/pause)
RuleStatus (derived status + labels, usage-aware),
RulePolicy (Hard Mode gating, unblock/pause,
app-list lock), UsageDisplay (Usage-section text)
Services/ ScreenTimeAuthorization (FamilyControls behind a
protocol + mock), ShieldController (ManagedSettings
shields + adult-content web filter + mock),
RuleEnforcer (active rules → shields),
LaunchConfiguration + SampleRules (UI-test harness)
protocol + mock), RuleEnforcer (rules → shields),
RuleScheduler (rules → DeviceActivity monitoring),
AppListMigration, LaunchConfiguration +
SampleRules (UI-test harness)
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
target defaults to MainActor isolation)
OpenAppLockUITests/ XCUITest flows (see harness below)
@@ -98,23 +112,46 @@ them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.
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:
.combine)` (or a Button/control) to be queryable.
- List/Form section headers render uppercased unless `.textCase(nil)` —
tests assert exact header strings.
- Inside tinted Button rows, hierarchical `.primary`/`.secondary` foreground
styles resolve to the tint; use concrete `Color.primary`/`Color.secondary`.
- Inside tinted Button rows, hierarchical `.primary`/`.secondary`/`.tertiary`
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[...]`
(a bare `buttons["Unblock"]` is ambiguous with the row label).
## Known gaps / next steps
- **No DeviceActivityMonitor extension yet**: shields apply/clear only while
the app runs (launch, foreground 30s loop). Background window transitions,
and any real enforcement for Time Limit / Open Limit rules (usage
thresholds), require adding the extension target + app group.
- Time Limit / Open Limit rules are fully modeled, editable, and displayed,
but not enforced (see above).
- **On-device verification of limit enforcement is pending.** The
DeviceActivity monitor + shield extensions and the app group are in place,
but real blocking/usage tracking is only observable on a device (the
simulator neither tracks usage nor renders custom shields). Verify: time
limits accrue in the Usage section and block at the budget; open-limit
apps shield immediately with an "Open (N left)" button; an open lasts
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
- **Schedule-rule background transitions** still rely on the app running
(launch / foreground 30s loop); schedule rules have no DeviceActivity
monitoring yet — only limit rules do.
- `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
entitlement; development builds work with the dev entitlement.
entitlement **for the app and each extension bundle ID**
(`dev.bchen.OpenAppLock`, `.Monitor`, `.ShieldConfig`, `.ShieldAction`);
development builds work with the dev entitlement.

View File

@@ -6,7 +6,34 @@
objectVersion = 77;
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 */
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;
};
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
@@ -27,6 +54,9 @@
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; };
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 */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -45,8 +75,77 @@
path = OpenAppLockUITests;
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 */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000001 /* OpenAppLockMonitor */;
};
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
};
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXCopyFilesBuildPhase section */
E10000000000000000000001 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -69,6 +168,27 @@
);
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 */
/* Begin PBXGroup section */
@@ -76,6 +196,10 @@
isa = PBXGroup;
children = (
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
AA00000000000000000000A1 /* Shared */,
AA00000000000000000000A2 /* OpenAppLockMonitor */,
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
20A7EDE02E47B7CF0097608D /* OpenAppLockTests */,
20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */,
20A7EDCF2E47B7CD0097608D /* Products */,
@@ -88,6 +212,9 @@
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */,
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */,
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */,
B10000000000000000000001 /* OpenAppLockMonitor.appex */,
B10000000000000000000002 /* OpenAppLockShieldConfig.appex */,
B10000000000000000000003 /* OpenAppLockShieldAction.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -102,13 +229,18 @@
20A7EDCA2E47B7CD0097608D /* Sources */,
20A7EDCB2E47B7CD0097608D /* Frameworks */,
20A7EDCC2E47B7CD0097608D /* Resources */,
E10000000000000000000001 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
E30000000000000000000001 /* PBXTargetDependency */,
E30000000000000000000002 /* PBXTargetDependency */,
E30000000000000000000003 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
AA00000000000000000000A1 /* Shared */,
);
name = OpenAppLock;
packageProductDependencies = (
@@ -163,6 +295,75 @@
productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */;
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 = (
AA00000000000000000000A2 /* OpenAppLockMonitor */,
AA00000000000000000000A1 /* Shared */,
);
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 = (
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
AA00000000000000000000A1 /* Shared */,
);
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 = (
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
AA00000000000000000000A1 /* Shared */,
);
name = OpenAppLockShieldAction;
packageProductDependencies = (
);
productName = OpenAppLockShieldAction;
productReference = B10000000000000000000003 /* OpenAppLockShieldAction.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -203,6 +404,9 @@
20A7EDCD2E47B7CD0097608D /* OpenAppLock */,
20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */,
20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */,
C10000000000000000000001 /* OpenAppLockMonitor */,
C10000000000000000000002 /* OpenAppLockShieldConfig */,
C10000000000000000000003 /* OpenAppLockShieldAction */,
);
};
/* End PBXProject section */
@@ -229,6 +433,27 @@
);
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 */
/* Begin PBXSourcesBuildPhase section */
@@ -253,6 +478,27 @@
);
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 */
/* Begin PBXTargetDependency section */
@@ -266,6 +512,21 @@
target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */;
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 */
/* Begin XCBuildConfiguration section */
@@ -569,6 +830,168 @@
};
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 */
/* Begin XCConfigurationList section */
@@ -608,6 +1031,33 @@
defaultConfigurationIsVisible = 0;
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 */
};
rootObject = 20A7EDC62E47B7CD0097608D /* Project object */;

View File

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

View File

@@ -8,58 +8,88 @@ import Foundation
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
/// while a hard-mode rule is actively blocking, nothing about it can be
/// 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 {
/// True while the rule is actively blocking with Hard Mode on.
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 {
rule.hardMode && rule.status(at: now, calendar: calendar).isActive
rule.hardMode && rule.status(at: now, calendar: calendar, usage: usage).isActive
}
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 {
!isHardLocked(rule, at: now, calendar: calendar)
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
}
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 {
!isHardLocked(rule, at: now, calendar: calendar)
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
}
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 {
!isHardLocked(rule, at: now, calendar: calendar)
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
}
/// 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(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
_ rule: BlockingRule, usage: RuleUsage? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> 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
/// actively blocking that is the whole point of a hard block.
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 {
!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)
/// when unblocking is not allowed.
/// App lists feed active shields, so while any hard-mode rule is actively
/// 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
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 {
guard canUnblock(rule, at: now, calendar: calendar),
let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
guard canUnblock(rule, usage: usage, at: now, calendar: calendar) else { return false }
switch rule.kind {
case .schedule:
guard let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
else { return false }
rule.pausedUntil = window.end
case .timeLimit, .openLimit:
guard let midnight = calendar.nextMidnight(after: now) else { return false }
rule.pausedUntil = midnight
}
return true
}
}

View File

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

View File

@@ -0,0 +1,40 @@
//
// UsageDisplay.swift
// OpenAppLock
//
import Foundation
/// Strings for the home screen's Usage section. Used values clamp to the
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
enum UsageDisplay {
/// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.kind {
case .schedule:
""
case .timeLimit:
"\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of "
+ "\(rule.dailyLimitMinutes)m used today"
case .openLimit:
"\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today"
}
}
/// "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.kind {
case .schedule:
return ""
case .timeLimit:
return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left"
case .openLimit:
let remaining = rule.maxOpens - usage.opensUsed
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
}
}
}

View File

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

View File

@@ -23,9 +23,17 @@ final class BlockingRule {
/// Inline default so existing stores migrate cleanly.
var blockAdultContent: Bool = false
var selectionModeRaw: String
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
/// The reusable app list this rule blocks (or allows, in Allow Only mode).
///
/// Deliberately not an `init` parameter: SwiftData relationship properties
/// must only be assigned once both models are inserted in a context
/// 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?
/// Denormalized count of selected apps/categories/domains for display.
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
var selectionCount: Int
var dayNumbers: [Int]
var startMinutes: Int

View File

@@ -4,6 +4,7 @@
//
import Foundation
import SwiftData
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model. Hashable so it can drive
@@ -19,8 +20,10 @@ struct RuleDraft: Hashable {
var hardMode: Bool
var blockAdultContent: Bool
var selectionMode: SelectionMode
var selectionData: Data?
var selectionCount: Int
/// Reference to the persisted list the rule will use. App lists are
/// managed (created/edited) directly by the picker, so the draft only
/// carries the pointer.
var appList: AppList?
/// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
@@ -35,8 +38,7 @@ struct RuleDraft: Hashable {
self.hardMode = false
self.blockAdultContent = false
self.selectionMode = .block
self.selectionData = nil
self.selectionCount = 0
self.appList = nil
}
init(rule: BlockingRule) {
@@ -50,8 +52,7 @@ struct RuleDraft: Hashable {
self.hardMode = rule.hardMode
self.blockAdultContent = rule.blockAdultContent
self.selectionMode = rule.selectionMode
self.selectionData = rule.selectionData
self.selectionCount = rule.selectionCount
self.appList = rule.appList
}
init(preset: RulePreset) {
@@ -62,6 +63,9 @@ struct RuleDraft: Hashable {
self.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) {
rule.name = name
rule.kind = kind
@@ -73,22 +77,32 @@ struct RuleDraft: Hashable {
rule.hardMode = hardMode
rule.blockAdultContent = blockAdultContent
rule.selectionMode = selectionMode
rule.selectionData = selectionData
rule.selectionCount = selectionCount
if rule.appList !== appList {
rule.appList = appList
}
}
func makeRule() -> BlockingRule {
/// Creates and inserts a new rule from this draft. The rule is inserted
/// *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, kind: kind)
context.insert(rule)
apply(to: rule)
return rule
}
/// Trims the name and falls back to the kind's default when it is empty,
/// so a cleared name field can never produce an unnamed rule.
/// Trims the name (falling back to the kind's default when it is empty)
/// and forces Block mode for time- and open-limit rules Allow Only is a
/// schedule-rule concept.
func sanitized() -> RuleDraft {
var copy = self
let trimmed = name.trimmingCharacters(in: .whitespaces)
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
if kind != .schedule {
copy.selectionMode = .block
}
return copy
}

View File

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

View File

@@ -21,7 +21,7 @@ struct OpenAppLockApp: App {
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
}
let schema = Schema([BlockingRule.self])
let schema = Schema([BlockingRule.self, AppList.self])
let modelConfiguration = ModelConfiguration(
schema: schema, isStoredInMemoryOnly: config.isUITesting
)
@@ -31,9 +31,14 @@ struct OpenAppLockApp: App {
fatalError("Could not create ModelContainer: \(error)")
}
let usageLedger: UsageReading = config.isUITesting ? MockUsageLedger() : UsageLedger()
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 =
config.isUITesting
@@ -45,7 +50,11 @@ struct OpenAppLockApp: App {
let shields: ShieldApplying =
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
_enforcer = State(initialValue: RuleEnforcer(shields: shields))
let scheduler =
config.isUITesting
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
_enforcer = State(
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler))
}
var body: some Scene {

View File

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

View File

@@ -13,6 +13,10 @@ struct LaunchConfiguration: Equatable {
case standard
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
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

View File

@@ -7,18 +7,39 @@ import Foundation
import Observation
/// 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
/// react to usage thresholds in the background; they are persisted and shown
/// in the UI but not yet enforced here.
/// Background transitions (and usage tracking itself) belong to the
/// DeviceActivity monitor extension; this keeps shields correct while the
/// app runs.
@Observable
final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = []
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
init(shields: ShieldApplying) {
init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil
) {
self.shields = shields
self.usageReader = usage
self.scheduler = scheduler
}
/// 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,
@@ -29,18 +50,19 @@ final class RuleEnforcer {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
guard rule.kind == .schedule,
rule.status(at: now, calendar: calendar).isActive
else { continue }
let usage = usage(for: rule, at: now, calendar: calendar)
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
active.insert(rule.id)
shields.applyShield(
ruleID: rule.id,
selectionData: rule.selectionData,
mode: rule.selectionMode,
selectionData: rule.appList?.selectionData,
// Allow Only is a schedule-rule concept; limit blocks always Block.
mode: rule.kind == .schedule ? rule.selectionMode : .block,
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
scheduler?.sync(rules: rules, at: now)
}
}

View File

@@ -0,0 +1,172 @@
//
// 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
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 where rule.kind != .schedule {
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue }
let name = MonitoringPlan.dailyActivityName(for: rule.id)
desiredNames.insert(name)
let events =
rule.kind == .timeLimit
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
: [:]
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
+ "\(selectionData.hashValue)"
guard fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
else { continue }
do {
try monitor.startDailyMonitoring(
name: name, selectionData: selectionData, eventMinutes: events)
fingerprints[name] = fingerprint
} catch {
// Monitoring is best-effort here; the next sync retries.
// (Throws on the simulator and when authorization is missing.)
}
}
let stale = monitor.monitoredNames.filter {
MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil && !desiredNames.contains($0)
}
if !stale.isEmpty {
monitor.stopMonitoring(names: stale)
for name in stale {
fingerprints[name] = nil
}
}
storedFingerprints = fingerprints
}
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,
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 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 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 stopMonitoring(names: [String]) {
monitoredNames.removeAll(where: names.contains)
for name in names {
startedEvents[name] = nil
}
}
}

View File

@@ -12,16 +12,46 @@ enum SampleRules {
static func seed(
_ scenario: LaunchConfiguration.SeedScenario,
into context: ModelContext,
usage: MockUsageLedger? = nil,
now: Date = .now,
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 {
case .standard:
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
rules = [
activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
upcomingRule(named: "Sleep", now: now, calendar: calendar),
]
case .hardModeActive:
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
rules = [
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", kind: .timeLimit, days: Weekday.everyDay,
dailyLimitMinutes: 45)
let gateKeeper = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
let doomScroll = BlockingRule(
name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay,
dailyLimitMinutes: 30)
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
}
}

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ struct AppsHomeView: View {
) {
Button("Unblock", role: .destructive) {
if let rule = unblockCandidate {
RulePolicy.unblock(rule)
RulePolicy.unblock(rule, usage: enforcer.usage(for: rule))
refreshEnforcement()
}
unblockCandidate = nil
@@ -74,15 +74,22 @@ struct AppsHomeView: View {
private func ruleList(now: Date) -> some View {
List {
blockedSection(now: now)
usageSection(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
@ViewBuilder
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 {
if blocking.isEmpty {
Text("Nothing is blocked right now.")
@@ -100,7 +107,7 @@ struct AppsHomeView: View {
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
Button {
if RulePolicy.canUnblock(rule, at: now) {
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
unblockCandidate = rule
} else {
hardModeBlockedAttempt = true
@@ -120,6 +127,53 @@ struct AppsHomeView: View {
.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
@ViewBuilder
@@ -147,7 +201,7 @@ struct AppsHomeView: 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 {
detailRule = rule
} label: {
@@ -173,18 +227,21 @@ struct AppsHomeView: View {
}
private func blockSummary(for rule: BlockingRule) -> String {
let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps"
return "\(rule.selectionMode.displayName) · \(apps)"
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
}
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
switch rule.kind {
case .schedule:
status.label(relativeTo: now)
return status.label(relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
// While blocking (or paused/disabled) show the live state; the
// budget is only informative while it is still being spent.
if case .upcoming = status { return "\(rule.dailyLimitMinutes)m / day" }
return status.label(relativeTo: now)
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
if case .upcoming = status { return "\(rule.maxOpens) opens / day" }
return status.label(relativeTo: now)
}
}
@@ -195,7 +252,9 @@ struct AppsHomeView: View {
rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
+ "\($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: ",")
}

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ struct RuleDetailSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@State private var isEditing = false
@State private var pendingDeletion = false
@@ -50,13 +51,14 @@ struct RuleDetailSheet: 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 {
Section {
detailRows
}
Section {
if RulePolicy.canEdit(rule, at: now) {
if RulePolicy.canEdit(rule, usage: usage, at: now) {
Button {
isEditing = true
} label: {
@@ -123,7 +125,8 @@ struct RuleDetailSheet: View {
}
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 {

View File

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

View File

@@ -0,0 +1,46 @@
//
// 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()
)
}
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
enforcement.handleDayStart(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])
}
}
override func eventDidReachThreshold(
_ event: DeviceActivityEvent.Name, activity: DeviceActivityName
) {
super.eventDidReachThreshold(event, activity: activity)
guard let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue),
let minutes = MonitoringPlan.minutes(fromEventName: event.rawValue)
else { return }
enforcement.handleUsageMinutes(minutes, ruleID: ruleID)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,236 @@
//
// 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 {
@Test("Rules with legacy inline selections get a list named after them")
func createsListsFromLegacySelections() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3)
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 = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
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 = BlockingRule(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("Sanitizing forces Block mode for time- and open-limit rules")
func sanitizedForcesBlockForLimitKinds() {
var timeDraft = RuleDraft(kind: .timeLimit)
timeDraft.selectionMode = .allowOnly
#expect(timeDraft.sanitized().selectionMode == .block)
var openDraft = RuleDraft(kind: .openLimit)
openDraft.selectionMode = .allowOnly
#expect(openDraft.sanitized().selectionMode == .block)
}
@Test("Sanitizing keeps Allow Only on schedule rules")
func sanitizedKeepsAllowOnlyForSchedule() {
var draft = RuleDraft(kind: .schedule)
draft.selectionMode = .allowOnly
#expect(draft.sanitized().selectionMode == .allowOnly)
}
}
@MainActor
@Suite("App-list editing under Hard Mode")
struct AppListEditingPolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
@Test("App lists are locked while any hard-mode rule is actively blocking")
func lockedDuringHardModeSession() {
let hard = BlockingRule(name: "Locked In", hardMode: true)
let soft = BlockingRule(name: "Work Time")
#expect(
!RulePolicy.canEditAppLists(rules: [hard, soft], at: mondayDuringWork, calendar: utc))
}
@Test("App lists stay editable when no hard-mode rule is blocking")
func editableWithoutHardSession() {
let softActive = BlockingRule(name: "Work Time")
let hardInactive = BlockingRule(name: "Locked In", hardMode: true)
#expect(
RulePolicy.canEditAppLists(
rules: [softActive, hardInactive], at: mondayEvening, calendar: utc))
#expect(RulePolicy.canEditAppLists(rules: [], at: mondayDuringWork, calendar: utc))
}
@Test("A disabled hard-mode rule does not lock app lists")
func disabledHardRuleDoesNotLock() {
let rule = BlockingRule(name: "Locked In", isEnabled: false, hardMode: true)
#expect(RulePolicy.canEditAppLists(rules: [rule], at: mondayDuringWork, calendar: utc))
}
}
@MainActor
@Suite("Enforcement reads selections through app lists")
struct AppListEnforcementTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
@Test("The rule's app-list selection reaches the shield layer")
func forwardsAppListSelection() throws {
let context = try makeInMemoryContext()
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let list = AppList(name: "Distractions", selectionData: Data([1, 2, 3]))
let rule = BlockingRule(name: "Work Time")
context.insert(list)
context.insert(rule)
rule.appList = list
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3]))
}
}

View File

@@ -47,11 +47,7 @@ struct RuleModelTests {
@Test("Rules persist and fetch through SwiftData")
func persistence() throws {
let container = try ModelContainer(
for: BlockingRule.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = container.mainContext
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: "Deep Sleep", hardMode: true,
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
@@ -83,7 +79,11 @@ struct RuleDraftTests {
}
@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)
draft.name = "Locked In"
draft.days = Weekday.everyDay
@@ -92,16 +92,18 @@ struct RuleDraftTests {
draft.hardMode = true
draft.blockAdultContent = true
draft.selectionMode = .allowOnly
draft.selectionCount = 3
draft.appList = list
let rule = draft.makeRule()
let rule = draft.insertRule(into: context)
#expect(rule.blockAdultContent)
#expect(RuleDraft(rule: rule) == draft)
}
@Test("Applying a draft updates an existing rule")
func applyToExisting() {
func applyToExisting() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(name: "Old Name")
context.insert(rule)
var draft = RuleDraft(rule: rule)
draft.name = "New Name"
draft.hardMode = true

View File

@@ -0,0 +1,279 @@
//
// 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], dailyLimitMinutes: 45, maxOpens: 5,
pausedUntil: nil
)
store.save([snapshot])
#expect(store.load() == [snapshot])
#expect(store.snapshot(for: snapshot.id) == snapshot)
#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", kind: .openLimit, days: Weekday.weekends, maxOpens: 3)
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)
}
}
@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("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, kind: kind, days: Weekday.everyDay)
context.insert(list)
context.insert(rule)
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("Schedule rules and app-less limit rules are not monitored")
func skipsUnmonitorable() throws {
let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext()
let scheduleRule = BlockingRule(name: "Work Time")
let appless = BlockingRule(name: "Empty", kind: .timeLimit)
context.insert(scheduleRule)
context.insert(appless)
scheduler.sync(rules: [scheduleRule, appless])
#expect(monitor.monitoredNames.isEmpty)
}
@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())
return (LimitEnforcement(snapshots: store, ledger: ledger, shields: shields), 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),
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("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)
}
}

View File

@@ -4,9 +4,45 @@
//
import Foundation
import SwiftData
@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
/// of the machine running the tests.
let utc: Calendar = {

View File

@@ -0,0 +1,222 @@
//
// 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", kind: .timeLimit, days: Weekday.everyDay,
dailyLimitMinutes: limit)
}
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
maxOpens: maxOpens)
}
@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", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
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)
}
@Test("A limit rule with budget left is not shielded")
func leavesUnspentLimitAlone() {
let shields = MockShieldController()
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
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", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
let openRule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
@Test("Time-limit rows show minutes used and remaining")
func timeLimitStrings() {
let usage = RuleUsage(minutesUsed: 18)
#expect(UsageDisplay.subtitle(for: timeRule, usage: usage) == "18m of 45m used today")
#expect(UsageDisplay.remainingLabel(for: timeRule, usage: usage, isPaused: false) == "27m left")
}
@Test("Open-limit rows show opens used and remaining")
func openLimitStrings() {
let usage = RuleUsage(opensUsed: 2)
#expect(UsageDisplay.subtitle(for: openRule, usage: usage) == "2 of 5 opens today")
#expect(UsageDisplay.remainingLabel(for: openRule, usage: usage, isPaused: false) == "3 opens left")
let oneLeft = RuleUsage(opensUsed: 4)
#expect(UsageDisplay.remainingLabel(for: openRule, usage: oneLeft, isPaused: false) == "1 open left")
}
@Test("Spent budgets read as blocked, or unblocked while paused")
func exhaustedStrings() {
let spent = RuleUsage(minutesUsed: 45)
#expect(
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: false)
== "Blocked until tomorrow")
#expect(
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: true)
== "Unblocked until tomorrow")
#expect(UsageDisplay.subtitle(for: timeRule, usage: spent) == "45m of 45m used today")
}
@Test("Overshoot clamps to the budget")
func overshootClamps() {
let over = RuleUsage(minutesUsed: 60)
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
}
}

View File

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

View File

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

19
Shared/AppGroup.swift Normal file
View File

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

View File

@@ -0,0 +1,98 @@
//
// 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
/// 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
) {
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)
return true
}
private func shield(_ snapshot: RuleSnapshot) {
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
mode: .block,
blockAdultContent: snapshot.blockAdultContent
)
}
}

View File

@@ -0,0 +1,61 @@
//
// 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-"
/// 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)))
}
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)
}
)
}
}

75
Shared/RuleSnapshot.swift Normal file
View File

@@ -0,0 +1,75 @@
//
// 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]
var dailyLimitMinutes: Int
var maxOpens: Int
var pausedUntil: Date?
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
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
}
}
/// Persistence for the rule mirror in the shared app-group defaults.
final class RuleSnapshotStore {
private static let key = "ruleSnapshots"
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
func save(_ snapshots: [RuleSnapshot]) {
guard let data = try? JSONEncoder().encode(snapshots) else { return }
defaults.set(data, forKey: Self.key)
}
func load() -> [RuleSnapshot] {
guard let data = defaults.data(forKey: Self.key),
let snapshots = try? JSONDecoder().decode([RuleSnapshot].self, from: data)
else { return [] }
return snapshots
}
func snapshot(for ruleID: UUID) -> RuleSnapshot? {
load().first { $0.id == ruleID }
}
}

View File

@@ -13,18 +13,22 @@ protocol ShieldApplying: AnyObject {
func applyShield(
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
/// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>)
}
/// 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 {
private static let trackedIDsKey = "shieldedRuleIDs"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
@@ -50,11 +54,15 @@ final class ManagedSettingsShieldController: ShieldApplying {
track(ruleID: ruleID)
}
func clearShields(except activeRuleIDs: Set<UUID>) {
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
func clearShield(ruleID: UUID) {
store(for: ruleID).clearAllSettings()
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 {
@@ -82,6 +90,7 @@ final class MockShieldController: ShieldApplying {
private(set) var shieldedRuleIDs: Set<UUID> = []
private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:]
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
@@ -89,6 +98,14 @@ final class MockShieldController: ShieldApplying {
shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode
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>) {
@@ -97,6 +114,7 @@ final class MockShieldController: ShieldApplying {
appliedAdultContentFlags = appliedAdultContentFlags.filter {
activeRuleIDs.contains($0.key)
}
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
}
}

44
Shared/ShieldLookup.swift Normal file
View File

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

93
Shared/UsageLedger.swift Normal file
View File

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

View File

@@ -23,7 +23,16 @@ Common attributes across all types:
- **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
- **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").
- **Selection mode** — **Block** (block the list) or **Allow Only** (block
everything except the list). The mode belongs to the *rule*, not the list.
Only **Schedule** rules offer the choice; Time Limit and Open Limit rules
are always Block (a usage budget over "everything except X" is not
meaningful, and drafts of those kinds are sanitized back to Block).
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
@@ -67,6 +76,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
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
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,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet.
@@ -147,7 +167,11 @@ 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
with black letter, unselected = dark circle.
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
toggle.
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)*
@@ -209,8 +233,22 @@ Full-height sheet:
> parties cannot enumerate installed apps; the system-sanctioned route is
> `FamilyActivityPicker` (FamilyControls), which provides its own
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker, and
> keep the `Block`/`Allow Only` segmented control as our own wrapper state.
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker.
>
> **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 +268,15 @@ Full-height sheet:
4. **Time-limit rules** — accumulate usage daily across the selected apps;
on crossing the threshold, shield until the `Until` reset point
(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).
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.
@@ -324,7 +371,33 @@ enum SelectionMode: String, Codable { case block, allowOnly }
6. Preset gallery content + polish (gradients, photos, haptics, live
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 three stores between the
app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
per-day minutes/opens), and the shield-store tracking list.
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
enabled limit rules: one repeating 00:0023:59 activity per rule
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
event per budget minute (`minutes-<k>`) over the rule's app list.
Activities restart only when their configuration changes, because a
restart resets threshold accounting.
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
= midnight reset (open-limit rules re-shield so opens can be counted;
time-limit shields clear for the fresh budget); each `minutes-<k>` event
records usage and shields at the budget; a finished `open-session-<uuid>`
one-shot re-shields after a granted open.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name.
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, and starts the
~15-minute one-shot session (DeviceActivity's minimum interval).
- 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."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"