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")