feat: introduce reusable app lists for rules
- AppList @Model with launch migration from legacy inline selections (rules with identical selections share one list; idempotent) - rules point at one app list; Block/Allow Only belongs to the rule and is offered only in the Schedule editor (limit kinds sanitize to Block) - app-list picker sheet (select / create / edit / delete with in-use protection) replaces the per-rule selection sheet - Hard Mode locks app-list editing while any hard rule is blocking - SwiftData stability: relationships are only wired between managed models, and unit tests share one in-memory container (fresh context + data wipe per test) — per-test container creation trapped intermittently inside SwiftData Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,15 @@ them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
|
|||||||
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.
|
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.
|
||||||
|
|
||||||
Gotchas learned the hard way:
|
Gotchas learned the hard way:
|
||||||
|
- **SwiftData relationships**: never assign a relationship property (e.g.
|
||||||
|
`rule.appList`) inside a model's `init` or on un-inserted instances —
|
||||||
|
insert both models into a context first, then wire them.
|
||||||
|
- **SwiftData container churn**: repeatedly creating `ModelContainer`s for
|
||||||
|
this schema traps intermittently (EXC_BREAKPOINT inside SwiftData's
|
||||||
|
configuration setup), which Xcode shows as a test "hang" paused at a
|
||||||
|
breakpoint. Unit tests must go through `makeInMemoryContext()`
|
||||||
|
(TestSupport.swift): one shared container per process, fresh context +
|
||||||
|
data wipe per test.
|
||||||
- Identifiers on SwiftUI containers need `.accessibilityElement(children:
|
- Identifiers on SwiftUI containers need `.accessibilityElement(children:
|
||||||
.combine)` (or a Button/control) to be queryable.
|
.combine)` (or a Button/control) to be queryable.
|
||||||
- List/Form section headers render uppercased unless `.textCase(nil)` —
|
- List/Form section headers render uppercased unless `.textCase(nil)` —
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ enum RulePolicy {
|
|||||||
!isHardLocked(rule, at: now, calendar: calendar)
|
!isHardLocked(rule, at: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App lists feed active shields, so while any hard-mode rule is actively
|
||||||
|
/// blocking, no list may be edited or deleted — changing a list would be a
|
||||||
|
/// back door out of the hard block. Creating new lists and picking lists
|
||||||
|
/// for other rules stay allowed; they cannot weaken an active block.
|
||||||
|
static func canEditAppLists(
|
||||||
|
rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current
|
||||||
|
) -> Bool {
|
||||||
|
!rules.contains { isHardLocked($0, at: now, calendar: calendar) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Pauses the rule's current window. Returns false (and changes nothing)
|
/// Pauses the rule's current window. Returns false (and changes nothing)
|
||||||
/// when unblocking is not allowed.
|
/// when unblocking is not allowed.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
47
OpenAppLock/Models/AppList.swift
Normal file
47
OpenAppLock/Models/AppList.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// AppList.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// A named, reusable selection of apps/categories/websites. Rules point at a
|
||||||
|
/// list, so editing the list affects every rule that uses it. Deleting a list
|
||||||
|
/// detaches it from its rules (they fall back to "no apps").
|
||||||
|
@Model
|
||||||
|
final class AppList {
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var name: String
|
||||||
|
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until apps are picked.
|
||||||
|
var selectionData: Data?
|
||||||
|
/// Denormalized count of selected apps/categories/domains for display.
|
||||||
|
var selectionCount: Int
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .nullify, inverse: \BlockingRule.appList)
|
||||||
|
var rules: [BlockingRule] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
selectionData: Data? = nil,
|
||||||
|
selectionCount: Int = 0,
|
||||||
|
createdAt: Date = .now
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.selectionData = selectionData
|
||||||
|
self.selectionCount = selectionCount
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any rule currently points at this list (guards deletion).
|
||||||
|
static func isInUse(_ list: AppList, context: ModelContext) -> Bool {
|
||||||
|
let listID = list.id
|
||||||
|
let descriptor = FetchDescriptor<BlockingRule>(
|
||||||
|
predicate: #Predicate { $0.appList?.id == listID }
|
||||||
|
)
|
||||||
|
return ((try? context.fetchCount(descriptor)) ?? 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,17 @@ final class BlockingRule {
|
|||||||
/// Inline default so existing stores migrate cleanly.
|
/// Inline default so existing stores migrate cleanly.
|
||||||
var blockAdultContent: Bool = false
|
var blockAdultContent: Bool = false
|
||||||
var selectionModeRaw: String
|
var selectionModeRaw: String
|
||||||
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
|
/// The reusable app list this rule blocks (or allows, in Allow Only mode).
|
||||||
|
///
|
||||||
|
/// Deliberately not an `init` parameter: SwiftData relationship properties
|
||||||
|
/// must only be assigned once both models are inserted in a context —
|
||||||
|
/// writing them on unmanaged instances traps intermittently inside
|
||||||
|
/// SwiftData (EXC_BREAKPOINT on the next insert/save).
|
||||||
|
var appList: AppList?
|
||||||
|
/// Legacy inline selection, superseded by `appList`. Kept only so
|
||||||
|
/// `AppListMigration` can read pre-app-list stores; always nil afterwards.
|
||||||
var selectionData: Data?
|
var selectionData: Data?
|
||||||
/// Denormalized count of selected apps/categories/domains for display.
|
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
|
||||||
var selectionCount: Int
|
var selectionCount: Int
|
||||||
var dayNumbers: [Int]
|
var dayNumbers: [Int]
|
||||||
var startMinutes: Int
|
var startMinutes: Int
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
/// Value-type working copy of a rule used by the editors, so cancelling an
|
||||||
/// edit never touches the persisted model. Hashable so it can drive
|
/// edit never touches the persisted model. Hashable so it can drive
|
||||||
@@ -19,8 +20,10 @@ struct RuleDraft: Hashable {
|
|||||||
var hardMode: Bool
|
var hardMode: Bool
|
||||||
var blockAdultContent: Bool
|
var blockAdultContent: Bool
|
||||||
var selectionMode: SelectionMode
|
var selectionMode: SelectionMode
|
||||||
var selectionData: Data?
|
/// Reference to the persisted list the rule will use. App lists are
|
||||||
var selectionCount: Int
|
/// managed (created/edited) directly by the picker, so the draft only
|
||||||
|
/// carries the pointer.
|
||||||
|
var appList: AppList?
|
||||||
|
|
||||||
/// A fresh draft for a new rule of the given kind, using the reference
|
/// A fresh draft for a new rule of the given kind, using the reference
|
||||||
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||||
@@ -35,8 +38,7 @@ struct RuleDraft: Hashable {
|
|||||||
self.hardMode = false
|
self.hardMode = false
|
||||||
self.blockAdultContent = false
|
self.blockAdultContent = false
|
||||||
self.selectionMode = .block
|
self.selectionMode = .block
|
||||||
self.selectionData = nil
|
self.appList = nil
|
||||||
self.selectionCount = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(rule: BlockingRule) {
|
init(rule: BlockingRule) {
|
||||||
@@ -50,8 +52,7 @@ struct RuleDraft: Hashable {
|
|||||||
self.hardMode = rule.hardMode
|
self.hardMode = rule.hardMode
|
||||||
self.blockAdultContent = rule.blockAdultContent
|
self.blockAdultContent = rule.blockAdultContent
|
||||||
self.selectionMode = rule.selectionMode
|
self.selectionMode = rule.selectionMode
|
||||||
self.selectionData = rule.selectionData
|
self.appList = rule.appList
|
||||||
self.selectionCount = rule.selectionCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(preset: RulePreset) {
|
init(preset: RulePreset) {
|
||||||
@@ -62,6 +63,9 @@ struct RuleDraft: Hashable {
|
|||||||
self.endMinutes = preset.endMinutes
|
self.endMinutes = preset.endMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the draft back onto a rule. The rule (and the chosen list) must
|
||||||
|
/// already be inserted in a context: SwiftData relationships may only be
|
||||||
|
/// assigned between managed models (see `BlockingRule.appList`).
|
||||||
func apply(to rule: BlockingRule) {
|
func apply(to rule: BlockingRule) {
|
||||||
rule.name = name
|
rule.name = name
|
||||||
rule.kind = kind
|
rule.kind = kind
|
||||||
@@ -73,22 +77,32 @@ struct RuleDraft: Hashable {
|
|||||||
rule.hardMode = hardMode
|
rule.hardMode = hardMode
|
||||||
rule.blockAdultContent = blockAdultContent
|
rule.blockAdultContent = blockAdultContent
|
||||||
rule.selectionMode = selectionMode
|
rule.selectionMode = selectionMode
|
||||||
rule.selectionData = selectionData
|
if rule.appList !== appList {
|
||||||
rule.selectionCount = selectionCount
|
rule.appList = appList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeRule() -> BlockingRule {
|
/// Creates and inserts a new rule from this draft. The rule is inserted
|
||||||
|
/// *before* the draft is applied so the app-list relationship is only
|
||||||
|
/// ever written on a managed model.
|
||||||
|
@discardableResult
|
||||||
|
func insertRule(into context: ModelContext) -> BlockingRule {
|
||||||
let rule = BlockingRule(name: name, kind: kind)
|
let rule = BlockingRule(name: name, kind: kind)
|
||||||
|
context.insert(rule)
|
||||||
apply(to: rule)
|
apply(to: rule)
|
||||||
return rule
|
return rule
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trims the name and falls back to the kind's default when it is empty,
|
/// Trims the name (falling back to the kind's default when it is empty)
|
||||||
/// so a cleared name field can never produce an unnamed rule.
|
/// and forces Block mode for time- and open-limit rules — Allow Only is a
|
||||||
|
/// schedule-rule concept.
|
||||||
func sanitized() -> RuleDraft {
|
func sanitized() -> RuleDraft {
|
||||||
var copy = self
|
var copy = self
|
||||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
||||||
|
if kind != .schedule {
|
||||||
|
copy.selectionMode = .block
|
||||||
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct OpenAppLockApp: App {
|
|||||||
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
|
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
|
||||||
}
|
}
|
||||||
|
|
||||||
let schema = Schema([BlockingRule.self])
|
let schema = Schema([BlockingRule.self, AppList.self])
|
||||||
let modelConfiguration = ModelConfiguration(
|
let modelConfiguration = ModelConfiguration(
|
||||||
schema: schema, isStoredInMemoryOnly: config.isUITesting
|
schema: schema, isStoredInMemoryOnly: config.isUITesting
|
||||||
)
|
)
|
||||||
@@ -34,6 +34,7 @@ struct OpenAppLockApp: App {
|
|||||||
if let scenario = config.seedScenario {
|
if let scenario = config.seedScenario {
|
||||||
SampleRules.seed(scenario, into: container.mainContext)
|
SampleRules.seed(scenario, into: container.mainContext)
|
||||||
}
|
}
|
||||||
|
AppListMigration.run(in: container.mainContext)
|
||||||
|
|
||||||
let authProvider: AuthorizationProviding =
|
let authProvider: AuthorizationProviding =
|
||||||
config.isUITesting
|
config.isUITesting
|
||||||
|
|||||||
45
OpenAppLock/Services/AppListMigration.swift
Normal file
45
OpenAppLock/Services/AppListMigration.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// AppListMigration.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// One-time launch migration from the legacy model where every rule carried
|
||||||
|
/// its own inline `FamilyActivitySelection` to shared, reusable app lists.
|
||||||
|
///
|
||||||
|
/// Each rule that still has inline selection data gets a list named after it;
|
||||||
|
/// rules with byte-identical selections share a single list. The inline copy
|
||||||
|
/// is cleared afterwards, which also makes the migration idempotent.
|
||||||
|
enum AppListMigration {
|
||||||
|
static func run(in context: ModelContext) {
|
||||||
|
let descriptor = FetchDescriptor<BlockingRule>(
|
||||||
|
predicate: #Predicate { $0.selectionData != nil }
|
||||||
|
)
|
||||||
|
guard let legacyRules = try? context.fetch(descriptor), !legacyRules.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listsBySelection: [Data: AppList] = [:]
|
||||||
|
for rule in legacyRules.sorted(by: { $0.createdAt < $1.createdAt }) {
|
||||||
|
guard rule.appList == nil, let selectionData = rule.selectionData else { continue }
|
||||||
|
let list: AppList
|
||||||
|
if let existing = listsBySelection[selectionData] {
|
||||||
|
list = existing
|
||||||
|
} else {
|
||||||
|
list = AppList(
|
||||||
|
name: "\(rule.name) Apps",
|
||||||
|
selectionData: selectionData,
|
||||||
|
selectionCount: rule.selectionCount
|
||||||
|
)
|
||||||
|
context.insert(list)
|
||||||
|
listsBySelection[selectionData] = list
|
||||||
|
}
|
||||||
|
rule.appList = list
|
||||||
|
rule.selectionData = nil
|
||||||
|
rule.selectionCount = 0
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ final class RuleEnforcer {
|
|||||||
active.insert(rule.id)
|
active.insert(rule.id)
|
||||||
shields.applyShield(
|
shields.applyShield(
|
||||||
ruleID: rule.id,
|
ruleID: rule.id,
|
||||||
selectionData: rule.selectionData,
|
selectionData: rule.appList?.selectionData,
|
||||||
mode: rule.selectionMode,
|
mode: rule.selectionMode,
|
||||||
blockAdultContent: rule.blockAdultContent
|
blockAdultContent: rule.blockAdultContent
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,13 +15,29 @@ enum SampleRules {
|
|||||||
now: Date = .now,
|
now: Date = .now,
|
||||||
calendar: Calendar = .current
|
calendar: Calendar = .current
|
||||||
) {
|
) {
|
||||||
|
// Shared list so seeded rules demonstrate the app-list UI. The count
|
||||||
|
// is display-only; UI tests never decode real tokens.
|
||||||
|
let distractions = AppList(name: "Distractions", selectionCount: 3)
|
||||||
|
context.insert(distractions)
|
||||||
|
|
||||||
|
let rules =
|
||||||
switch scenario {
|
switch scenario {
|
||||||
case .standard:
|
case .standard:
|
||||||
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar))
|
[
|
||||||
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
|
activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
|
||||||
|
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
||||||
|
]
|
||||||
case .hardModeActive:
|
case .hardModeActive:
|
||||||
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar))
|
[
|
||||||
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
|
activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar),
|
||||||
|
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Relationships are wired only after both sides are managed
|
||||||
|
// (see BlockingRule.appList).
|
||||||
|
for rule in rules {
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = distractions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ final class MockShieldController: ShieldApplying {
|
|||||||
private(set) var shieldedRuleIDs: Set<UUID> = []
|
private(set) var shieldedRuleIDs: Set<UUID> = []
|
||||||
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
||||||
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
|
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
|
||||||
|
private(set) var appliedSelectionData: [UUID: Data?] = [:]
|
||||||
|
|
||||||
func applyShield(
|
func applyShield(
|
||||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||||
@@ -89,6 +90,7 @@ final class MockShieldController: ShieldApplying {
|
|||||||
shieldedRuleIDs.insert(ruleID)
|
shieldedRuleIDs.insert(ruleID)
|
||||||
appliedModes[ruleID] = mode
|
appliedModes[ruleID] = mode
|
||||||
appliedAdultContentFlags[ruleID] = blockAdultContent
|
appliedAdultContentFlags[ruleID] = blockAdultContent
|
||||||
|
appliedSelectionData[ruleID] = selectionData
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||||
@@ -97,6 +99,7 @@ final class MockShieldController: ShieldApplying {
|
|||||||
appliedAdultContentFlags = appliedAdultContentFlags.filter {
|
appliedAdultContentFlags = appliedAdultContentFlags.filter {
|
||||||
activeRuleIDs.contains($0.key)
|
activeRuleIDs.contains($0.key)
|
||||||
}
|
}
|
||||||
|
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
OpenAppLock/Views/AppLists/AppListEditorView.swift
Normal file
101
OpenAppLock/Views/AppLists/AppListEditorView.swift
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// AppListEditorView.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import FamilyControls
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Creates or edits an app list: a name field above Apple's
|
||||||
|
/// `FamilyActivityPicker`. Pushed inside the App List picker's stack, so the
|
||||||
|
/// back swipe cancels and Save commits.
|
||||||
|
struct AppListEditorView: View {
|
||||||
|
/// Nil creates a new list; otherwise edits (and saves into) the given one.
|
||||||
|
let list: AppList?
|
||||||
|
var onComplete: (AppList) -> Void
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var selection: FamilyActivitySelection
|
||||||
|
|
||||||
|
init(list: AppList?, onComplete: @escaping (AppList) -> Void) {
|
||||||
|
self.list = list
|
||||||
|
self.onComplete = onComplete
|
||||||
|
self._name = State(initialValue: list?.name ?? "")
|
||||||
|
self._selection = State(initialValue: AppSelectionCodec.decode(list?.selectionData))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Styled like the app's other grouped inputs (rounded fill, no
|
||||||
|
// border) — FamilyActivityPicker below brings its own background,
|
||||||
|
// so the field sits directly on the sheet background.
|
||||||
|
TextField("List Name", text: $name)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.accessibilityIdentifier("appListNameField")
|
||||||
|
|
||||||
|
FamilyActivityPicker(selection: $selection)
|
||||||
|
}
|
||||||
|
.navigationTitle(list == nil ? "New List" : "Edit List")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(role: .confirm) {
|
||||||
|
save()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Save List")
|
||||||
|
.accessibilityIdentifier("saveAppListButton")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(selectionSummary)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("selectionCountLabel")
|
||||||
|
Button {
|
||||||
|
save()
|
||||||
|
} label: {
|
||||||
|
Text("Save")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("saveAppListBottomButton")
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectionSummary: String {
|
||||||
|
let count = AppSelectionCodec.count(of: selection)
|
||||||
|
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
let resolvedName = trimmed.isEmpty ? "Untitled List" : trimmed
|
||||||
|
let data = AppSelectionCodec.encode(selection)
|
||||||
|
let count = AppSelectionCodec.count(of: selection)
|
||||||
|
|
||||||
|
if let list {
|
||||||
|
list.name = resolvedName
|
||||||
|
list.selectionData = data
|
||||||
|
list.selectionCount = count
|
||||||
|
onComplete(list)
|
||||||
|
} else {
|
||||||
|
let created = AppList(name: resolvedName, selectionData: data, selectionCount: count)
|
||||||
|
modelContext.insert(created)
|
||||||
|
onComplete(created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal file
156
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// AppListPickerSheet.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Chooses the app list a rule uses: saved lists (tap to select), an Edit
|
||||||
|
/// affordance per list, and a "New List" flow. Lists are persisted directly,
|
||||||
|
/// independent of any rule, so creating one here never depends on the rule
|
||||||
|
/// draft being committed.
|
||||||
|
struct AppListPickerSheet: View {
|
||||||
|
@Binding var selected: AppList?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query(sort: \AppList.createdAt) private var lists: [AppList]
|
||||||
|
@Query private var rules: [BlockingRule]
|
||||||
|
|
||||||
|
@State private var editingList: AppList?
|
||||||
|
@State private var creatingList = false
|
||||||
|
@State private var deletionBlocked = false
|
||||||
|
|
||||||
|
/// While any hard-mode rule is actively blocking, lists are read-only —
|
||||||
|
/// editing one would be a back door out of the hard block.
|
||||||
|
private var listsLocked: Bool {
|
||||||
|
!RulePolicy.canEditAppLists(rules: rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
if lists.isEmpty {
|
||||||
|
Text("No app lists yet. Create one to choose which apps this rule affects.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("emptyAppListsLabel")
|
||||||
|
} else {
|
||||||
|
ForEach(lists) { list in
|
||||||
|
listRow(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Your App Lists").textCase(nil)
|
||||||
|
} footer: {
|
||||||
|
if listsLocked {
|
||||||
|
Label(
|
||||||
|
"Hard Mode is on — app lists are locked until the block ends.",
|
||||||
|
systemImage: "lock.fill"
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityIdentifier("appListsLockedNotice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
creatingList = true
|
||||||
|
} label: {
|
||||||
|
Label("New List", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("newAppListButton")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("App List")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Close", systemImage: "xmark") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("closeAppListPickerButton")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $creatingList) {
|
||||||
|
AppListEditorView(list: nil) { created in
|
||||||
|
selected = created
|
||||||
|
creatingList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $editingList) { list in
|
||||||
|
AppListEditorView(list: list) { _ in
|
||||||
|
editingList = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("This list is in use", isPresented: $deletionBlocked) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Remove it from the rules that use it before deleting.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listRow(_ list: AppList) -> some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
selected = list
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(
|
||||||
|
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
|
||||||
|
)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(list.name)
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
Text(list.appCountLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("appListRow-\(list.name)")
|
||||||
|
Spacer()
|
||||||
|
if !listsLocked {
|
||||||
|
Button("Edit") {
|
||||||
|
editingList = list
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.accessibilityIdentifier("editAppListButton-\(list.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.swipeActions {
|
||||||
|
if !listsLocked {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
delete(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelected(_ list: AppList) -> Bool {
|
||||||
|
selected?.id == list.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ list: AppList) {
|
||||||
|
guard !AppList.isInUse(list, context: modelContext) else {
|
||||||
|
deletionBlocked = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSelected(list) {
|
||||||
|
selected = nil
|
||||||
|
}
|
||||||
|
modelContext.delete(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppList {
|
||||||
|
/// "4 Apps" / "1 App" label shared by the picker, editor, and detail rows.
|
||||||
|
var appCountLabel: String {
|
||||||
|
selectionCount == 1 ? "1 App" : "\(selectionCount) Apps"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,8 +173,7 @@ struct AppsHomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func blockSummary(for rule: BlockingRule) -> String {
|
private func blockSummary(for rule: BlockingRule) -> String {
|
||||||
let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps"
|
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
|
||||||
return "\(rule.selectionMode.displayName) · \(apps)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
||||||
@@ -195,7 +194,9 @@ struct AppsHomeView: View {
|
|||||||
rules.map {
|
rules.map {
|
||||||
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
|
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
|
||||||
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
|
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
|
||||||
+ "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
+ "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
|
||||||
|
+ "\($0.appList?.selectionCount ?? 0)|"
|
||||||
|
+ "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
||||||
}
|
}
|
||||||
.joined(separator: ",")
|
.joined(separator: ",")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// AppSelectionSheet.swift
|
|
||||||
// OpenAppLock
|
|
||||||
//
|
|
||||||
|
|
||||||
import FamilyControls
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// App/category/website selection wrapping Apple's `FamilyActivityPicker`,
|
|
||||||
/// presented as a plain sheet with a segmented Block / Allow Only control.
|
|
||||||
struct AppSelectionSheet: View {
|
|
||||||
@Binding var draft: RuleDraft
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
@State private var selection: FamilyActivitySelection
|
|
||||||
@State private var mode: SelectionMode
|
|
||||||
|
|
||||||
init(draft: Binding<RuleDraft>) {
|
|
||||||
self._draft = draft
|
|
||||||
self._selection = State(
|
|
||||||
initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData)
|
|
||||||
)
|
|
||||||
self._mode = State(initialValue: draft.wrappedValue.selectionMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Picker("Mode", selection: $mode) {
|
|
||||||
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
|
||||||
Text(mode.displayName).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.accessibilityIdentifier("selectionModePicker")
|
|
||||||
|
|
||||||
FamilyActivityPicker(selection: $selection)
|
|
||||||
}
|
|
||||||
.navigationTitle("Selected")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("selectionBackButton")
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("confirmSelectionButton")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.safeAreaInset(edge: .bottom) {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text(selectionSummary)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.accessibilityIdentifier("selectionCountLabel")
|
|
||||||
Button {
|
|
||||||
save()
|
|
||||||
} label: {
|
|
||||||
Text("Save")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.large)
|
|
||||||
.accessibilityIdentifier("saveSelectionButton")
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var selectionSummary: String {
|
|
||||||
let count = AppSelectionCodec.count(of: selection)
|
|
||||||
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func save() {
|
|
||||||
draft.selectionData = AppSelectionCodec.encode(selection)
|
|
||||||
draft.selectionCount = AppSelectionCodec.count(of: selection)
|
|
||||||
draft.selectionMode = mode
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ struct NewRuleSheet: View {
|
|||||||
mode: .create,
|
mode: .create,
|
||||||
draft: draft,
|
draft: draft,
|
||||||
onCommit: { committed in
|
onCommit: { committed in
|
||||||
modelContext.insert(committed.makeRule())
|
committed.insertRule(into: modelContext)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ struct RuleDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var appCountLabel: String {
|
private var appCountLabel: String {
|
||||||
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
|
guard let list = rule.appList else { return "No apps" }
|
||||||
|
return "\(list.name) · \(list.appCountLabel)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ label: String, _ value: String) -> some View {
|
private func row(_ label: String, _ value: String) -> some View {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAppPicker) {
|
.sheet(isPresented: $showingAppPicker) {
|
||||||
AppSelectionSheet(draft: $draft)
|
AppListPickerSheet(selected: $draft.appList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +112,22 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
daysSection
|
daysSection
|
||||||
Section {
|
Section {
|
||||||
selectedAppsRow
|
Picker("Mode", selection: $draft.selectionMode) {
|
||||||
|
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.accessibilityIdentifier("selectionModePicker")
|
||||||
|
appListRow
|
||||||
} header: {
|
} header: {
|
||||||
Text("Apps are blocked").textCase(nil)
|
Text(draft.selectionMode == .block ? "Apps are blocked" : "Only these apps are allowed")
|
||||||
|
.textCase(nil)
|
||||||
}
|
}
|
||||||
toggleSections
|
toggleSections
|
||||||
case .timeLimit:
|
case .timeLimit:
|
||||||
Section {
|
Section {
|
||||||
selectedAppsRow
|
appListRow
|
||||||
} header: {
|
} header: {
|
||||||
Text("When I use").textCase(nil)
|
Text("When I use").textCase(nil)
|
||||||
}
|
}
|
||||||
@@ -138,7 +146,7 @@ struct RuleEditorView: View {
|
|||||||
toggleSections
|
toggleSections
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
Section {
|
Section {
|
||||||
selectedAppsRow
|
appListRow
|
||||||
} header: {
|
} header: {
|
||||||
Text("When I open").textCase(nil)
|
Text("When I open").textCase(nil)
|
||||||
}
|
}
|
||||||
@@ -204,15 +212,15 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedAppsRow: some View {
|
private var appListRow: some View {
|
||||||
Button {
|
Button {
|
||||||
showingAppPicker = true
|
showingAppPicker = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Selected Apps")
|
Text("App List")
|
||||||
.foregroundStyle(Color.primary)
|
.foregroundStyle(Color.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
|
Text(appListLabel)
|
||||||
.foregroundStyle(Color.secondary)
|
.foregroundStyle(Color.secondary)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
@@ -222,6 +230,11 @@ struct RuleEditorView: View {
|
|||||||
.accessibilityIdentifier("selectedAppsRow")
|
.accessibilityIdentifier("selectedAppsRow")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var appListLabel: String {
|
||||||
|
guard let list = draft.appList else { return "Choose" }
|
||||||
|
return "\(list.name) · \(list.appCountLabel)"
|
||||||
|
}
|
||||||
|
|
||||||
private func budgetRow(
|
private func budgetRow(
|
||||||
value: String,
|
value: String,
|
||||||
stepperID: String,
|
stepperID: String,
|
||||||
|
|||||||
236
OpenAppLockTests/AppListTests.swift
Normal file
236
OpenAppLockTests/AppListTests.swift
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//
|
||||||
|
// AppListTests.swift
|
||||||
|
// OpenAppLockTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import OpenAppLock
|
||||||
|
|
||||||
|
// Note: every test wires `rule.appList` only after both models are inserted —
|
||||||
|
// SwiftData relationships must not be written on unmanaged instances
|
||||||
|
// (see BlockingRule.appList).
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("AppList model & relationship")
|
||||||
|
struct AppListModelTests {
|
||||||
|
@Test("App lists persist and fetch through SwiftData")
|
||||||
|
func persistence() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let list = AppList(name: "Distractions", selectionData: Data([1, 2]), selectionCount: 2)
|
||||||
|
context.insert(list)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let fetched = try context.fetch(FetchDescriptor<AppList>())
|
||||||
|
#expect(fetched.count == 1)
|
||||||
|
let saved = try #require(fetched.first)
|
||||||
|
#expect(saved.name == "Distractions")
|
||||||
|
#expect(saved.selectionCount == 2)
|
||||||
|
#expect(saved.selectionData == Data([1, 2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Deleting a list detaches it from its rules")
|
||||||
|
func deletingListDetachesRules() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let list = AppList(name: "Distractions")
|
||||||
|
let rule = BlockingRule(name: "Work Time")
|
||||||
|
context.insert(list)
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = list
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
context.delete(list)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let rules = try context.fetch(FetchDescriptor<BlockingRule>())
|
||||||
|
#expect(rules.count == 1)
|
||||||
|
#expect(rules.first?.appList == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Deleting a rule keeps its list")
|
||||||
|
func deletingRuleKeepsList() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let list = AppList(name: "Distractions")
|
||||||
|
let rule = BlockingRule(name: "Work Time")
|
||||||
|
context.insert(list)
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = list
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
context.delete(rule)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||||
|
#expect(lists.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lists report whether any rule uses them")
|
||||||
|
func usageQuery() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let used = AppList(name: "Used")
|
||||||
|
let unused = AppList(name: "Unused")
|
||||||
|
let rule = BlockingRule(name: "Work Time")
|
||||||
|
context.insert(used)
|
||||||
|
context.insert(unused)
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = used
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
#expect(AppList.isInUse(used, context: context))
|
||||||
|
#expect(!AppList.isInUse(unused, context: context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Legacy selection → AppList migration")
|
||||||
|
struct AppListMigrationTests {
|
||||||
|
@Test("Rules with legacy inline selections get a list named after them")
|
||||||
|
func createsListsFromLegacySelections() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3)
|
||||||
|
context.insert(rule)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
AppListMigration.run(in: context)
|
||||||
|
|
||||||
|
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||||
|
#expect(lists.count == 1)
|
||||||
|
let list = try #require(rule.appList)
|
||||||
|
#expect(list.name == "Work Time Apps")
|
||||||
|
#expect(list.selectionData == Data([1]))
|
||||||
|
#expect(list.selectionCount == 3)
|
||||||
|
// The legacy inline copy is cleared so migration never re-runs.
|
||||||
|
#expect(rule.selectionData == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rules with identical selections share one list")
|
||||||
|
func sharesListForIdenticalSelections() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
||||||
|
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
||||||
|
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
||||||
|
context.insert(first)
|
||||||
|
context.insert(second)
|
||||||
|
context.insert(different)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
AppListMigration.run(in: context)
|
||||||
|
|
||||||
|
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||||
|
#expect(lists.count == 2)
|
||||||
|
#expect(first.appList === second.appList)
|
||||||
|
#expect(first.appList !== different.appList)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Migration is idempotent and skips selection-less rules")
|
||||||
|
func idempotentAndSkipsEmpty() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
|
||||||
|
let empty = BlockingRule(name: "No Apps")
|
||||||
|
context.insert(legacy)
|
||||||
|
context.insert(empty)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
AppListMigration.run(in: context)
|
||||||
|
AppListMigration.run(in: context)
|
||||||
|
|
||||||
|
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||||
|
#expect(lists.count == 1)
|
||||||
|
#expect(empty.appList == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Rule drafts with app lists")
|
||||||
|
struct AppListDraftTests {
|
||||||
|
@Test("Drafts carry the rule's app list and apply it back")
|
||||||
|
func draftCarriesAppList() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let list = AppList(name: "Distractions", selectionCount: 4)
|
||||||
|
let rule = BlockingRule(name: "Work Time")
|
||||||
|
context.insert(list)
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = list
|
||||||
|
|
||||||
|
var draft = RuleDraft(rule: rule)
|
||||||
|
#expect(draft.appList === list)
|
||||||
|
|
||||||
|
draft.name = "Other"
|
||||||
|
let other = draft.insertRule(into: context)
|
||||||
|
#expect(other.appList === list)
|
||||||
|
#expect(other.name == "Other")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sanitizing forces Block mode for time- and open-limit rules")
|
||||||
|
func sanitizedForcesBlockForLimitKinds() {
|
||||||
|
var timeDraft = RuleDraft(kind: .timeLimit)
|
||||||
|
timeDraft.selectionMode = .allowOnly
|
||||||
|
#expect(timeDraft.sanitized().selectionMode == .block)
|
||||||
|
|
||||||
|
var openDraft = RuleDraft(kind: .openLimit)
|
||||||
|
openDraft.selectionMode = .allowOnly
|
||||||
|
#expect(openDraft.sanitized().selectionMode == .block)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sanitizing keeps Allow Only on schedule rules")
|
||||||
|
func sanitizedKeepsAllowOnlyForSchedule() {
|
||||||
|
var draft = RuleDraft(kind: .schedule)
|
||||||
|
draft.selectionMode = .allowOnly
|
||||||
|
#expect(draft.sanitized().selectionMode == .allowOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("App-list editing under Hard Mode")
|
||||||
|
struct AppListEditingPolicyTests {
|
||||||
|
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||||
|
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||||
|
|
||||||
|
@Test("App lists are locked while any hard-mode rule is actively blocking")
|
||||||
|
func lockedDuringHardModeSession() {
|
||||||
|
let hard = BlockingRule(name: "Locked In", hardMode: true)
|
||||||
|
let soft = BlockingRule(name: "Work Time")
|
||||||
|
#expect(
|
||||||
|
!RulePolicy.canEditAppLists(rules: [hard, soft], at: mondayDuringWork, calendar: utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("App lists stay editable when no hard-mode rule is blocking")
|
||||||
|
func editableWithoutHardSession() {
|
||||||
|
let softActive = BlockingRule(name: "Work Time")
|
||||||
|
let hardInactive = BlockingRule(name: "Locked In", hardMode: true)
|
||||||
|
#expect(
|
||||||
|
RulePolicy.canEditAppLists(
|
||||||
|
rules: [softActive, hardInactive], at: mondayEvening, calendar: utc))
|
||||||
|
#expect(RulePolicy.canEditAppLists(rules: [], at: mondayDuringWork, calendar: utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A disabled hard-mode rule does not lock app lists")
|
||||||
|
func disabledHardRuleDoesNotLock() {
|
||||||
|
let rule = BlockingRule(name: "Locked In", isEnabled: false, hardMode: true)
|
||||||
|
#expect(RulePolicy.canEditAppLists(rules: [rule], at: mondayDuringWork, calendar: utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Enforcement reads selections through app lists")
|
||||||
|
struct AppListEnforcementTests {
|
||||||
|
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||||
|
|
||||||
|
@Test("The rule's app-list selection reaches the shield layer")
|
||||||
|
func forwardsAppListSelection() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields)
|
||||||
|
let list = AppList(name: "Distractions", selectionData: Data([1, 2, 3]))
|
||||||
|
let rule = BlockingRule(name: "Work Time")
|
||||||
|
context.insert(list)
|
||||||
|
context.insert(rule)
|
||||||
|
rule.appList = list
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,11 +47,7 @@ struct RuleModelTests {
|
|||||||
|
|
||||||
@Test("Rules persist and fetch through SwiftData")
|
@Test("Rules persist and fetch through SwiftData")
|
||||||
func persistence() throws {
|
func persistence() throws {
|
||||||
let container = try ModelContainer(
|
let context = try makeInMemoryContext()
|
||||||
for: BlockingRule.self,
|
|
||||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
|
||||||
)
|
|
||||||
let context = container.mainContext
|
|
||||||
let rule = BlockingRule(
|
let rule = BlockingRule(
|
||||||
name: "Deep Sleep", hardMode: true,
|
name: "Deep Sleep", hardMode: true,
|
||||||
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
||||||
@@ -83,7 +79,11 @@ struct RuleDraftTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test("Draft → rule → draft round-trips every field")
|
@Test("Draft → rule → draft round-trips every field")
|
||||||
func roundTrip() {
|
func roundTrip() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
|
let list = AppList(name: "Distractions", selectionCount: 3)
|
||||||
|
context.insert(list)
|
||||||
|
|
||||||
var draft = RuleDraft(kind: .schedule)
|
var draft = RuleDraft(kind: .schedule)
|
||||||
draft.name = "Locked In"
|
draft.name = "Locked In"
|
||||||
draft.days = Weekday.everyDay
|
draft.days = Weekday.everyDay
|
||||||
@@ -92,16 +92,18 @@ struct RuleDraftTests {
|
|||||||
draft.hardMode = true
|
draft.hardMode = true
|
||||||
draft.blockAdultContent = true
|
draft.blockAdultContent = true
|
||||||
draft.selectionMode = .allowOnly
|
draft.selectionMode = .allowOnly
|
||||||
draft.selectionCount = 3
|
draft.appList = list
|
||||||
|
|
||||||
let rule = draft.makeRule()
|
let rule = draft.insertRule(into: context)
|
||||||
#expect(rule.blockAdultContent)
|
#expect(rule.blockAdultContent)
|
||||||
#expect(RuleDraft(rule: rule) == draft)
|
#expect(RuleDraft(rule: rule) == draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Applying a draft updates an existing rule")
|
@Test("Applying a draft updates an existing rule")
|
||||||
func applyToExisting() {
|
func applyToExisting() throws {
|
||||||
|
let context = try makeInMemoryContext()
|
||||||
let rule = BlockingRule(name: "Old Name")
|
let rule = BlockingRule(name: "Old Name")
|
||||||
|
context.insert(rule)
|
||||||
var draft = RuleDraft(rule: rule)
|
var draft = RuleDraft(rule: rule)
|
||||||
draft.name = "New Name"
|
draft.name = "New Name"
|
||||||
draft.hardMode = true
|
draft.hardMode = true
|
||||||
|
|||||||
@@ -4,9 +4,45 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
@testable import OpenAppLock
|
@testable import OpenAppLock
|
||||||
|
|
||||||
|
/// One in-memory ModelContainer shared by the whole test process.
|
||||||
|
///
|
||||||
|
/// Repeatedly creating containers for this schema (inverse relationships)
|
||||||
|
/// trips an intermittent EXC_BREAKPOINT inside SwiftData's configuration
|
||||||
|
/// setup — observed both in test runs and in a sequential snippet that made
|
||||||
|
/// six containers. Tests therefore share a single container; isolation comes
|
||||||
|
/// from a fresh ModelContext plus a data wipe per test.
|
||||||
|
@MainActor
|
||||||
|
private let sharedTestContainer: ModelContainer = {
|
||||||
|
do {
|
||||||
|
return try ModelContainer(
|
||||||
|
for: Schema([BlockingRule.self, AppList.self]),
|
||||||
|
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
fatalError("Could not create the shared test ModelContainer: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Fresh, empty SwiftData context for model tests. The wipe deletes object
|
||||||
|
/// by object — batch `delete(model:)` refuses to fire the appList nullify
|
||||||
|
/// inverse ("Constraint trigger violation").
|
||||||
|
@MainActor
|
||||||
|
func makeInMemoryContext() throws -> ModelContext {
|
||||||
|
let context = ModelContext(sharedTestContainer)
|
||||||
|
for rule in try context.fetch(FetchDescriptor<BlockingRule>()) {
|
||||||
|
context.delete(rule)
|
||||||
|
}
|
||||||
|
for list in try context.fetch(FetchDescriptor<AppList>()) {
|
||||||
|
context.delete(list)
|
||||||
|
}
|
||||||
|
try context.save()
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless
|
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless
|
||||||
/// of the machine running the tests.
|
/// of the machine running the tests.
|
||||||
let utc: Calendar = {
|
let utc: Calendar = {
|
||||||
|
|||||||
106
OpenAppLockUITests/AppListUITests.swift
Normal file
106
OpenAppLockUITests/AppListUITests.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// AppListUITests.swift
|
||||||
|
// OpenAppLockUITests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// App lists: the editor's App List row, the picker (select / create / edit),
|
||||||
|
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown.
|
||||||
|
final class AppListUITests: XCTestCase {
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScheduleEditorOffersModeChoice() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock()
|
||||||
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||||
|
|
||||||
|
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||||
|
app.swipeUp()
|
||||||
|
app.element("selectionModePicker").waitToAppear()
|
||||||
|
XCTAssertTrue(app.staticTexts["Apps are blocked"].exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLimitEditorsAreBlockOnly() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock()
|
||||||
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
|
||||||
|
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||||
|
app.element("selectedAppsRow").waitToAppear()
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.element("selectionModePicker").exists,
|
||||||
|
"Time-limit rules are always Block; the mode picker must not appear"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Back to the rule-type list, then check the open-limit editor too.
|
||||||
|
let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
|
||||||
|
let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
||||||
|
edge.press(forDuration: 0.05, thenDragTo: middle)
|
||||||
|
|
||||||
|
app.buttons["ruleKind-openLimit"].waitToAppear().tap()
|
||||||
|
app.element("selectedAppsRow").waitToAppear()
|
||||||
|
XCTAssertFalse(app.element("selectionModePicker").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateAppListFlowSelectsNewList() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock()
|
||||||
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||||
|
|
||||||
|
app.element("selectedAppsRow").waitToAppear().tap()
|
||||||
|
|
||||||
|
// Fresh install: no lists yet, so the picker offers creation.
|
||||||
|
app.element("emptyAppListsLabel").waitToAppear()
|
||||||
|
app.buttons["newAppListButton"].tap()
|
||||||
|
|
||||||
|
let nameField = app.textFields["appListNameField"].waitToAppear()
|
||||||
|
nameField.tap()
|
||||||
|
nameField.typeText("Focus Apps\n")
|
||||||
|
app.buttons["saveAppListButton"].waitToAppear().tap()
|
||||||
|
|
||||||
|
// Saving pops back to the picker with the new list selected.
|
||||||
|
app.element("appListRow-Focus Apps").waitToAppear()
|
||||||
|
app.buttons["closeAppListPickerButton"].tap()
|
||||||
|
|
||||||
|
// The editor row now reports the chosen list.
|
||||||
|
let row = app.element("selectedAppsRow").waitToAppear()
|
||||||
|
XCTAssertTrue(row.label.contains("Focus Apps"), "Got: \(row.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailShowsAppListName() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||||
|
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||||
|
|
||||||
|
let row = app.element("detailRow-Block").waitToAppear()
|
||||||
|
XCTAssertTrue(row.label.contains("Distractions"), "Got: \(row.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHardModeSessionLocksAppListEditing() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||||
|
|
||||||
|
// "Sleep" is soft and editable even while "Locked In" hard-blocks.
|
||||||
|
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||||
|
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||||
|
app.element("selectedAppsRow").waitToAppear().tap()
|
||||||
|
|
||||||
|
app.element("appListRow-Distractions").waitToAppear()
|
||||||
|
app.element("appListsLockedNotice").waitToAppear()
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.buttons["editAppListButton-Distractions"].exists,
|
||||||
|
"App lists must be read-only while a Hard Mode rule is blocking"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAppListsEditableWithoutHardSession() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||||
|
|
||||||
|
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||||
|
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||||
|
app.element("selectedAppsRow").waitToAppear().tap()
|
||||||
|
|
||||||
|
app.buttons["editAppListButton-Distractions"].waitToAppear()
|
||||||
|
XCTAssertFalse(app.element("appListsLockedNotice").exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,16 @@ Common attributes across all types:
|
|||||||
|
|
||||||
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
|
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
|
||||||
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
|
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
|
||||||
- **App selection** — apps, categories, and websites; selection mode is either **Block** (block selected) or **Allow Only** (block everything except selected)
|
- **App List** *(OpenAppLock refinement)* — each rule points to exactly one
|
||||||
|
**App List**: a named, reusable selection of apps/categories/websites stored
|
||||||
|
independently of any rule. When editing a rule the user picks an existing
|
||||||
|
list or creates a new one; editing a list affects every rule that uses it.
|
||||||
|
Deleting a list detaches it from its rules (they fall back to "no apps").
|
||||||
|
- **Selection mode** — **Block** (block the list) or **Allow Only** (block
|
||||||
|
everything except the list). The mode belongs to the *rule*, not the list.
|
||||||
|
Only **Schedule** rules offer the choice; Time Limit and Open Limit rules
|
||||||
|
are always Block (a usage budget over "everything except X" is not
|
||||||
|
meaningful, and drafts of those kinds are sanitized back to Block).
|
||||||
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
|
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
|
||||||
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
|
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
|
||||||
|
|
||||||
@@ -147,7 +156,11 @@ Sections (each an inset rounded group with a small icon + caption header):
|
|||||||
row of 7 circular toggles `S M T W T F S`; selected = filled white circle
|
row of 7 circular toggles `S M T W T F S`; selected = filled white circle
|
||||||
with black letter, unselected = dark circle.
|
with black letter, unselected = dark circle.
|
||||||
3. **🛡 Apps are blocked**
|
3. **🛡 Apps are blocked**
|
||||||
- Row: `Selected Apps` → `N Apps ›` — pushes the App Picker.
|
- Row: `App List` → `<list name> · N Apps ›` (or `Choose ›` when none) —
|
||||||
|
presents the App List picker.
|
||||||
|
- A segmented `Block | Allow Only` row (Schedule editor only) chooses how
|
||||||
|
the rule interprets its list; the section header reads "Apps are
|
||||||
|
blocked" / "Only these apps are allowed" accordingly.
|
||||||
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
||||||
toggle.
|
toggle.
|
||||||
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* —
|
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* —
|
||||||
@@ -209,8 +222,22 @@ Full-height sheet:
|
|||||||
> parties cannot enumerate installed apps; the system-sanctioned route is
|
> parties cannot enumerate installed apps; the system-sanctioned route is
|
||||||
> `FamilyActivityPicker` (FamilyControls), which provides its own
|
> `FamilyActivityPicker` (FamilyControls), which provides its own
|
||||||
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
|
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
|
||||||
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker, and
|
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker.
|
||||||
> keep the `Block`/`Allow Only` segmented control as our own wrapper state.
|
>
|
||||||
|
> **App Lists (OpenAppLock):** the selection itself lives on a reusable
|
||||||
|
> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`).
|
||||||
|
> The editor's App List row presents a picker sheet listing saved lists
|
||||||
|
> (checkmark on the rule's current list; tap to select), an Edit affordance
|
||||||
|
> per list, and a "New List" flow — a name field plus an embedded
|
||||||
|
> `FamilyActivityPicker`. The `Block`/`Allow Only` segmented control lives in
|
||||||
|
> the Schedule rule editor (it is rule state, not list state). Legacy rules
|
||||||
|
> that stored an inline selection are migrated at launch: one list per
|
||||||
|
> distinct selection (rules sharing identical selection data share a list),
|
||||||
|
> named "<rule name> Apps". Lists in use by a rule cannot be deleted from the
|
||||||
|
> picker. While any **Hard Mode** rule is actively blocking, all lists are
|
||||||
|
> read-only — the picker hides Edit/Delete and shows a lock notice — because
|
||||||
|
> editing a list would be a back door out of the hard block. Creating new
|
||||||
|
> lists and selecting lists for other rules remain available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user