diff --git a/Sources/CleanMyScreen/ContentView.swift b/Sources/CleanMyScreen/ContentView.swift
index 40b2558..36cf6b9 100644
--- a/Sources/CleanMyScreen/ContentView.swift
+++ b/Sources/CleanMyScreen/ContentView.swift
@@ -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
diff --git a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
index 126aee8..c5c744e 100644
--- a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
+++ b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
@@ -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))
}
@@ -110,6 +113,7 @@ public final class LockSessionCoordinator: ObservableObject {
guard case .countingDown = sessionState else { return }
countdownTask?.cancel()
countdownTask = nil
+ overlays.hideAll()
sessionState = .idle
}
diff --git a/Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift b/Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift
index 949efc1..360da8b 100644
--- a/Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift
+++ b/Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift
@@ -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?
@@ -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 }
@@ -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()
@@ -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() {
@@ -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
@@ -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
@@ -296,6 +335,8 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
transientHUDWindow?.orderOut(nil)
transientHUDWindow?.close()
transientHUDWindow = nil
+ transientHUDTitleLabel = nil
+ transientHUDDetailLabel = nil
}
private func closeCleaningWindows() {
@@ -315,5 +356,7 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
transientHUDWindow?.orderOut(nil)
transientHUDWindow?.close()
transientHUDWindow = nil
+ transientHUDTitleLabel = nil
+ transientHUDDetailLabel = nil
}
}
diff --git a/Sources/CleanMyScreenKit/Services/ServiceProtocols.swift b/Sources/CleanMyScreenKit/Services/ServiceProtocols.swift
index 9d83974..f2f92ba 100644
--- a/Sources/CleanMyScreenKit/Services/ServiceProtocols.swift
+++ b/Sources/CleanMyScreenKit/Services/ServiceProtocols.swift
@@ -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()
}
diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist
index 384e701..cb012cc 100644
--- a/SupportingFiles/Info.plist
+++ b/SupportingFiles/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.3
+ 0.1.4
CFBundleVersion
- 4
+ 5
LSMinimumSystemVersion
14.0
NSHighResolutionCapable
diff --git a/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
index 1e5d5cf..2e0cb18 100644
--- a/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
+++ b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
@@ -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 {
@@ -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
}
}