diff --git a/Design/audit-0.1.1/01-selective-scrolled-bottom.png b/Design/audit-0.1.1/01-selective-scrolled-bottom.png new file mode 100644 index 0000000..d57a40e Binary files /dev/null and b/Design/audit-0.1.1/01-selective-scrolled-bottom.png differ diff --git a/Design/audit-0.1.1/02-selective-bottom-with-return.png b/Design/audit-0.1.1/02-selective-bottom-with-return.png new file mode 100644 index 0000000..ce502ae Binary files /dev/null and b/Design/audit-0.1.1/02-selective-bottom-with-return.png differ diff --git a/Design/audit-0.1.1/03-selective-returned-to-tabs.png b/Design/audit-0.1.1/03-selective-returned-to-tabs.png new file mode 100644 index 0000000..3ee3162 Binary files /dev/null and b/Design/audit-0.1.1/03-selective-returned-to-tabs.png differ diff --git a/Design/audit-0.1.1/04-permission-alert-immediate.png b/Design/audit-0.1.1/04-permission-alert-immediate.png new file mode 100644 index 0000000..7545a0b Binary files /dev/null and b/Design/audit-0.1.1/04-permission-alert-immediate.png differ diff --git a/Design/audit-0.1.1/notes.md b/Design/audit-0.1.1/notes.md new file mode 100644 index 0000000..15706a5 --- /dev/null +++ b/Design/audit-0.1.1/notes.md @@ -0,0 +1,29 @@ +# Session flow audit + +## Step 1 — Selective mode after scrolling + +Health: Needs improvement. + +The mode selector is outside the viewport, and the bottom of the page offers no route back to Cleaning or Pet / Kid. The screen can be mistaken for a standalone feature. Screenshot: `01-selective-scrolled-bottom.png`. + +## Step 2 — Selective mode with return control + +Health: Healthy. + +A quiet, right-aligned **Back to modes** action appears after the primary button. It is visible at the bottom without competing with Start Selective Lock. Screenshot: `02-selective-bottom-with-return.png`. + +## Step 3 — Returned to the mode selector + +Health: Healthy. + +Activating Back to modes scrolls directly to the three mode tabs and preserves the Selective selection. Screenshot: `03-selective-returned-to-tabs.png`. + +## Step 4 — Missing Input Monitoring permission + +Health: Healthy. + +Clicking Start presents the permission error immediately, before any countdown begins. The alert exposes clearly labelled Settings and Cancel actions. Screenshot: `04-permission-alert-immediate.png`. + +## Evidence limits + +The Pet / Kid and Selective window-hiding behavior was verified through the activation-state implementation and unit coverage for the mode policy. A full live lock was not captured because doing so would intentionally block the computer input used for this audit. diff --git a/Sources/CleanMyScreen/ContentView.swift b/Sources/CleanMyScreen/ContentView.swift index 9b750f5..a5cc735 100644 --- a/Sources/CleanMyScreen/ContentView.swift +++ b/Sources/CleanMyScreen/ContentView.swift @@ -18,37 +18,55 @@ struct ContentView: View { var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(spacing: 26) { - ModeSelector( - selection: $coordinator.selectedMode, - isEnabled: coordinator.sessionState.isIdle - ) + ScrollViewReader { scrollProxy in + ScrollView { + VStack(spacing: 26) { + ModeSelector( + selection: $coordinator.selectedMode, + isEnabled: coordinator.sessionState.isIdle + ) + .id("mode-selector") - ModeContentView() + ModeContentView { + withAnimation(.easeOut(duration: 0.2)) { + scrollProxy.scrollTo("mode-selector", anchor: .top) + } + } - if let warning = coordinator.warningMessage { - MessageBanner( - message: warning, - systemImage: "exclamationmark.triangle.fill", - tint: AppTheme.warning, - dismiss: coordinator.clearWarning - ) - .transition(.move(edge: .bottom).combined(with: .opacity)) + if let warning = coordinator.warningMessage { + MessageBanner( + message: warning, + systemImage: "exclamationmark.triangle.fill", + tint: AppTheme.warning, + dismiss: coordinator.clearWarning + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } + .frame(maxWidth: AppTheme.contentWidth) + .padding(.horizontal, AppTheme.outerPadding) + .padding(.top, 24) + .padding(.bottom, 24) + .frame(maxWidth: .infinity) } - .frame(maxWidth: AppTheme.contentWidth) - .padding(.horizontal, AppTheme.outerPadding) - .padding(.top, 24) - .padding(.bottom, 24) - .frame(maxWidth: .infinity) + .scrollIndicators(.never) } - .scrollIndicators(.never) SafetyExitFooter() } .animation(.easeInOut(duration: 0.2), value: coordinator.selectedMode) .animation(.easeInOut(duration: 0.2), value: coordinator.warningMessage) + .onChange(of: coordinator.sessionState) { _, state in + guard state == .active, + coordinator.selectedMode.hidesApplicationOnActivation + else { + return + } + + // The app must keep running to enforce the lock, so hide it instead + // of terminating it. The menu-bar item remains available. + NSApp.hide(nil) + } .alert("CleanMyScreen couldn’t start", isPresented: isPresentingError) { if let destination = coordinator.permissionSettingsDestination { Button(settingsButtonTitle(for: destination)) { diff --git a/Sources/CleanMyScreen/ModeContentView.swift b/Sources/CleanMyScreen/ModeContentView.swift index e600ce8..10cadb7 100644 --- a/Sources/CleanMyScreen/ModeContentView.swift +++ b/Sources/CleanMyScreen/ModeContentView.swift @@ -4,6 +4,11 @@ import SwiftUI struct ModeContentView: View { @EnvironmentObject private var coordinator: LockSessionCoordinator + let returnToModes: () -> Void + + init(returnToModes: @escaping () -> Void = {}) { + self.returnToModes = returnToModes + } var body: some View { VStack(spacing: 22) { @@ -19,6 +24,20 @@ struct ModeContentView: View { .opacity(coordinator.sessionState.isIdle ? 1 : 0.62) SessionActionView() + + if coordinator.selectedMode == .selective { + HStack { + Spacer() + Button(action: returnToModes) { + Label("Back to modes", systemImage: "arrow.up") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to mode selector") + } + .padding(.top, -8) + } } .frame(maxWidth: .infinity) } diff --git a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift index 7deb120..4708898 100644 --- a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift +++ b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift @@ -84,6 +84,14 @@ public final class LockSessionCoordinator: ObservableObject { } clearMessages() + + // Ask before the countdown so a missing permission never makes the + // user wait through an animation that cannot complete successfully. + guard inputBlocker.requestMonitoringAccess() else { + present(error: CleanMyScreenError.inputMonitoringRequired) + return + } + countdownTask?.cancel() countdownTask = Task { [weak self] in guard let self else { return } @@ -160,9 +168,6 @@ public final class LockSessionCoordinator: ObservableObject { } if !eventMask.isEmpty { - guard inputBlocker.requestMonitoringAccess() else { - throw CleanMyScreenError.inputMonitoringRequired - } try inputBlocker.start(blocking: eventMask) { [weak self] in Task { @MainActor in self?.stop() diff --git a/Sources/CleanMyScreenKit/Models/LockMode.swift b/Sources/CleanMyScreenKit/Models/LockMode.swift index fd0e953..3a027d9 100644 --- a/Sources/CleanMyScreenKit/Models/LockMode.swift +++ b/Sources/CleanMyScreenKit/Models/LockMode.swift @@ -22,4 +22,11 @@ public enum LockMode: String, CaseIterable, Identifiable, Sendable { case .selective: "lock.square" } } + + /// Viewing modes should hand the foreground back to the content the user + /// wants to watch. Cleaning keeps its own window because the display is + /// immediately covered by the cleaning overlay. + public var hidesApplicationOnActivation: Bool { + self != .cleaning + } } diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 2aea066..dd9a461 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.1 + 0.1.2 CFBundleVersion - 2 + 3 LSMinimumSystemVersion 14.0 NSHighResolutionCapable diff --git a/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift new file mode 100644 index 0000000..75abc56 --- /dev/null +++ b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CleanMyScreenKit + +@MainActor +@Test("Input Monitoring is checked before the countdown starts") +func permissionIsCheckedBeforeCountdown() { + let input = TestInputBlocker(hasMonitoringAccess: false) + let coordinator = LockSessionCoordinator( + inputBlocker: input, + hidBlocker: TestHIDBlocker(), + overlays: TestOverlayController(), + brightness: TestBrightnessController() + ) + + coordinator.startSelectedMode() + + #expect(input.monitoringRequestCount == 1) + #expect(coordinator.sessionState == .idle) + #expect(coordinator.permissionSettingsDestination == .inputMonitoring) +} + +@MainActor +@Test("Granted Input Monitoring begins the countdown") +func permissionAllowsCountdown() async { + let input = TestInputBlocker(hasMonitoringAccess: true) + let coordinator = LockSessionCoordinator( + inputBlocker: input, + hidBlocker: TestHIDBlocker(), + overlays: TestOverlayController(), + brightness: TestBrightnessController() + ) + + coordinator.startSelectedMode() + await Task.yield() + + #expect(input.monitoringRequestCount == 1) + #expect(coordinator.sessionState == .countingDown(3)) + coordinator.cancelCountdown() +} + +private final class TestInputBlocker: InputBlocking, @unchecked Sendable { + let hasMonitoringAccess: Bool + private(set) var monitoringRequestCount = 0 + var isRunning = false + + init(hasMonitoringAccess: Bool) { + self.hasMonitoringAccess = hasMonitoringAccess + } + + func requestMonitoringAccess() -> Bool { + monitoringRequestCount += 1 + return hasMonitoringAccess + } + + func start( + blocking mask: InputBlockMask, + onEmergencyUnlock: @escaping @Sendable () -> Void + ) throws { + isRunning = true + } + + func stop() { + isRunning = false + } +} + +private final class TestHIDBlocker: HIDDeviceBlocking, @unchecked Sendable { + var blockedDeviceNames: [String] = [] + var failedDeviceNames: [String] = [] + + func blockBuiltInTrackpads() throws -> Int { 1 } + func blockExternalInputDevices() throws -> Int { 1 } + func stop() {} +} + +@MainActor +private final class TestOverlayController: OverlayControlling { + var isShowingCleaningOverlay = false + + func showCleaningOverlay(onAllDisplays: Bool, unlockHint: String) { + isShowingCleaningOverlay = true + } + + func showTransientHUD(title: String, detail: String) {} + + func hideAll() { + isShowingCleaningOverlay = false + } +} + +@MainActor +private final class TestBrightnessController: BrightnessControlling { + var supportedDisplayCount = 1 + func maximizeSupportedDisplays() {} + func restore() {} +} diff --git a/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift b/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift index 0c51dfe..15b9bf8 100644 --- a/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift +++ b/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift @@ -4,6 +4,9 @@ import Testing @Test("All three modes remain first-class and free") func exposesThreeModes() { #expect(LockMode.allCases == [.cleaning, .petKid, .selective]) + #expect(!LockMode.cleaning.hidesApplicationOnActivation) + #expect(LockMode.petKid.hidesApplicationOnActivation) + #expect(LockMode.selective.hidesApplicationOnActivation) } @Test("Cleaning defaults match the selected prototype")