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) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
52
AGENTS.md
52
AGENTS.md
@@ -10,18 +10,32 @@ feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
|
|||||||
|
|
||||||
```
|
```
|
||||||
OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData)
|
OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData)
|
||||||
Models/ BlockingRule (@Model), RuleDraft, RuleKind,
|
Models/ BlockingRule + AppList (@Model), RuleDraft,
|
||||||
Weekday, RulePreset
|
RulePreset
|
||||||
Logic/ Pure, heavily unit-tested:
|
Logic/ Pure, heavily unit-tested:
|
||||||
RuleSchedule (window math, incl. overnight),
|
RuleStatus (derived status + labels, usage-aware),
|
||||||
RuleStatus (derived status + labels),
|
RulePolicy (Hard Mode gating, unblock/pause,
|
||||||
RulePolicy (Hard Mode gating, unblock/pause)
|
app-list lock), UsageDisplay (Usage-section text)
|
||||||
Services/ ScreenTimeAuthorization (FamilyControls behind a
|
Services/ ScreenTimeAuthorization (FamilyControls behind a
|
||||||
protocol + mock), ShieldController (ManagedSettings
|
protocol + mock), RuleEnforcer (rules → shields),
|
||||||
shields + adult-content web filter + mock),
|
RuleScheduler (rules → DeviceActivity monitoring),
|
||||||
RuleEnforcer (active rules → shields),
|
AppListMigration, LaunchConfiguration +
|
||||||
LaunchConfiguration + SampleRules (UI-test harness)
|
SampleRules (UI-test harness)
|
||||||
Views/ Native SwiftUI screens (see docs spec §6)
|
Views/ Native SwiftUI screens (see docs spec §6)
|
||||||
|
Shared/ Compiled into the app AND all three extensions:
|
||||||
|
RuleKind, Weekday, RuleSchedule, AppGroup,
|
||||||
|
UsageLedger (per-day minutes/opens),
|
||||||
|
RuleSnapshot(+Store) (rule mirror in the app
|
||||||
|
group), MonitoringPlan (activity/event naming),
|
||||||
|
LimitEnforcement (shared event reactions),
|
||||||
|
ShieldController, ShieldLookup
|
||||||
|
OpenAppLockMonitor/ DeviceActivityMonitor extension: midnight resets,
|
||||||
|
usage-minute checkpoints → shield at the limit,
|
||||||
|
open-session expiry
|
||||||
|
OpenAppLockShieldConfig/ ShieldConfiguration extension: "Opened X of N" +
|
||||||
|
Open button on open-limit shields
|
||||||
|
OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open,
|
||||||
|
lifts the shield, starts the ~15-min session
|
||||||
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
|
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
|
||||||
target defaults to MainActor isolation)
|
target defaults to MainActor isolation)
|
||||||
OpenAppLockUITests/ XCUITest flows (see harness below)
|
OpenAppLockUITests/ XCUITest flows (see harness below)
|
||||||
@@ -119,12 +133,16 @@ Gotchas learned the hard way:
|
|||||||
|
|
||||||
## Known gaps / next steps
|
## Known gaps / next steps
|
||||||
|
|
||||||
- **No DeviceActivityMonitor extension yet**: shields apply/clear only while
|
- **On-device verification of limit enforcement is pending.** The
|
||||||
the app runs (launch, foreground 30s loop). Background window transitions,
|
DeviceActivity monitor + shield extensions and the app group are in place,
|
||||||
and any real enforcement for Time Limit / Open Limit rules (usage
|
but real blocking/usage tracking is only observable on a device (the
|
||||||
thresholds), require adding the extension target + app group.
|
simulator neither tracks usage nor renders custom shields). Verify: time
|
||||||
- Time Limit / Open Limit rules are fully modeled, editable, and displayed,
|
limits accrue in the Usage section and block at the budget; open-limit
|
||||||
but not enforced (see above).
|
apps shield immediately with an "Open (N left)" button; an open lasts
|
||||||
|
~15 minutes (DeviceActivity's minimum interval) before re-shielding.
|
||||||
|
- **Schedule-rule background transitions** 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` shows few apps on the simulator; fine on device.
|
||||||
- `FamilyActivityPicker` **silently ignores selections** (binding never
|
- `FamilyActivityPicker` **silently ignores selections** (binding never
|
||||||
updates, rows still show checkmarks) unless real FamilyControls
|
updates, rows still show checkmarks) unless real FamilyControls
|
||||||
@@ -134,4 +152,6 @@ Gotchas learned the hard way:
|
|||||||
`-ui-testing`, complete onboarding, and approve the system Screen Time
|
`-ui-testing`, complete onboarding, and approve the system Screen Time
|
||||||
prompts ("Allow with Passcode" works on the simulator).
|
prompts ("Allow with Passcode" works on the simulator).
|
||||||
- Distribution (App Store) requires Apple's approval for the Family Controls
|
- Distribution (App Store) requires Apple's approval for the Family Controls
|
||||||
entitlement; development builds work with the dev entitlement.
|
entitlement **for the app and each extension bundle ID**
|
||||||
|
(`dev.bchen.OpenAppLock`, `.Monitor`, `.ShieldConfig`, `.ShieldAction`);
|
||||||
|
development builds work with the dev entitlement.
|
||||||
|
|||||||
@@ -6,7 +6,34 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenAppLockMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
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 */ = {
|
20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
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; };
|
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B10000000000000000000001 /* OpenAppLockMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B10000000000000000000002 /* OpenAppLockShieldConfig.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldConfig.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -45,8 +75,77 @@
|
|||||||
path = OpenAppLockUITests;
|
path = OpenAppLockUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA00000000000000000000A1 /* Shared */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AA00000000000000000000A2 /* OpenAppLockMonitor */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */,
|
||||||
|
);
|
||||||
|
path = OpenAppLockMonitor;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AA00000000000000000000A3 /* OpenAppLockShieldConfig */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */,
|
||||||
|
);
|
||||||
|
path = OpenAppLockShieldConfig;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AA00000000000000000000A4 /* OpenAppLockShieldAction */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */,
|
||||||
|
);
|
||||||
|
path = OpenAppLockShieldAction;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = C10000000000000000000001 /* OpenAppLockMonitor */;
|
||||||
|
};
|
||||||
|
AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
|
||||||
|
};
|
||||||
|
AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
E10000000000000000000001 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */,
|
||||||
|
F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */,
|
||||||
|
F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
|
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
@@ -69,6 +168,27 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D12000000000000000000001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D12000000000000000000002 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D12000000000000000000003 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -76,6 +196,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
|
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
|
||||||
|
AA00000000000000000000A1 /* Shared */,
|
||||||
|
AA00000000000000000000A2 /* OpenAppLockMonitor */,
|
||||||
|
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
|
||||||
|
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
|
||||||
20A7EDE02E47B7CF0097608D /* OpenAppLockTests */,
|
20A7EDE02E47B7CF0097608D /* OpenAppLockTests */,
|
||||||
20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */,
|
20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */,
|
||||||
20A7EDCF2E47B7CD0097608D /* Products */,
|
20A7EDCF2E47B7CD0097608D /* Products */,
|
||||||
@@ -88,6 +212,9 @@
|
|||||||
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */,
|
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */,
|
||||||
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */,
|
20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */,
|
||||||
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */,
|
20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */,
|
||||||
|
B10000000000000000000001 /* OpenAppLockMonitor.appex */,
|
||||||
|
B10000000000000000000002 /* OpenAppLockShieldConfig.appex */,
|
||||||
|
B10000000000000000000003 /* OpenAppLockShieldAction.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -102,13 +229,18 @@
|
|||||||
20A7EDCA2E47B7CD0097608D /* Sources */,
|
20A7EDCA2E47B7CD0097608D /* Sources */,
|
||||||
20A7EDCB2E47B7CD0097608D /* Frameworks */,
|
20A7EDCB2E47B7CD0097608D /* Frameworks */,
|
||||||
20A7EDCC2E47B7CD0097608D /* Resources */,
|
20A7EDCC2E47B7CD0097608D /* Resources */,
|
||||||
|
E10000000000000000000001 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
E30000000000000000000001 /* PBXTargetDependency */,
|
||||||
|
E30000000000000000000002 /* PBXTargetDependency */,
|
||||||
|
E30000000000000000000003 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
|
20A7EDD02E47B7CD0097608D /* OpenAppLock */,
|
||||||
|
AA00000000000000000000A1 /* Shared */,
|
||||||
);
|
);
|
||||||
name = OpenAppLock;
|
name = OpenAppLock;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -163,6 +295,75 @@
|
|||||||
productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */;
|
productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
|
C10000000000000000000001 /* OpenAppLockMonitor */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */;
|
||||||
|
buildPhases = (
|
||||||
|
D11000000000000000000001 /* Sources */,
|
||||||
|
D12000000000000000000001 /* Frameworks */,
|
||||||
|
D13000000000000000000001 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -203,6 +404,9 @@
|
|||||||
20A7EDCD2E47B7CD0097608D /* OpenAppLock */,
|
20A7EDCD2E47B7CD0097608D /* OpenAppLock */,
|
||||||
20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */,
|
20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */,
|
||||||
20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */,
|
20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */,
|
||||||
|
C10000000000000000000001 /* OpenAppLockMonitor */,
|
||||||
|
C10000000000000000000002 /* OpenAppLockShieldConfig */,
|
||||||
|
C10000000000000000000003 /* OpenAppLockShieldAction */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -229,6 +433,27 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D13000000000000000000001 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D13000000000000000000002 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D13000000000000000000003 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -253,6 +478,27 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D11000000000000000000001 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D11000000000000000000002 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D11000000000000000000003 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -266,6 +512,21 @@
|
|||||||
target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */;
|
target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */;
|
||||||
targetProxy = 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */;
|
targetProxy = 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
E30000000000000000000001 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = C10000000000000000000001 /* OpenAppLockMonitor */;
|
||||||
|
targetProxy = E20000000000000000000001 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
E30000000000000000000002 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = C10000000000000000000002 /* OpenAppLockShieldConfig */;
|
||||||
|
targetProxy = E20000000000000000000002 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
E30000000000000000000003 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = C10000000000000000000003 /* OpenAppLockShieldAction */;
|
||||||
|
targetProxy = E20000000000000000000003 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -569,6 +830,168 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
E50000000000000000000011 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockMonitor/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E50000000000000000000012 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockMonitor/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
E50000000000000000000021 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E50000000000000000000022 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
E50000000000000000000031 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E50000000000000000000032 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -608,6 +1031,33 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E50000000000000000000011 /* Debug */,
|
||||||
|
E50000000000000000000012 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
E40000000000000000000002 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldConfig" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E50000000000000000000021 /* Debug */,
|
||||||
|
E50000000000000000000022 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
E40000000000000000000003 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldAction" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E50000000000000000000031 /* Debug */,
|
||||||
|
E50000000000000000000032 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 20A7EDC62E47B7CD0097608D /* Project object */;
|
rootObject = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
|
||||||
|
BuildableName = "OpenAppLock.app"
|
||||||
|
BlueprintName = "OpenAppLock"
|
||||||
|
ReferencedContainer = "container:OpenAppLock.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20A7EDDC2E47B7CF0097608D"
|
||||||
|
BuildableName = "OpenAppLockTests.xctest"
|
||||||
|
BlueprintName = "OpenAppLockTests"
|
||||||
|
ReferencedContainer = "container:OpenAppLock.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20A7EDE62E47B7CF0097608D"
|
||||||
|
BuildableName = "OpenAppLockUITests.xctest"
|
||||||
|
BlueprintName = "OpenAppLockUITests"
|
||||||
|
ReferencedContainer = "container:OpenAppLock.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
|
||||||
|
BuildableName = "OpenAppLock.app"
|
||||||
|
BlueprintName = "OpenAppLock"
|
||||||
|
ReferencedContainer = "container:OpenAppLock.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20A7EDCD2E47B7CD0097608D"
|
||||||
|
BuildableName = "OpenAppLock.app"
|
||||||
|
BlueprintName = "OpenAppLock"
|
||||||
|
ReferencedContainer = "container:OpenAppLock.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -4,5 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.family-controls</key>
|
<key>com.apple.developer.family-controls</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.bchen.OpenAppLock</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ struct OpenAppLockApp: App {
|
|||||||
|
|
||||||
let shields: ShieldApplying =
|
let shields: ShieldApplying =
|
||||||
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
|
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
|
||||||
_enforcer = State(initialValue: RuleEnforcer(shields: shields, usage: usageLedger))
|
let scheduler =
|
||||||
|
config.isUITesting
|
||||||
|
? nil : RuleScheduler(monitor: DeviceActivityCenterMonitor())
|
||||||
|
_enforcer = State(
|
||||||
|
initialValue: RuleEnforcer(shields: shields, usage: usageLedger, scheduler: scheduler))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ import Observation
|
|||||||
final class RuleEnforcer {
|
final class RuleEnforcer {
|
||||||
private(set) var blockingRuleIDs: Set<UUID> = []
|
private(set) var blockingRuleIDs: Set<UUID> = []
|
||||||
private let shields: ShieldApplying
|
private let shields: ShieldApplying
|
||||||
|
/// Mirrors rules to the app group and keeps DeviceActivity monitoring in
|
||||||
|
/// step; nil in UI-test launches.
|
||||||
|
private let scheduler: RuleScheduler?
|
||||||
/// Day-usage source consulted for limit rules; also exposed to views for
|
/// Day-usage source consulted for limit rules; also exposed to views for
|
||||||
/// the Usage section.
|
/// the Usage section.
|
||||||
let usageReader: UsageReading
|
let usageReader: UsageReading
|
||||||
|
|
||||||
init(shields: ShieldApplying, usage: UsageReading = UsageLedger()) {
|
init(
|
||||||
|
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
|
||||||
|
scheduler: RuleScheduler? = nil
|
||||||
|
) {
|
||||||
self.shields = shields
|
self.shields = shields
|
||||||
self.usageReader = usage
|
self.usageReader = usage
|
||||||
|
self.scheduler = scheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
||||||
@@ -56,5 +63,6 @@ final class RuleEnforcer {
|
|||||||
}
|
}
|
||||||
shields.clearShields(except: active)
|
shields.clearShields(except: active)
|
||||||
blockingRuleIDs = active
|
blockingRuleIDs = active
|
||||||
|
scheduler?.sync(rules: rules, at: now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
OpenAppLock/Services/RuleScheduler.swift
Normal file
172
OpenAppLock/Services/RuleScheduler.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ protocol AuthorizationProviding {
|
|||||||
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
|
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
|
||||||
var currentStatus: ScreenTimeAuthorizationStatus {
|
var currentStatus: ScreenTimeAuthorizationStatus {
|
||||||
switch AuthorizationCenter.shared.authorizationStatus {
|
switch AuthorizationCenter.shared.authorizationStatus {
|
||||||
case .approved: .approved
|
case .approved, .approvedWithDataAccess: .approved
|
||||||
case .denied: .denied
|
case .denied: .denied
|
||||||
case .notDetermined: .notDetermined
|
case .notDetermined: .notDetermined
|
||||||
@unknown default: .notDetermined
|
@unknown default: .notDetermined
|
||||||
|
|||||||
46
OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Normal file
46
OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
OpenAppLockMonitor/Info.plist
Normal file
13
OpenAppLockMonitor/Info.plist
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.deviceactivity.monitor-extension</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
OpenAppLockMonitor/OpenAppLockMonitor.entitlements
Normal file
12
OpenAppLockMonitor/OpenAppLockMonitor.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.family-controls</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.bchen.OpenAppLock</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
13
OpenAppLockShieldAction/Info.plist
Normal file
13
OpenAppLockShieldAction/Info.plist
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.ManagedSettings.shield-action-service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShieldActionExtension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal file
12
OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.family-controls</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.bchen.OpenAppLock</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal file
101
OpenAppLockShieldAction/ShieldActionExtension.swift
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// ShieldActionExtension.swift
|
||||||
|
// OpenAppLockShieldAction
|
||||||
|
//
|
||||||
|
|
||||||
|
import DeviceActivity
|
||||||
|
import Foundation
|
||||||
|
import ManagedSettings
|
||||||
|
|
||||||
|
/// Handles shield button presses. The secondary "Open" button on an
|
||||||
|
/// open-limit shield spends one open, lifts the rule's shield, and starts a
|
||||||
|
/// one-shot DeviceActivity session after which the monitor extension
|
||||||
|
/// re-shields.
|
||||||
|
final class ShieldActionExtension: ShieldActionDelegate {
|
||||||
|
override func handle(
|
||||||
|
action: ShieldAction, for application: ApplicationToken,
|
||||||
|
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||||
|
) {
|
||||||
|
switch action {
|
||||||
|
case .primaryButtonPressed:
|
||||||
|
completionHandler(.close)
|
||||||
|
case .secondaryButtonPressed:
|
||||||
|
completionHandler(handleOpenPress(applicationToken: application))
|
||||||
|
case .firstSecondarySubmenuItemPressed, .secondSecondarySubmenuItemPressed,
|
||||||
|
.thirdSecondarySubmenuItemPressed:
|
||||||
|
// Our shields define no submenu items.
|
||||||
|
completionHandler(.close)
|
||||||
|
@unknown default:
|
||||||
|
completionHandler(.close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handle(
|
||||||
|
action: ShieldAction, for webDomain: WebDomainToken,
|
||||||
|
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(.close)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handle(
|
||||||
|
action: ShieldAction, for category: ActivityCategoryToken,
|
||||||
|
completionHandler: @escaping (ShieldActionResponse) -> Void
|
||||||
|
) {
|
||||||
|
switch action {
|
||||||
|
case .secondaryButtonPressed:
|
||||||
|
if let snapshot = ShieldLookup.openLimitSnapshot(
|
||||||
|
containingCategory: category, in: RuleSnapshotStore().load()) {
|
||||||
|
completionHandler(grantOpen(ruleID: snapshot.id))
|
||||||
|
} else {
|
||||||
|
completionHandler(.close)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
completionHandler(.close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleOpenPress(applicationToken: ApplicationToken) -> ShieldActionResponse {
|
||||||
|
guard
|
||||||
|
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||||
|
containingApplication: applicationToken, in: RuleSnapshotStore().load())
|
||||||
|
else { return .close }
|
||||||
|
return grantOpen(ruleID: snapshot.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func grantOpen(ruleID: UUID) -> ShieldActionResponse {
|
||||||
|
let enforcement = LimitEnforcement(
|
||||||
|
snapshots: RuleSnapshotStore(),
|
||||||
|
ledger: UsageLedger(),
|
||||||
|
shields: ManagedSettingsShieldController()
|
||||||
|
)
|
||||||
|
guard enforcement.handleOpenRequest(ruleID: ruleID) else {
|
||||||
|
// Out of opens; keep the shield up (it re-renders with the
|
||||||
|
// exhausted message on next presentation).
|
||||||
|
return .defer
|
||||||
|
}
|
||||||
|
startOpenSession(ruleID: ruleID)
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Times the granted open with a one-shot activity. One extra minute over
|
||||||
|
/// the advertised session keeps the interval above DeviceActivity's
|
||||||
|
/// 15-minute minimum.
|
||||||
|
private func startOpenSession(ruleID: UUID) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date.now
|
||||||
|
guard
|
||||||
|
let end = calendar.date(
|
||||||
|
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
|
||||||
|
else { return }
|
||||||
|
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
|
||||||
|
let schedule = DeviceActivitySchedule(
|
||||||
|
intervalStart: calendar.dateComponents(components, from: now),
|
||||||
|
intervalEnd: calendar.dateComponents(components, from: end),
|
||||||
|
repeats: false
|
||||||
|
)
|
||||||
|
try? DeviceActivityCenter().startMonitoring(
|
||||||
|
DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)),
|
||||||
|
during: schedule
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
OpenAppLockShieldConfig/Info.plist
Normal file
13
OpenAppLockShieldConfig/Info.plist
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.ManagedSettingsUI.shield-configuration-service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShieldConfigurationExtension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements
Normal file
12
OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.family-controls</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.bchen.OpenAppLock</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal file
85
OpenAppLockShieldConfig/ShieldConfigurationExtension.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// ShieldConfigurationExtension.swift
|
||||||
|
// OpenAppLockShieldConfig
|
||||||
|
//
|
||||||
|
|
||||||
|
import ManagedSettings
|
||||||
|
import ManagedSettingsUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Customizes the system shield. Apps under an open-limit rule show how many
|
||||||
|
/// opens were used and offer an "Open" secondary button while opens remain;
|
||||||
|
/// everything else gets a plain blocked shield named after its rule.
|
||||||
|
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
||||||
|
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
||||||
|
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
||||||
|
return configuration(forApplicationToken: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func configuration(
|
||||||
|
shielding application: Application, in category: ActivityCategory
|
||||||
|
) -> ShieldConfiguration {
|
||||||
|
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
||||||
|
return configuration(forApplicationToken: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
||||||
|
blockedConfiguration(ruleName: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func configuration(
|
||||||
|
shielding webDomain: WebDomain, in category: ActivityCategory
|
||||||
|
) -> ShieldConfiguration {
|
||||||
|
blockedConfiguration(ruleName: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configuration(
|
||||||
|
forApplicationToken token: ApplicationToken
|
||||||
|
) -> ShieldConfiguration {
|
||||||
|
let snapshots = RuleSnapshotStore().load()
|
||||||
|
guard
|
||||||
|
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||||
|
containingApplication: token, in: snapshots)
|
||||||
|
else {
|
||||||
|
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
|
||||||
|
return blockedConfiguration(ruleName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
|
||||||
|
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
|
||||||
|
guard remaining > 0 else {
|
||||||
|
return ShieldConfiguration(
|
||||||
|
backgroundBlurStyle: .systemMaterial,
|
||||||
|
title: .init(text: snapshot.name, color: .label),
|
||||||
|
subtitle: .init(
|
||||||
|
text: "No opens left today — the block lifts tomorrow.",
|
||||||
|
color: .secondaryLabel),
|
||||||
|
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||||
|
primaryButtonBackgroundColor: .systemBlue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ShieldConfiguration(
|
||||||
|
backgroundBlurStyle: .systemMaterial,
|
||||||
|
title: .init(text: snapshot.name, color: .label),
|
||||||
|
subtitle: .init(
|
||||||
|
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
|
||||||
|
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
|
||||||
|
color: .secondaryLabel),
|
||||||
|
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||||
|
primaryButtonBackgroundColor: .systemBlue,
|
||||||
|
secondaryButtonLabel: .init(
|
||||||
|
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
|
||||||
|
color: .systemBlue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
|
||||||
|
ShieldConfiguration(
|
||||||
|
backgroundBlurStyle: .systemMaterial,
|
||||||
|
title: .init(text: ruleName ?? "Blocked", color: .label),
|
||||||
|
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
|
||||||
|
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||||
|
primaryButtonBackgroundColor: .systemBlue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
279
OpenAppLockTests/SchedulingTests.swift
Normal file
279
OpenAppLockTests/SchedulingTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Shared/LimitEnforcement.swift
Normal file
98
Shared/LimitEnforcement.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Shared/MonitoringPlan.swift
Normal file
61
Shared/MonitoringPlan.swift
Normal 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
75
Shared/RuleSnapshot.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,22 @@ protocol ShieldApplying: AnyObject {
|
|||||||
func applyShield(
|
func applyShield(
|
||||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||||
)
|
)
|
||||||
|
/// Clears the shield of a single rule (used by the extensions for day
|
||||||
|
/// resets and granted opens).
|
||||||
|
func clearShield(ruleID: UUID)
|
||||||
/// Clears every shield except those for the given rule IDs. Covers rules
|
/// Clears every shield except those for the given rule IDs. Covers rules
|
||||||
/// that were deleted or expired while the app was not running.
|
/// that were deleted or expired while the app was not running.
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>)
|
func clearShields(except activeRuleIDs: Set<UUID>)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
|
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
|
||||||
/// are tracked in UserDefaults because ManagedSettings cannot enumerate them.
|
/// are tracked in the shared app-group defaults (ManagedSettings cannot
|
||||||
|
/// enumerate stores) so the app and extensions see one consistent set.
|
||||||
final class ManagedSettingsShieldController: ShieldApplying {
|
final class ManagedSettingsShieldController: ShieldApplying {
|
||||||
private static let trackedIDsKey = "shieldedRuleIDs"
|
private static let trackedIDsKey = "shieldedRuleIDs"
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +54,15 @@ final class ManagedSettingsShieldController: ShieldApplying {
|
|||||||
track(ruleID: ruleID)
|
track(ruleID: ruleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
func clearShield(ruleID: UUID) {
|
||||||
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
|
|
||||||
store(for: ruleID).clearAllSettings()
|
store(for: ruleID).clearAllSettings()
|
||||||
untrack(ruleID: ruleID)
|
untrack(ruleID: ruleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||||
|
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
|
||||||
|
clearShield(ruleID: ruleID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func store(for ruleID: UUID) -> ManagedSettingsStore {
|
private func store(for ruleID: UUID) -> ManagedSettingsStore {
|
||||||
@@ -93,6 +101,13 @@ final class MockShieldController: ShieldApplying {
|
|||||||
appliedSelectionData[ruleID] = selectionData
|
appliedSelectionData[ruleID] = selectionData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearShield(ruleID: UUID) {
|
||||||
|
shieldedRuleIDs.remove(ruleID)
|
||||||
|
appliedModes[ruleID] = nil
|
||||||
|
appliedAdultContentFlags[ruleID] = nil
|
||||||
|
appliedSelectionData[ruleID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||||
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
||||||
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
||||||
44
Shared/ShieldLookup.swift
Normal file
44
Shared/ShieldLookup.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// ShieldLookup.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import FamilyControls
|
||||||
|
import Foundation
|
||||||
|
import ManagedSettings
|
||||||
|
|
||||||
|
/// Matches shielded tokens back to the rule that shields them, so the shield
|
||||||
|
/// UI can offer "Open" with the right counts for open-limit rules.
|
||||||
|
enum ShieldLookup {
|
||||||
|
static func openLimitSnapshot(
|
||||||
|
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
|
||||||
|
) -> RuleSnapshot? {
|
||||||
|
snapshots.first { snapshot in
|
||||||
|
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
|
||||||
|
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||||
|
.applicationTokens.contains(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func openLimitSnapshot(
|
||||||
|
containingCategory token: ActivityCategoryToken, in snapshots: [RuleSnapshot]
|
||||||
|
) -> RuleSnapshot? {
|
||||||
|
snapshots.first { snapshot in
|
||||||
|
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
|
||||||
|
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||||
|
.categoryTokens.contains(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any enabled rule whose selection includes the application (for naming
|
||||||
|
/// plain blocked shields).
|
||||||
|
static func snapshot(
|
||||||
|
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
|
||||||
|
) -> RuleSnapshot? {
|
||||||
|
snapshots.first { snapshot in
|
||||||
|
guard snapshot.isEnabled else { return false }
|
||||||
|
return AppSelectionCodec.decode(snapshot.selectionData)
|
||||||
|
.applicationTokens.contains(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,7 +371,33 @@ enum SelectionMode: String, Codable { case block, allowOnly }
|
|||||||
6. Preset gallery content + polish (gradients, photos, haptics, live
|
6. Preset gallery content + polish (gradients, photos, haptics, live
|
||||||
countdown timers).
|
countdown timers).
|
||||||
|
|
||||||
### 5.5 Out of scope (seen in video, not part of "rules")
|
### 5.5 Background enforcement architecture (implemented)
|
||||||
|
|
||||||
|
- **App group** `group.dev.bchen.OpenAppLock` shares 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:00–23:59 activity per rule
|
||||||
|
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||||
|
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||||
|
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."),
|
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
|
||||||
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
|
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
|
||||||
|
|||||||
Reference in New Issue
Block a user