From fc7d4cec8da04d78eafa3c3139c9f173f3c42319 Mon Sep 17 00:00:00 2001 From: WesLin Date: Tue, 11 Aug 2026 13:19:09 +0800 Subject: [PATCH] Show Pet mode preparation countdown --- Sources/CleanMyScreen/ContentView.swift | 10 ++- .../LockSessionCoordinator.swift | 4 ++ .../Services/DisplayOverlayController.swift | 65 +++++++++++++++---- .../Services/ServiceProtocols.swift | 1 + SupportingFiles/Info.plist | 4 +- .../CoordinatorFlowTests.swift | 17 ++++- 6 files changed, 86 insertions(+), 15 deletions(-) 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 } }