Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions Design/audit-0.1.1/notes.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 39 additions & 21 deletions Sources/CleanMyScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
19 changes: 19 additions & 0 deletions Sources/CleanMyScreen/ModeContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
11 changes: 8 additions & 3 deletions Sources/CleanMyScreenKit/LockSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/CleanMyScreenKit/Models/LockMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
4 changes: 2 additions & 2 deletions SupportingFiles/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.1</string>
<string>0.1.2</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
97 changes: 97 additions & 0 deletions Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
3 changes: 3 additions & 0 deletions Tests/CleanMyScreenKitTests/LockConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading