diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh
index 017ce69..f590f42 100755
--- a/Scripts/build-app.sh
+++ b/Scripts/build-app.sh
@@ -43,11 +43,10 @@ if [[ -d "$APP_ROOT" ]]; then
fi
mkdir -p "$OUTPUT_ROOT"
ditto --noextattr --noqtn "$STAGED_APP" "$APP_ROOT"
+# Strip metadata in one pass immediately before verification. Removing
+# File Provider attributes individually can cause FinderInfo to be re-applied
+# between commands on synced workspace folders.
xattr -cr "$APP_ROOT"
-# File Provider folders can immediately reapply bundle-level metadata after a
-# copy. Remove those two attributes explicitly before the final verification.
-xattr -d com.apple.FinderInfo "$APP_ROOT" 2>/dev/null || true
-xattr -d 'com.apple.fileprovider.fpfs#P' "$APP_ROOT" 2>/dev/null || true
codesign --verify --deep --strict "$APP_ROOT"
echo "$APP_ROOT"
diff --git a/Sources/CleanMyScreen/ContentView.swift b/Sources/CleanMyScreen/ContentView.swift
index a5cc735..40b2558 100644
--- a/Sources/CleanMyScreen/ContentView.swift
+++ b/Sources/CleanMyScreen/ContentView.swift
@@ -57,12 +57,17 @@ 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
- guard state == .active,
- coordinator.selectedMode.hidesApplicationOnActivation
- else {
- return
+ let shouldHide = switch state {
+ case .idle:
+ false
+ case .countingDown:
+ coordinator.selectedMode.hidesApplicationWhenCountdownBegins
+ case .active:
+ coordinator.selectedMode.hidesApplicationOnActivation
}
+ guard shouldHide 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)
diff --git a/Sources/CleanMyScreen/ModeContentView.swift b/Sources/CleanMyScreen/ModeContentView.swift
index 10cadb7..0508d7f 100644
--- a/Sources/CleanMyScreen/ModeContentView.swift
+++ b/Sources/CleanMyScreen/ModeContentView.swift
@@ -11,7 +11,7 @@ struct ModeContentView: View {
}
var body: some View {
- VStack(spacing: 22) {
+ VStack(spacing: coordinator.selectedMode == .petKid ? 12 : 22) {
ModeHero(
mode: coordinator.selectedMode,
statusTitle: coordinator.statusTitle,
@@ -23,6 +23,10 @@ struct ModeContentView: View {
.disabled(!coordinator.sessionState.isIdle)
.opacity(coordinator.sessionState.isIdle ? 1 : 0.62)
+ if coordinator.selectedMode == .petKid {
+ PetVideoPreparationHint()
+ }
+
SessionActionView()
if coordinator.selectedMode == .selective {
@@ -144,6 +148,31 @@ struct ModeContentView: View {
}
}
+private struct PetVideoPreparationHint: View {
+ var body: some View {
+ HStack(alignment: .center, spacing: 9) {
+ Image(systemName: "play.rectangle.fill")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(AppTheme.accent)
+ .frame(width: 21)
+
+ Text("After Start, this window hides. You’ll have 5 seconds to open or full-screen your video.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .frame(maxWidth: 520, alignment: .leading)
+ .background(AppTheme.accent.opacity(0.055), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 11, style: .continuous)
+ .stroke(AppTheme.accent.opacity(0.18), lineWidth: 1)
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
+
private struct ModeHero: View {
let mode: LockMode
let statusTitle: String
diff --git a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
index 4708898..126aee8 100644
--- a/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
+++ b/Sources/CleanMyScreenKit/LockSessionCoordinator.swift
@@ -93,9 +93,10 @@ public final class LockSessionCoordinator: ObservableObject {
}
countdownTask?.cancel()
+ let countdownSeconds = selectedMode.countdownSeconds
countdownTask = Task { [weak self] in
guard let self else { return }
- for seconds in stride(from: 3, through: 1, by: -1) {
+ for seconds in stride(from: countdownSeconds, through: 1, by: -1) {
guard !Task.isCancelled else { return }
self.sessionState = .countingDown(seconds)
try? await Task.sleep(for: .seconds(1))
diff --git a/Sources/CleanMyScreenKit/Models/LockMode.swift b/Sources/CleanMyScreenKit/Models/LockMode.swift
index 3a027d9..05e3884 100644
--- a/Sources/CleanMyScreenKit/Models/LockMode.swift
+++ b/Sources/CleanMyScreenKit/Models/LockMode.swift
@@ -29,4 +29,15 @@ public enum LockMode: String, CaseIterable, Identifiable, Sendable {
public var hidesApplicationOnActivation: Bool {
self != .cleaning
}
+
+ public var hidesApplicationWhenCountdownBegins: Bool {
+ self == .petKid
+ }
+
+ public var countdownSeconds: Int {
+ switch self {
+ case .petKid: 5
+ case .cleaning, .selective: 3
+ }
+ }
}
diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist
index dd9a461..384e701 100644
--- a/SupportingFiles/Info.plist
+++ b/SupportingFiles/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.2
+ 0.1.3
CFBundleVersion
- 3
+ 4
LSMinimumSystemVersion
14.0
NSHighResolutionCapable
diff --git a/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
index 75abc56..1e5d5cf 100644
--- a/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
+++ b/Tests/CleanMyScreenKitTests/CoordinatorFlowTests.swift
@@ -39,6 +39,25 @@ func permissionAllowsCountdown() async {
coordinator.cancelCountdown()
}
+@MainActor
+@Test("Pet and Kid mode provides a five-second preparation countdown")
+func petKidPreparationCountdown() async {
+ let input = TestInputBlocker(hasMonitoringAccess: true)
+ let coordinator = LockSessionCoordinator(
+ inputBlocker: input,
+ hidBlocker: TestHIDBlocker(),
+ overlays: TestOverlayController(),
+ brightness: TestBrightnessController()
+ )
+ coordinator.selectedMode = .petKid
+
+ coordinator.startSelectedMode()
+ await Task.yield()
+
+ #expect(coordinator.sessionState == .countingDown(5))
+ coordinator.cancelCountdown()
+}
+
private final class TestInputBlocker: InputBlocking, @unchecked Sendable {
let hasMonitoringAccess: Bool
private(set) var monitoringRequestCount = 0
diff --git a/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift b/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift
index 15b9bf8..c598201 100644
--- a/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift
+++ b/Tests/CleanMyScreenKitTests/LockConfigurationTests.swift
@@ -7,6 +7,12 @@ func exposesThreeModes() {
#expect(!LockMode.cleaning.hidesApplicationOnActivation)
#expect(LockMode.petKid.hidesApplicationOnActivation)
#expect(LockMode.selective.hidesApplicationOnActivation)
+ #expect(!LockMode.cleaning.hidesApplicationWhenCountdownBegins)
+ #expect(LockMode.petKid.hidesApplicationWhenCountdownBegins)
+ #expect(!LockMode.selective.hidesApplicationWhenCountdownBegins)
+ #expect(LockMode.cleaning.countdownSeconds == 3)
+ #expect(LockMode.petKid.countdownSeconds == 5)
+ #expect(LockMode.selective.countdownSeconds == 3)
}
@Test("Cleaning defaults match the selected prototype")