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")