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
10 changes: 9 additions & 1 deletion Sources/CleanMyScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ struct ContentView: View {
}
.animation(.easeInOut(duration: 0.2), value: coordinator.selectedMode)
.animation(.easeInOut(duration: 0.2), value: coordinator.warningMessage)
.onChange(of: coordinator.sessionState) { _, state in
.onChange(of: coordinator.sessionState) { previousState, state in
if case .countingDown = previousState,
state == .idle,
coordinator.selectedMode.hidesApplicationWhenCountdownBegins {
NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
return
}

let shouldHide = switch state {
case .idle:
false
Expand Down
4 changes: 4 additions & 0 deletions Sources/CleanMyScreenKit/LockSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ public final class LockSessionCoordinator: ObservableObject {
guard let self else { return }
for seconds in stride(from: countdownSeconds, through: 1, by: -1) {
guard !Task.isCancelled else { return }
if self.selectedMode == .petKid {
self.overlays.showPreparationHUD(secondsRemaining: seconds)
}
self.sessionState = .countingDown(seconds)
try? await Task.sleep(for: .seconds(1))
}
Expand All @@ -110,6 +113,7 @@ public final class LockSessionCoordinator: ObservableObject {
guard case .countingDown = sessionState else { return }
countdownTask?.cancel()
countdownTask = nil
overlays.hideAll()
sessionState = .idle
}

Expand Down
65 changes: 54 additions & 11 deletions Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
private var cleaningWindows: [NSWindow] = []
private var hintViews: [NSView] = []
private var transientHUDWindow: NSWindow?
private var transientHUDTitleLabel: NSTextField?
private var transientHUDDetailLabel: NSTextField?
private var hintFadeTimer: Timer?
private var hudDismissTimer: Timer?
private var hudCloseTimer: Timer?
Expand Down Expand Up @@ -49,6 +51,33 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
}

public func showTransientHUD(title: String, detail: String) {
presentHUD(title: title, detail: detail, dismissAfter: 3.5)
}

public func showPreparationHUD(secondsRemaining: Int) {
let unit = secondsRemaining == 1 ? "second" : "seconds"
presentHUD(
title: "Open or full-screen your video",
detail: "Input locks in \(secondsRemaining) \(unit)",
dismissAfter: nil
)
}

private func presentHUD(title: String, detail: String, dismissAfter: TimeInterval?) {
hudDismissTimer?.invalidate()
hudDismissTimer = nil
hudCloseTimer?.invalidate()
hudCloseTimer = nil

if transientHUDWindow != nil,
let titleLabel = transientHUDTitleLabel,
let detailLabel = transientHUDDetailLabel {
titleLabel.stringValue = title
detailLabel.stringValue = detail
scheduleHUDDismissal(after: dismissAfter)
return
}

hideAll()

guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
Expand Down Expand Up @@ -78,8 +107,8 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
.ignoresCycle,
]

let contentView = makeHUDContentView(title: title, detail: detail)
panel.contentView = contentView
let content = makeHUDContentView(title: title, detail: detail)
panel.contentView = content.view
panel.alphaValue = 0
panel.orderFrontRegardless()

Expand All @@ -89,13 +118,9 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
}

transientHUDWindow = panel
hudDismissTimer = Timer.scheduledTimer(
timeInterval: 3.5,
target: self,
selector: #selector(beginHUDDismissal(_:)),
userInfo: nil,
repeats: false
)
transientHUDTitleLabel = content.titleLabel
transientHUDDetailLabel = content.detailLabel
scheduleHUDDismissal(after: dismissAfter)
}

public func hideAll() {
Expand Down Expand Up @@ -223,7 +248,10 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
return container
}

private func makeHUDContentView(title: String, detail: String) -> NSView {
private func makeHUDContentView(
title: String,
detail: String
) -> (view: NSView, titleLabel: NSTextField, detailLabel: NSTextField) {
let container = NSVisualEffectView()
container.material = .hudWindow
container.blendingMode = .behindWindow
Expand Down Expand Up @@ -256,7 +284,18 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
])
return container
return (container, titleLabel, detailLabel)
}

private func scheduleHUDDismissal(after delay: TimeInterval?) {
guard let delay else { return }
hudDismissTimer = Timer.scheduledTimer(
timeInterval: delay,
target: self,
selector: #selector(beginHUDDismissal(_:)),
userInfo: nil,
repeats: false
)
}

@objc
Expand Down Expand Up @@ -296,6 +335,8 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
transientHUDWindow?.orderOut(nil)
transientHUDWindow?.close()
transientHUDWindow = nil
transientHUDTitleLabel = nil
transientHUDDetailLabel = nil
}

private func closeCleaningWindows() {
Expand All @@ -315,5 +356,7 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
transientHUDWindow?.orderOut(nil)
transientHUDWindow?.close()
transientHUDWindow = nil
transientHUDTitleLabel = nil
transientHUDDetailLabel = nil
}
}
1 change: 1 addition & 0 deletions Sources/CleanMyScreenKit/Services/ServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public protocol HIDDeviceBlocking: AnyObject {
public protocol OverlayControlling: AnyObject {
var isShowingCleaningOverlay: Bool { get }
func showCleaningOverlay(onAllDisplays: Bool, unlockHint: String)
func showPreparationHUD(secondsRemaining: Int)
func showTransientHUD(title: String, detail: String)
func hideAll()
}
Expand Down
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.3</string>
<string>0.1.4</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>5</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
17 changes: 16 additions & 1 deletion Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,25 @@ func permissionAllowsCountdown() async {
@Test("Pet and Kid mode provides a five-second preparation countdown")
func petKidPreparationCountdown() async {
let input = TestInputBlocker(hasMonitoringAccess: true)
let overlays = TestOverlayController()
let coordinator = LockSessionCoordinator(
inputBlocker: input,
hidBlocker: TestHIDBlocker(),
overlays: TestOverlayController(),
overlays: overlays,
brightness: TestBrightnessController()
)
coordinator.selectedMode = .petKid
overlays.onShowPreparation = { _ in
#expect(coordinator.sessionState == .idle)
}

coordinator.startSelectedMode()
await Task.yield()

#expect(coordinator.sessionState == .countingDown(5))
#expect(overlays.preparationHUDSeconds == [5])
coordinator.cancelCountdown()
#expect(overlays.hideAllCount == 1)
}

private final class TestInputBlocker: InputBlocking, @unchecked Sendable {
Expand Down Expand Up @@ -96,14 +102,23 @@ private final class TestHIDBlocker: HIDDeviceBlocking, @unchecked Sendable {
@MainActor
private final class TestOverlayController: OverlayControlling {
var isShowingCleaningOverlay = false
var preparationHUDSeconds: [Int] = []
var hideAllCount = 0
var onShowPreparation: ((Int) -> Void)?

func showCleaningOverlay(onAllDisplays: Bool, unlockHint: String) {
isShowingCleaningOverlay = true
}

func showPreparationHUD(secondsRemaining: Int) {
preparationHUDSeconds.append(secondsRemaining)
onShowPreparation?(secondsRemaining)
}

func showTransientHUD(title: String, detail: String) {}

func hideAll() {
hideAllCount += 1
isShowingCleaningOverlay = false
}
}
Expand Down
Loading