From 443b37c5e16682df999a6fa23d3e03e3f24ac494 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 21:06:08 -0400 Subject: [PATCH] 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 --- AGENTS.md | 52 +- OpenAppLock.xcodeproj/project.pbxproj | 450 ++++++++++++++++++ .../xcschemes/OpenAppLock.xcscheme | 99 ++++ OpenAppLock/OpenAppLock.entitlements | 4 + OpenAppLock/OpenAppLockApp.swift | 6 +- OpenAppLock/Services/RuleEnforcer.swift | 10 +- OpenAppLock/Services/RuleScheduler.swift | 172 +++++++ .../Services/ScreenTimeAuthorization.swift | 2 +- .../DeviceActivityMonitorExtension.swift | 46 ++ OpenAppLockMonitor/Info.plist | 13 + .../OpenAppLockMonitor.entitlements | 12 + OpenAppLockShieldAction/Info.plist | 13 + .../OpenAppLockShieldAction.entitlements | 12 + .../ShieldActionExtension.swift | 101 ++++ OpenAppLockShieldConfig/Info.plist | 13 + .../OpenAppLockShieldConfig.entitlements | 12 + .../ShieldConfigurationExtension.swift | 85 ++++ OpenAppLockTests/SchedulingTests.swift | 279 +++++++++++ .../Services => Shared}/AppGroup.swift | 0 Shared/LimitEnforcement.swift | 98 ++++ Shared/MonitoringPlan.swift | 61 +++ {OpenAppLock/Models => Shared}/RuleKind.swift | 0 .../Logic => Shared}/RuleSchedule.swift | 0 Shared/RuleSnapshot.swift | 75 +++ .../ShieldController.swift | 23 +- Shared/ShieldLookup.swift | 44 ++ .../Services => Shared}/UsageLedger.swift | 0 {OpenAppLock/Models => Shared}/Weekday.swift | 0 docs/RULES_FEATURE_SPEC.md | 28 +- 29 files changed, 1686 insertions(+), 24 deletions(-) create mode 100644 OpenAppLock.xcodeproj/xcshareddata/xcschemes/OpenAppLock.xcscheme create mode 100644 OpenAppLock/Services/RuleScheduler.swift create mode 100644 OpenAppLockMonitor/DeviceActivityMonitorExtension.swift create mode 100644 OpenAppLockMonitor/Info.plist create mode 100644 OpenAppLockMonitor/OpenAppLockMonitor.entitlements create mode 100644 OpenAppLockShieldAction/Info.plist create mode 100644 OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements create mode 100644 OpenAppLockShieldAction/ShieldActionExtension.swift create mode 100644 OpenAppLockShieldConfig/Info.plist create mode 100644 OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements create mode 100644 OpenAppLockShieldConfig/ShieldConfigurationExtension.swift create mode 100644 OpenAppLockTests/SchedulingTests.swift rename {OpenAppLock/Services => Shared}/AppGroup.swift (100%) create mode 100644 Shared/LimitEnforcement.swift create mode 100644 Shared/MonitoringPlan.swift rename {OpenAppLock/Models => Shared}/RuleKind.swift (100%) rename {OpenAppLock/Logic => Shared}/RuleSchedule.swift (100%) create mode 100644 Shared/RuleSnapshot.swift rename {OpenAppLock/Services => Shared}/ShieldController.swift (86%) create mode 100644 Shared/ShieldLookup.swift rename {OpenAppLock/Services => Shared}/UsageLedger.swift (100%) rename {OpenAppLock/Models => Shared}/Weekday.swift (100%) diff --git a/AGENTS.md b/AGENTS.md index c0456b3..b542893 100644 --- a/AGENTS.md +++ b/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) - Models/ BlockingRule (@Model), RuleDraft, RuleKind, - Weekday, RulePreset + Models/ BlockingRule + AppList (@Model), RuleDraft, + RulePreset Logic/ Pure, heavily unit-tested: - RuleSchedule (window math, incl. overnight), - RuleStatus (derived status + labels), - RulePolicy (Hard Mode gating, unblock/pause) + RuleStatus (derived status + labels, usage-aware), + RulePolicy (Hard Mode gating, unblock/pause, + app-list lock), UsageDisplay (Usage-section text) Services/ ScreenTimeAuthorization (FamilyControls behind a - protocol + mock), ShieldController (ManagedSettings - shields + adult-content web filter + mock), - RuleEnforcer (active rules → shields), - LaunchConfiguration + SampleRules (UI-test harness) + protocol + mock), RuleEnforcer (rules → shields), + RuleScheduler (rules → DeviceActivity monitoring), + AppListMigration, LaunchConfiguration + + SampleRules (UI-test harness) Views/ Native SwiftUI screens (see docs spec §6) +Shared/ Compiled into the app AND all three extensions: + RuleKind, Weekday, RuleSchedule, AppGroup, + UsageLedger (per-day minutes/opens), + RuleSnapshot(+Store) (rule mirror in the app + group), MonitoringPlan (activity/event naming), + LimitEnforcement (shared event reactions), + ShieldController, ShieldLookup +OpenAppLockMonitor/ DeviceActivityMonitor extension: midnight resets, + usage-minute checkpoints → shield at the limit, + open-session expiry +OpenAppLockShieldConfig/ ShieldConfiguration extension: "Opened X of N" + + Open button on open-limit shields +OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open, + lifts the shield, starts the ~15-min session OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app target defaults to MainActor isolation) OpenAppLockUITests/ XCUITest flows (see harness below) @@ -119,12 +133,16 @@ Gotchas learned the hard way: ## Known gaps / next steps -- **No DeviceActivityMonitor extension yet**: shields apply/clear only while - the app runs (launch, foreground 30s loop). Background window transitions, - and any real enforcement for Time Limit / Open Limit rules (usage - thresholds), require adding the extension target + app group. -- Time Limit / Open Limit rules are fully modeled, editable, and displayed, - but not enforced (see above). +- **On-device verification of limit enforcement is pending.** The + DeviceActivity monitor + shield extensions and the app group are in place, + but real blocking/usage tracking is only observable on a device (the + simulator neither tracks usage nor renders custom shields). Verify: time + limits accrue in the Usage section and block at the budget; open-limit + apps shield immediately with an "Open (N left)" button; an open lasts + ~15 minutes (DeviceActivity's minimum interval) before re-shielding. +- **Schedule-rule background transitions** still rely on the app running + (launch / foreground 30s loop); schedule rules have no DeviceActivity + monitoring yet — only limit rules do. - `FamilyActivityPicker` shows few apps on the simulator; fine on device. - `FamilyActivityPicker` **silently ignores selections** (binding never updates, rows still show checkmarks) unless real FamilyControls @@ -134,4 +152,6 @@ Gotchas learned the hard way: `-ui-testing`, complete onboarding, and approve the system Screen Time prompts ("Allow with Passcode" works on the simulator). - Distribution (App Store) requires Apple's approval for the Family Controls - entitlement; development builds work with the dev entitlement. + entitlement **for the app and each extension bundle ID** + (`dev.bchen.OpenAppLock`, `.Monitor`, `.ShieldConfig`, `.ShieldAction`); + development builds work with the dev entitlement. diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index 477e898..5142dfe 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -6,7 +6,34 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenAppLockMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ + E20000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = C10000000000000000000001; + remoteInfo = OpenAppLockMonitor; + }; + E20000000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = C10000000000000000000002; + remoteInfo = OpenAppLockShieldConfig; + }; + E20000000000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = C10000000000000000000003; + remoteInfo = OpenAppLockShieldAction; + }; 20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; @@ -27,6 +54,9 @@ 20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000001 /* OpenAppLockMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000002 /* OpenAppLockShieldConfig.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldConfig.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -45,8 +75,77 @@ path = OpenAppLockUITests; sourceTree = ""; }; + AA00000000000000000000A1 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; + AA00000000000000000000A2 /* OpenAppLockMonitor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */, + ); + path = OpenAppLockMonitor; + sourceTree = ""; + }; + AA00000000000000000000A3 /* OpenAppLockShieldConfig */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */, + ); + path = OpenAppLockShieldConfig; + sourceTree = ""; + }; + AA00000000000000000000A4 /* OpenAppLockShieldAction */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */, + ); + path = OpenAppLockShieldAction; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000001 /* OpenAppLockMonitor */; + }; + AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000002 /* OpenAppLockShieldConfig */; + }; + AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000003 /* OpenAppLockShieldAction */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E10000000000000000000001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */, + F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */, + F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFrameworksBuildPhase section */ 20A7EDCB2E47B7CD0097608D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -69,6 +168,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D12000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D12000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D12000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -76,6 +196,10 @@ isa = PBXGroup; children = ( 20A7EDD02E47B7CD0097608D /* OpenAppLock */, + AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A2 /* OpenAppLockMonitor */, + AA00000000000000000000A3 /* OpenAppLockShieldConfig */, + AA00000000000000000000A4 /* OpenAppLockShieldAction */, 20A7EDE02E47B7CF0097608D /* OpenAppLockTests */, 20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */, 20A7EDCF2E47B7CD0097608D /* Products */, @@ -88,6 +212,9 @@ 20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */, 20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */, 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */, + B10000000000000000000001 /* OpenAppLockMonitor.appex */, + B10000000000000000000002 /* OpenAppLockShieldConfig.appex */, + B10000000000000000000003 /* OpenAppLockShieldAction.appex */, ); name = Products; sourceTree = ""; @@ -102,13 +229,18 @@ 20A7EDCA2E47B7CD0097608D /* Sources */, 20A7EDCB2E47B7CD0097608D /* Frameworks */, 20A7EDCC2E47B7CD0097608D /* Resources */, + E10000000000000000000001 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + E30000000000000000000001 /* PBXTargetDependency */, + E30000000000000000000002 /* PBXTargetDependency */, + E30000000000000000000003 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 20A7EDD02E47B7CD0097608D /* OpenAppLock */, + AA00000000000000000000A1 /* Shared */, ); name = OpenAppLock; packageProductDependencies = ( @@ -163,6 +295,75 @@ productReference = 20A7EDE72E47B7CF0097608D /* OpenAppLockUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + C10000000000000000000001 /* OpenAppLockMonitor */ = { + isa = PBXNativeTarget; + buildConfigurationList = E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */; + buildPhases = ( + D11000000000000000000001 /* Sources */, + D12000000000000000000001 /* Frameworks */, + D13000000000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000000A2 /* OpenAppLockMonitor */, + AA00000000000000000000A1 /* Shared */, + ); + name = OpenAppLockMonitor; + packageProductDependencies = ( + ); + productName = OpenAppLockMonitor; + productReference = B10000000000000000000001 /* OpenAppLockMonitor.appex */; + productType = "com.apple.product-type.app-extension"; + }; + C10000000000000000000002 /* OpenAppLockShieldConfig */ = { + isa = PBXNativeTarget; + buildConfigurationList = E40000000000000000000002 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldConfig" */; + buildPhases = ( + D11000000000000000000002 /* Sources */, + D12000000000000000000002 /* Frameworks */, + D13000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000000A3 /* OpenAppLockShieldConfig */, + AA00000000000000000000A1 /* Shared */, + ); + name = OpenAppLockShieldConfig; + packageProductDependencies = ( + ); + productName = OpenAppLockShieldConfig; + productReference = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; + productType = "com.apple.product-type.app-extension"; + }; + C10000000000000000000003 /* OpenAppLockShieldAction */ = { + isa = PBXNativeTarget; + buildConfigurationList = E40000000000000000000003 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldAction" */; + buildPhases = ( + D11000000000000000000003 /* Sources */, + D12000000000000000000003 /* Frameworks */, + D13000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000000A4 /* OpenAppLockShieldAction */, + AA00000000000000000000A1 /* Shared */, + ); + name = OpenAppLockShieldAction; + packageProductDependencies = ( + ); + productName = OpenAppLockShieldAction; + productReference = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -203,6 +404,9 @@ 20A7EDCD2E47B7CD0097608D /* OpenAppLock */, 20A7EDDC2E47B7CF0097608D /* OpenAppLockTests */, 20A7EDE62E47B7CF0097608D /* OpenAppLockUITests */, + C10000000000000000000001 /* OpenAppLockMonitor */, + C10000000000000000000002 /* OpenAppLockShieldConfig */, + C10000000000000000000003 /* OpenAppLockShieldAction */, ); }; /* End PBXProject section */ @@ -229,6 +433,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D13000000000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D13000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D13000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -253,6 +478,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D11000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D11000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D11000000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -266,6 +512,21 @@ target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */; targetProxy = 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */; }; + E30000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C10000000000000000000001 /* OpenAppLockMonitor */; + targetProxy = E20000000000000000000001 /* PBXContainerItemProxy */; + }; + E30000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C10000000000000000000002 /* OpenAppLockShieldConfig */; + targetProxy = E20000000000000000000002 /* PBXContainerItemProxy */; + }; + E30000000000000000000003 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C10000000000000000000003 /* OpenAppLockShieldAction */; + targetProxy = E20000000000000000000003 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -569,6 +830,168 @@ }; name = Release; }; + E50000000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E50000000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Monitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E50000000000000000000021 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E50000000000000000000022 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldConfig; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E50000000000000000000031 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E50000000000000000000032 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.ShieldAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -608,6 +1031,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E40000000000000000000001 /* Build configuration list for PBXNativeTarget "OpenAppLockMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E50000000000000000000011 /* Debug */, + E50000000000000000000012 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E40000000000000000000002 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldConfig" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E50000000000000000000021 /* Debug */, + E50000000000000000000022 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E40000000000000000000003 /* Build configuration list for PBXNativeTarget "OpenAppLockShieldAction" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E50000000000000000000031 /* Debug */, + E50000000000000000000032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 20A7EDC62E47B7CD0097608D /* Project object */; diff --git a/OpenAppLock.xcodeproj/xcshareddata/xcschemes/OpenAppLock.xcscheme b/OpenAppLock.xcodeproj/xcshareddata/xcschemes/OpenAppLock.xcscheme new file mode 100644 index 0000000..6b1d32f --- /dev/null +++ b/OpenAppLock.xcodeproj/xcshareddata/xcschemes/OpenAppLock.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenAppLock/OpenAppLock.entitlements b/OpenAppLock/OpenAppLock.entitlements index e048c21..9038cbd 100644 --- a/OpenAppLock/OpenAppLock.entitlements +++ b/OpenAppLock/OpenAppLock.entitlements @@ -4,5 +4,9 @@ com.apple.developer.family-controls + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index 40da66a..0255ce8 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -50,7 +50,11 @@ struct OpenAppLockApp: App { let shields: ShieldApplying = 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 { diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index cf2d879..d8d5959 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -18,13 +18,20 @@ import Observation final class RuleEnforcer { private(set) var blockingRuleIDs: Set = [] private let shields: ShieldApplying + /// Mirrors rules to the app group and keeps DeviceActivity monitoring in + /// step; nil in UI-test launches. + private let scheduler: RuleScheduler? /// Day-usage source consulted for limit rules; also exposed to views for /// the Usage section. let usageReader: UsageReading - init(shields: ShieldApplying, usage: UsageReading = UsageLedger()) { + init( + shields: ShieldApplying, usage: UsageReading = UsageLedger(), + scheduler: RuleScheduler? = nil + ) { self.shields = shields self.usageReader = usage + self.scheduler = scheduler } /// The day's usage for a rule (nil for schedule rules, which don't track). @@ -56,5 +63,6 @@ final class RuleEnforcer { } shields.clearShields(except: active) blockingRuleIDs = active + scheduler?.sync(rules: rules, at: now) } } diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift new file mode 100644 index 0000000..fd99371 --- /dev/null +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -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 = [] + + 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 + } + } +} diff --git a/OpenAppLock/Services/ScreenTimeAuthorization.swift b/OpenAppLock/Services/ScreenTimeAuthorization.swift index bb9bffe..0ad6443 100644 --- a/OpenAppLock/Services/ScreenTimeAuthorization.swift +++ b/OpenAppLock/Services/ScreenTimeAuthorization.swift @@ -24,7 +24,7 @@ protocol AuthorizationProviding { struct FamilyControlsAuthorizationProvider: AuthorizationProviding { var currentStatus: ScreenTimeAuthorizationStatus { switch AuthorizationCenter.shared.authorizationStatus { - case .approved: .approved + case .approved, .approvedWithDataAccess: .approved case .denied: .denied case .notDetermined: .notDetermined @unknown default: .notDetermined diff --git a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift new file mode 100644 index 0000000..5c7c2cb --- /dev/null +++ b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift @@ -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) + } +} diff --git a/OpenAppLockMonitor/Info.plist b/OpenAppLockMonitor/Info.plist new file mode 100644 index 0000000..13f9299 --- /dev/null +++ b/OpenAppLockMonitor/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.monitor-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension + + + diff --git a/OpenAppLockMonitor/OpenAppLockMonitor.entitlements b/OpenAppLockMonitor/OpenAppLockMonitor.entitlements new file mode 100644 index 0000000..9038cbd --- /dev/null +++ b/OpenAppLockMonitor/OpenAppLockMonitor.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + diff --git a/OpenAppLockShieldAction/Info.plist b/OpenAppLockShieldAction/Info.plist new file mode 100644 index 0000000..e8acc18 --- /dev/null +++ b/OpenAppLockShieldAction/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.ManagedSettings.shield-action-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShieldActionExtension + + + diff --git a/OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements b/OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements new file mode 100644 index 0000000..9038cbd --- /dev/null +++ b/OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift new file mode 100644 index 0000000..cdac491 --- /dev/null +++ b/OpenAppLockShieldAction/ShieldActionExtension.swift @@ -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 = [.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 + ) + } +} diff --git a/OpenAppLockShieldConfig/Info.plist b/OpenAppLockShieldConfig/Info.plist new file mode 100644 index 0000000..c89920e --- /dev/null +++ b/OpenAppLockShieldConfig/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.ManagedSettingsUI.shield-configuration-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShieldConfigurationExtension + + + diff --git a/OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements b/OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements new file mode 100644 index 0000000..9038cbd --- /dev/null +++ b/OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + diff --git a/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift new file mode 100644 index 0000000..d69b7ec --- /dev/null +++ b/OpenAppLockShieldConfig/ShieldConfigurationExtension.swift @@ -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 + ) + } +} diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift new file mode 100644 index 0000000..9749bae --- /dev/null +++ b/OpenAppLockTests/SchedulingTests.swift @@ -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) + } +} diff --git a/OpenAppLock/Services/AppGroup.swift b/Shared/AppGroup.swift similarity index 100% rename from OpenAppLock/Services/AppGroup.swift rename to Shared/AppGroup.swift diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift new file mode 100644 index 0000000..86983c7 --- /dev/null +++ b/Shared/LimitEnforcement.swift @@ -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 + ) + } +} diff --git a/Shared/MonitoringPlan.swift b/Shared/MonitoringPlan.swift new file mode 100644 index 0000000..9d16adc --- /dev/null +++ b/Shared/MonitoringPlan.swift @@ -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) + } + ) + } +} diff --git a/OpenAppLock/Models/RuleKind.swift b/Shared/RuleKind.swift similarity index 100% rename from OpenAppLock/Models/RuleKind.swift rename to Shared/RuleKind.swift diff --git a/OpenAppLock/Logic/RuleSchedule.swift b/Shared/RuleSchedule.swift similarity index 100% rename from OpenAppLock/Logic/RuleSchedule.swift rename to Shared/RuleSchedule.swift diff --git a/Shared/RuleSnapshot.swift b/Shared/RuleSnapshot.swift new file mode 100644 index 0000000..cdbfd00 --- /dev/null +++ b/Shared/RuleSnapshot.swift @@ -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 { 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 } + } +} diff --git a/OpenAppLock/Services/ShieldController.swift b/Shared/ShieldController.swift similarity index 86% rename from OpenAppLock/Services/ShieldController.swift rename to Shared/ShieldController.swift index 8c8cab1..7886f0b 100644 --- a/OpenAppLock/Services/ShieldController.swift +++ b/Shared/ShieldController.swift @@ -13,18 +13,22 @@ protocol ShieldApplying: AnyObject { func applyShield( ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool ) + /// Clears the shield of a single rule (used by the extensions for day + /// resets and granted opens). + func clearShield(ruleID: UUID) /// Clears every shield except those for the given rule IDs. Covers rules /// that were deleted or expired while the app was not running. func clearShields(except activeRuleIDs: Set) } /// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names -/// are tracked in UserDefaults because ManagedSettings cannot enumerate them. +/// are tracked in the shared app-group defaults (ManagedSettings cannot +/// enumerate stores) so the app and extensions see one consistent set. final class ManagedSettingsShieldController: ShieldApplying { private static let trackedIDsKey = "shieldedRuleIDs" private let defaults: UserDefaults - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = AppGroup.defaults) { self.defaults = defaults } @@ -50,10 +54,14 @@ final class ManagedSettingsShieldController: ShieldApplying { track(ruleID: ruleID) } + func clearShield(ruleID: UUID) { + store(for: ruleID).clearAllSettings() + untrack(ruleID: ruleID) + } + func clearShields(except activeRuleIDs: Set) { for ruleID in trackedIDs.subtracting(activeRuleIDs) { - store(for: ruleID).clearAllSettings() - untrack(ruleID: ruleID) + clearShield(ruleID: ruleID) } } @@ -93,6 +101,13 @@ final class MockShieldController: ShieldApplying { appliedSelectionData[ruleID] = selectionData } + func clearShield(ruleID: UUID) { + shieldedRuleIDs.remove(ruleID) + appliedModes[ruleID] = nil + appliedAdultContentFlags[ruleID] = nil + appliedSelectionData[ruleID] = nil + } + func clearShields(except activeRuleIDs: Set) { shieldedRuleIDs.formIntersection(activeRuleIDs) appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) } diff --git a/Shared/ShieldLookup.swift b/Shared/ShieldLookup.swift new file mode 100644 index 0000000..7e9f675 --- /dev/null +++ b/Shared/ShieldLookup.swift @@ -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) + } + } +} diff --git a/OpenAppLock/Services/UsageLedger.swift b/Shared/UsageLedger.swift similarity index 100% rename from OpenAppLock/Services/UsageLedger.swift rename to Shared/UsageLedger.swift diff --git a/OpenAppLock/Models/Weekday.swift b/Shared/Weekday.swift similarity index 100% rename from OpenAppLock/Models/Weekday.swift rename to Shared/Weekday.swift diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 7630e33..19916a8 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -371,7 +371,33 @@ enum SelectionMode: String, Codable { case block, allowOnly } 6. Preset gallery content + polish (gradients, photos, haptics, live countdown timers). -### 5.5 Out of scope (seen in video, not part of "rules") +### 5.5 Background enforcement architecture (implemented) + +- **App group** `group.dev.bchen.OpenAppLock` shares three stores between the + app and its extensions: `RuleSnapshotStore` (Codable rule mirror, written + by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule, + per-day minutes/opens), and the shield-store tracking list. +- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the + enabled limit rules: one repeating 00:00–23:59 activity per rule + (`rule-`); time-limit rules carry one cumulative usage-threshold + event per budget minute (`minutes-`) 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-` event + records usage and shields at the budget; a finished `open-session-` + one-shot re-shields after a granted open. +- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit + shields show "Opened X of N times today" with an "Open (Y left)" secondary + button; other shields show the blocking rule's name. +- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press + spends one open in the ledger, lifts the rule's shield, and starts the + ~15-minute one-shot session (DeviceActivity's minimum interval). +- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit + tested from the app test target. + +### 5.6 Out of scope (seen in video, not part of "rules") - Onboarding flow, paywall ("You know Opal works. Make it permanent."), Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"