From 886a6e1af7fe2f4f95afbf7fabe5bce18d5643fa Mon Sep 17 00:00:00 2001 From: Amr Shawqy Date: Thu, 23 Jul 2026 22:32:53 +0300 Subject: [PATCH 1/2] Fix global Escape recording cancellation --- README.md | 12 +- Speaky/AppState.swift | 48 +++-- Speaky/Protocols/SoundEffecting.swift | 1 + Speaky/Services/HotkeyManager.swift | 203 ++++++++++++++---- Speaky/Services/SoundEffectService.swift | 34 ++- .../Services/TranscriptionCoordinator.swift | 36 +++- Speaky/Utilities/Constants.swift | 1 - Speaky/Views/MainWindow/SettingsView.swift | 2 +- .../Onboarding/OnboardingContainerView.swift | 2 +- Speaky/Views/Overlay/NotchOverlayView.swift | 14 -- SpeakyTests/HotkeyManagerTests.swift | 56 +++++ SpeakyTests/Mocks/MockServices.swift | 23 ++ .../TranscriptionCoordinatorTests.swift | 79 ++++++- 13 files changed, 408 insertions(+), 103 deletions(-) create mode 100644 SpeakyTests/HotkeyManagerTests.swift diff --git a/README.md b/README.md index 4e32282..9588804 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ xcodebuild -project Speaky.xcodeproj -scheme Speaky -configuration Release build ./build.sh separate # Both architectures + DMGs ``` +### Manual global Escape verification + +macOS does not allow automated tests to grant Accessibility permission, so verify global cancellation manually before release: + +1. Grant Accessibility permission to the exact Speaky build under **System Settings → Privacy & Security → Accessibility**. +2. Start recording, focus another application, and press Escape once. +3. Confirm recording is cancelled and the focused application does not also receive Escape. +4. Confirm Escape behaves normally when Speaky is not recording. +5. Hold the recording shortcut, press Escape, then release the shortcut and confirm recording does not restart. + ## Architecture ``` @@ -159,7 +169,7 @@ Speaky/ - macOS 15.0+ (Sequoia) - Microphone permission -- Accessibility permission (for auto-paste) +- Accessibility permission (for auto-paste and global Escape cancellation) ## License diff --git a/Speaky/AppState.swift b/Speaky/AppState.swift index 9f2b9fc..c1c80a6 100644 --- a/Speaky/AppState.swift +++ b/Speaky/AppState.swift @@ -18,16 +18,20 @@ private let appStateLogger = Logger.speaky(category: "AppState") @Observable @MainActor final class AppState { - var state: RecordingState = .idle + var state: RecordingState = .idle { + didSet { + hotkeyManager.setEscapeCancellationEnabled(state == .recording) + } + } var lastTranscription: String? var audioLevels: [Float] = Array(repeating: 0, count: 30) var recordingStartTime: Date? - var showingCancelWarning = false var showingCelebration = false var permissionWarning: String? var pasteWarning: String? - private var cancelWarningDismissTask: Task? private var pasteWarningDismissTask: Task? + private var escapeMonitoringUnavailable = false + private var recordingGeneration: UInt = 0 let settings = AppSettings() let hotkeyManager = HotkeyManager() @@ -52,6 +56,11 @@ final class AppState { hotkeyManager.onEscapePressed = { [weak self] in self?.handleEscapePressed() } + hotkeyManager.onEscapeMonitoringAvailabilityChanged = { [weak self] isAvailable in + guard let self else { return } + escapeMonitoringUnavailable = !isAvailable + refreshPermissionWarning() + } coordinator.deviceGuard.onDeviceLost = { [weak self] in guard let self else { return } if self.isRecording { @@ -64,6 +73,10 @@ final class AppState { /// Check permissions on launch and surface a warning banner if any are revoked. func checkPermissionsOnLaunch() { + refreshPermissionWarning() + } + + private func refreshPermissionWarning() { let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let accessibilityGranted = AXIsProcessTrusted() @@ -72,7 +85,9 @@ final class AppState { } else if micStatus == .denied { permissionWarning = "Microphone access was revoked. Go to Settings > Permissions to restore it." } else if !accessibilityGranted { - permissionWarning = "Accessibility access is missing — auto-paste won't work. Go to Settings > Permissions to enable it." + permissionWarning = "Accessibility access is missing — auto-paste and global Escape cancellation won't work. Go to Settings > Permissions to enable it." + } else if escapeMonitoringUnavailable { + permissionWarning = "Global Escape cancellation is unavailable. Re-enable Accessibility for Speaky in Settings > Permissions, then try again." } else { permissionWarning = nil } @@ -135,8 +150,9 @@ final class AppState { } state = .recording + recordingGeneration &+= 1 + let generation = recordingGeneration recordingStartTime = Date() - showingCancelWarning = false showingCelebration = false audioLevels = Array(repeating: 0, count: 30) @@ -144,8 +160,9 @@ final class AppState { // Play start sound (if enabled), then apply system-level mute after it finishes. // Media is already paused above, so background audio is silent during the sound. - Task { - await coordinator.playStartSoundAndMute() + coordinator.startRecordingFeedback { [weak self] in + guard let self else { return false } + return isRecording && recordingGeneration == generation } } catch { @@ -304,25 +321,10 @@ final class AppState { func handleEscapePressed() { guard isRecording else { return } - - if showingCancelWarning { - // Second ESC → cancel recording - cancelRecording() - } else { - // First ESC → show warning - showingCancelWarning = true - cancelWarningDismissTask?.cancel() - cancelWarningDismissTask = Task { - try? await Task.sleep(for: .seconds(Constants.Timing.cancelWarningDuration)) - guard !Task.isCancelled else { return } - self.showingCancelWarning = false - } - } + cancelRecording() } func cancelRecording() { - showingCancelWarning = false - cancelWarningDismissTask?.cancel() coordinator.cancelRecording() state = .idle audioLevels = Array(repeating: 0, count: 30) diff --git a/Speaky/Protocols/SoundEffecting.swift b/Speaky/Protocols/SoundEffecting.swift index 9475076..43cc7b6 100644 --- a/Speaky/Protocols/SoundEffecting.swift +++ b/Speaky/Protocols/SoundEffecting.swift @@ -4,6 +4,7 @@ import Foundation @MainActor protocol SoundEffecting: AnyObject { func playStartAndWait() async + func stopStart() func playEnd() } diff --git a/Speaky/Services/HotkeyManager.swift b/Speaky/Services/HotkeyManager.swift index 94dea53..5a54d8a 100644 --- a/Speaky/Services/HotkeyManager.swift +++ b/Speaky/Services/HotkeyManager.swift @@ -4,6 +4,41 @@ import Carbon import AppKit import os +private func escapeEventTapCallback( + _: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let refcon else { + return Unmanaged.passUnretained(event) + } + + let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + Task { @MainActor in + manager.restoreEscapeTapIfNeeded() + } + return Unmanaged.passUnretained(event) + } + + guard HotkeyManager.shouldInterceptEscape( + eventType: type, + keyCode: event.getIntegerValueField(.keyboardEventKeycode), + isEnabled: true + ) else { + return Unmanaged.passUnretained(event) + } + + Task { @MainActor in + manager.handleMonitoredEscape() + } + + // While recording, ESC belongs exclusively to Speaky. + return nil +} + extension KeyboardShortcuts.Name { nonisolated(unsafe) static let toggleRecording = Self("toggleRecording", default: .init(.space, modifiers: .option)) } @@ -12,6 +47,7 @@ extension KeyboardShortcuts.Name { @MainActor final class HotkeyManager: @unchecked Sendable { private let logger = Logger.speaky(category: "HotkeyManager") + private let usesSystemMonitoring: Bool enum HotkeyOption: String, CaseIterable, Identifiable { case rightOption = "rightOption" @@ -59,15 +95,18 @@ final class HotkeyManager: @unchecked Sendable { // Callbacks var onToggleRecording: (() -> Void)? var onEscapePressed: (() -> Void)? + var onEscapeMonitoringAvailabilityChanged: ((Bool) -> Void)? // NSEvent monitors — nonisolated(unsafe) so deinit can clean them up private nonisolated(unsafe) var globalEventMonitor: Any? private nonisolated(unsafe) var localEventMonitor: Any? + private nonisolated(unsafe) var globalEscapeMonitor: Any? private nonisolated(unsafe) var localEscapeMonitor: Any? - // CGEvent tap for global ESC (more reliable than NSEvent global monitor) + // Active only while recording so ESC remains untouched at all other times. private nonisolated(unsafe) var escapeTapPort: CFMachPort? private nonisolated(unsafe) var escapeTapSource: CFRunLoopSource? + private(set) var isEscapeCancellationEnabled = false // Push-to-talk / hands-free state private var currentKeyState = false @@ -81,6 +120,8 @@ final class HotkeyManager: @unchecked Sendable { private var shortcutCurrentKeyState = false private var lastShortcutTriggerTime: Date? private let shortcutCooldownInterval: TimeInterval = 0.3 + private var suppressModifierUntilRelease = false + private var suppressShortcutUntilRelease = false // Fn key debounce — nonisolated(unsafe) so deinit can cancel it private nonisolated(unsafe) var fnDebounceTask: Task? @@ -90,11 +131,15 @@ final class HotkeyManager: @unchecked Sendable { // State exposed to UI private(set) var isRecordingViaHotkey = false - init() { + init(startMonitoring: Bool = true) { + self.usesSystemMonitoring = startMonitoring + // Always use custom shortcut mode (modifier presets removed) self.selectedHotkey = .custom UserDefaults.standard.set(HotkeyOption.custom.rawValue, forKey: "selectedHotkey") + guard startMonitoring else { return } + // Slight delay to ensure app is fully launched Task { @MainActor in try? await Task.sleep(nanoseconds: 200_000_000) @@ -117,64 +162,117 @@ final class HotkeyManager: @unchecked Sendable { } private func setupEscapeMonitoring() { - // CGEvent tap captures ESC globally even when other apps consume the key event. - // NSEvent.addGlobalMonitorForEvents is only an observer and misses consumed events. - let callback: CGEventTapCallBack = { _, type, event, refcon in - // Re-enable tap if it gets disabled by the system - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - return Unmanaged.passUnretained(event) - } + installEscapeTapIfAvailable() - guard type == .keyDown, - event.getIntegerValueField(.keyboardEventKeycode) == Int64(Constants.KeyCode.escape), - let refcon else { - return Unmanaged.passUnretained(event) - } - - let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - Task { @MainActor in - manager.onEscapePressed?() - } + if isEscapeCancellationEnabled && escapeTapPort == nil { + installGlobalEscapeFallback() + } - // Pass event through (don't consume it) - return Unmanaged.passUnretained(event) + // Local monitor for when Speaky itself is focused + localEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, + Self.shouldInterceptEscape( + eventType: .keyDown, + keyCode: Int64(event.keyCode), + isEnabled: self.isEscapeCancellationEnabled + ) else { return event } + self.handleMonitoredEscape() + return nil } + } + + private func installEscapeTapIfAvailable() { + guard escapeTapPort == nil else { return } let selfPtr = Unmanaged.passUnretained(self).toOpaque() if let tap = CGEvent.tapCreate( - tap: .cghidEventTap, + tap: .cgSessionEventTap, place: .headInsertEventTap, - options: .listenOnly, + options: .defaultTap, eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue), - callback: callback, + callback: escapeEventTapCallback, userInfo: selfPtr ) { escapeTapPort = tap let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) escapeTapSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - logger.info("CGEvent tap for ESC installed successfully") + CGEvent.tapEnable(tap: tap, enable: isEscapeCancellationEnabled) + logger.info("Active session event tap for ESC installed successfully") + onEscapeMonitoringAvailabilityChanged?(true) } else { - logger.warning("Failed to create CGEvent tap for ESC — falling back to NSEvent monitor") - // Fallback: NSEvent global monitor (less reliable but better than nothing) - let fallbackMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard event.keyCode == Constants.KeyCode.escape else { return } - Task { @MainActor in - self?.onEscapePressed?() - } - } - globalEventMonitor = globalEventMonitor ?? fallbackMonitor + logger.warning("Failed to create active ESC event tap — will retry when recording starts") } + } - // Local monitor for when Speaky itself is focused - localEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard event.keyCode == Constants.KeyCode.escape else { return event } + private func installGlobalEscapeFallback() { + guard globalEscapeMonitor == nil else { return } + logger.warning("Using passive ESC monitor because the active event tap is unavailable") + globalEscapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard event.keyCode == Constants.KeyCode.escape else { return } Task { @MainActor in - self?.onEscapePressed?() + self?.handleMonitoredEscape() } - return event } + onEscapeMonitoringAvailabilityChanged?(false) + } + + nonisolated static func shouldInterceptEscape( + eventType: CGEventType, + keyCode: Int64, + isEnabled: Bool + ) -> Bool { + isEnabled + && eventType == .keyDown + && keyCode == Int64(Constants.KeyCode.escape) + } + + func setEscapeCancellationEnabled(_ enabled: Bool) { + guard isEscapeCancellationEnabled != enabled else { return } + isEscapeCancellationEnabled = enabled + + if enabled && escapeTapPort == nil && usesSystemMonitoring { + // Accessibility may have been granted since launch. + installEscapeTapIfAvailable() + } + + if let tap = escapeTapPort { + CGEvent.tapEnable(tap: tap, enable: enabled) + } else if enabled && usesSystemMonitoring { + installGlobalEscapeFallback() + } + + if !enabled, let monitor = globalEscapeMonitor { + NSEvent.removeMonitor(monitor) + globalEscapeMonitor = nil + } + } + + func handleMonitoredEscape() { + guard isEscapeCancellationEnabled else { return } + setEscapeCancellationEnabled(false) + cancelActiveHotkeyInteraction() + onEscapePressed?() + } + + private func cancelActiveHotkeyInteraction() { + suppressModifierUntilRelease = currentKeyState || pendingFnKeyState == true + suppressShortcutUntilRelease = shortcutCurrentKeyState + fnDebounceTask?.cancel() + fnDebounceTask = nil + pendingFnKeyState = nil + pendingFnEventTime = nil + currentKeyState = false + keyPressEventTime = nil + isHandsFreeMode = false + shortcutCurrentKeyState = false + shortcutKeyPressEventTime = nil + isShortcutHandsFreeMode = false + } + + fileprivate func restoreEscapeTapIfNeeded() { + guard isEscapeCancellationEnabled, let tap = escapeTapPort else { return } + CGEvent.tapEnable(tap: tap, enable: true) } private func setupModifierKeyMonitoring() { @@ -253,6 +351,13 @@ final class HotkeyManager: @unchecked Sendable { /// - Short press (<0.4s): enters hands-free mode (tap to start, tap to stop) /// - Long press (≥0.4s): push-to-talk (hold to record, release to stop) private func processKeyPress(isKeyPressed: Bool, eventTime: TimeInterval) async { + if suppressModifierUntilRelease { + if !isKeyPressed { + suppressModifierUntilRelease = false + } + return + } + guard isKeyPressed != currentKeyState else { return } currentKeyState = isKeyPressed @@ -286,7 +391,9 @@ final class HotkeyManager: @unchecked Sendable { // MARK: - Custom shortcut handling - private func handleCustomShortcutKeyDown(eventTime: TimeInterval) async { + func handleCustomShortcutKeyDown(eventTime: TimeInterval) async { + guard !suppressShortcutUntilRelease else { return } + // Cooldown to prevent double-triggers if let lastTrigger = lastShortcutTriggerTime, Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval { @@ -307,7 +414,12 @@ final class HotkeyManager: @unchecked Sendable { triggerToggle() } - private func handleCustomShortcutKeyUp(eventTime: TimeInterval) async { + func handleCustomShortcutKeyUp(eventTime: TimeInterval) async { + if suppressShortcutUntilRelease { + suppressShortcutUntilRelease = false + return + } + guard shortcutCurrentKeyState else { return } shortcutCurrentKeyState = false @@ -340,6 +452,10 @@ final class HotkeyManager: @unchecked Sendable { NSEvent.removeMonitor(monitor) localEventMonitor = nil } + if let monitor = globalEscapeMonitor { + NSEvent.removeMonitor(monitor) + globalEscapeMonitor = nil + } if let monitor = localEscapeMonitor { NSEvent.removeMonitor(monitor) localEscapeMonitor = nil @@ -365,6 +481,8 @@ final class HotkeyManager: @unchecked Sendable { shortcutCurrentKeyState = false shortcutKeyPressEventTime = nil isShortcutHandsFreeMode = false + suppressModifierUntilRelease = false + suppressShortcutUntilRelease = false } var isShortcutConfigured: Bool { @@ -378,6 +496,7 @@ final class HotkeyManager: @unchecked Sendable { // Release system resources that would otherwise leak. if let monitor = globalEventMonitor { NSEvent.removeMonitor(monitor) } if let monitor = localEventMonitor { NSEvent.removeMonitor(monitor) } + if let monitor = globalEscapeMonitor { NSEvent.removeMonitor(monitor) } if let monitor = localEscapeMonitor { NSEvent.removeMonitor(monitor) } if let source = escapeTapSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) diff --git a/Speaky/Services/SoundEffectService.swift b/Speaky/Services/SoundEffectService.swift index 78111ef..e18d70f 100644 --- a/Speaky/Services/SoundEffectService.swift +++ b/Speaky/Services/SoundEffectService.swift @@ -13,18 +13,42 @@ final class SoundEffectService { Self.logger.warning("Sound file not found: start.m4a") return } + do { - startPlayer = try AVAudioPlayer(contentsOf: url) - startPlayer?.volume = 0.15 - startPlayer?.play() + let player = try AVAudioPlayer(contentsOf: url) + startPlayer?.stop() + startPlayer = player + player.volume = 0.15 + player.prepareToPlay() + + guard player.play() else { + Self.logger.warning("Failed to start playback: start.m4a") + startPlayer = nil + return + } + + defer { + if startPlayer === player { + startPlayer = nil + } + } + // Wait for the sound to finish so caller can mute after - let duration = startPlayer?.duration ?? 2.0 - try? await Task.sleep(for: .seconds(duration)) + do { + try await Task.sleep(for: .seconds(player.duration)) + } catch { + player.stop() + } } catch { Self.logger.warning("Failed to play start sound: \(error.localizedDescription, privacy: .public)") } } + func stopStart() { + startPlayer?.stop() + startPlayer = nil + } + func playEnd() { guard let url = Bundle.main.url(forResource: "end", withExtension: "m4a", subdirectory: "Sounds") else { Self.logger.warning("Sound file not found: end.m4a") diff --git a/Speaky/Services/TranscriptionCoordinator.swift b/Speaky/Services/TranscriptionCoordinator.swift index b22699f..cdcb910 100644 --- a/Speaky/Services/TranscriptionCoordinator.swift +++ b/Speaky/Services/TranscriptionCoordinator.swift @@ -23,6 +23,7 @@ final class TranscriptionCoordinator { private var currentEngineModelID: String? private var engineUnloadTask: Task? private var levelMonitor: AudioLevelMonitor? + private var startFeedbackTask: Task? private let settings: AppSettings @@ -198,6 +199,7 @@ final class TranscriptionCoordinator { private var backgroundLoadTask: Task? func startRecording(onLevelsUpdate: @escaping @Sendable ([Float]) -> Void) throws { + cancelRecordingFeedback() engineUnloadTask?.cancel() engineUnloadTask = nil @@ -247,18 +249,35 @@ final class TranscriptionCoordinator { } } - func playStartSoundAndMute() async { - if settings.soundEffectsEnabled { - await soundEffect.playStartAndWait() - } - // System-level mute — only in muteSystemAudio mode. - // Mutes system volume so media keeps playing visually but silently. - if settings.backgroundAudioMode == .muteSystemAudio { - audioControl.mute() + func startRecordingFeedback( + while isRecording: @escaping @MainActor @Sendable () -> Bool + ) { + cancelRecordingFeedback() + startFeedbackTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + if settings.soundEffectsEnabled { + await soundEffect.playStartAndWait() + } + + guard !Task.isCancelled, isRecording() else { return } + + // System-level mute — only after the start sound for the current recording. + if settings.backgroundAudioMode == .muteSystemAudio { + audioControl.mute() + } } } + func cancelRecordingFeedback() { + guard let task = startFeedbackTask else { return } + task.cancel() + startFeedbackTask = nil + soundEffect.stopStart() + } + func stopRecording() throws -> URL { + cancelRecordingFeedback() let url = try audioRecorder.stop() levelMonitor = nil if settings.backgroundAudioMode == .muteSystemAudio { @@ -273,6 +292,7 @@ final class TranscriptionCoordinator { } func cancelRecording() { + cancelRecordingFeedback() do { let audioURL = try audioRecorder.stop() try? FileManager.default.removeItem(at: audioURL) diff --git a/Speaky/Utilities/Constants.swift b/Speaky/Utilities/Constants.swift index 211b55b..3665697 100644 --- a/Speaky/Utilities/Constants.swift +++ b/Speaky/Utilities/Constants.swift @@ -24,7 +24,6 @@ enum Constants { static let pasteboardRestoreDelay: TimeInterval = 0.4 static let hotkeyBriefPressThreshold: TimeInterval = 0.4 static let permissionPollInterval: TimeInterval = 3.0 - static let cancelWarningDuration: TimeInterval = 2.0 static let transcriptionTimeout: TimeInterval = 120 } diff --git a/Speaky/Views/MainWindow/SettingsView.swift b/Speaky/Views/MainWindow/SettingsView.swift index cb21174..b2961bd 100644 --- a/Speaky/Views/MainWindow/SettingsView.swift +++ b/Speaky/Views/MainWindow/SettingsView.swift @@ -145,7 +145,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 4) { Text("Accessibility Access") .font(.body) - Text("Required for auto-paste.") + Text("Required for auto-paste and global Escape cancellation.") .font(.caption) .foregroundStyle(Theme.textSecondary) } diff --git a/Speaky/Views/Onboarding/OnboardingContainerView.swift b/Speaky/Views/Onboarding/OnboardingContainerView.swift index 91d351b..8a3241e 100644 --- a/Speaky/Views/Onboarding/OnboardingContainerView.swift +++ b/Speaky/Views/Onboarding/OnboardingContainerView.swift @@ -124,7 +124,7 @@ struct OnboardingPermissionsView: View { .font(.system(size: 28, weight: .bold)) .foregroundStyle(Theme.textPrimary) - Text("Speaky needs microphone access to record and accessibility access to paste text.") + Text("Speaky needs microphone access to record and Accessibility access for auto-paste and global Escape cancellation.") .font(.system(size: 15)) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) diff --git a/Speaky/Views/Overlay/NotchOverlayView.swift b/Speaky/Views/Overlay/NotchOverlayView.swift index 6fb0645..7706f8a 100644 --- a/Speaky/Views/Overlay/NotchOverlayView.swift +++ b/Speaky/Views/Overlay/NotchOverlayView.swift @@ -26,20 +26,6 @@ struct NotchRecordingView: View { } .padding(.horizontal, 16) .padding(.vertical, 6) - } else if appState.showingCancelWarning { - // Cancel warning - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 11)) - .foregroundStyle(.red) - - Text("Press ESC again to cancel") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.red) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .transition(.opacity) } else { // Recording — wide bar, Speaky left, all controls single row right HStack(spacing: 0) { diff --git a/SpeakyTests/HotkeyManagerTests.swift b/SpeakyTests/HotkeyManagerTests.swift new file mode 100644 index 0000000..64892ec --- /dev/null +++ b/SpeakyTests/HotkeyManagerTests.swift @@ -0,0 +1,56 @@ +import CoreGraphics +import Testing +@testable import Speaky + +@Suite("HotkeyManager") +@MainActor +struct HotkeyManagerTests { + @Test("ESC key-down is intercepted only while cancellation is enabled") + func escapeKeyDownRequiresActiveRecording() { + #expect(HotkeyManager.shouldInterceptEscape( + eventType: .keyDown, + keyCode: Int64(Constants.KeyCode.escape), + isEnabled: true + )) + #expect(!HotkeyManager.shouldInterceptEscape( + eventType: .keyDown, + keyCode: Int64(Constants.KeyCode.escape), + isEnabled: false + )) + } + + @Test("Other key events are never intercepted") + func otherKeyEventsPassThrough() { + #expect(!HotkeyManager.shouldInterceptEscape( + eventType: .keyUp, + keyCode: Int64(Constants.KeyCode.escape), + isEnabled: true + )) + #expect(!HotkeyManager.shouldInterceptEscape( + eventType: .keyDown, + keyCode: Int64(Constants.KeyCode.escape + 1), + isEnabled: true + )) + } + + @Test("Escape cancels once and suppresses a held shortcut release") + func escapeSuppressesHeldShortcutRelease() async { + let manager = HotkeyManager(startMonitoring: false) + var toggleCount = 0 + var escapeCount = 0 + manager.onToggleRecording = { toggleCount += 1 } + manager.onEscapePressed = { escapeCount += 1 } + + await manager.handleCustomShortcutKeyDown(eventTime: 0) + #expect(toggleCount == 1) + + manager.setEscapeCancellationEnabled(true) + manager.handleMonitoredEscape() + manager.handleMonitoredEscape() + await manager.handleCustomShortcutKeyUp(eventTime: 1) + + #expect(escapeCount == 1) + #expect(toggleCount == 1) + #expect(!manager.isEscapeCancellationEnabled) + } +} diff --git a/SpeakyTests/Mocks/MockServices.swift b/SpeakyTests/Mocks/MockServices.swift index f7a3a39..fb4c79d 100644 --- a/SpeakyTests/Mocks/MockServices.swift +++ b/SpeakyTests/Mocks/MockServices.swift @@ -66,10 +66,33 @@ final class MockDeviceGuard: DeviceGuarding, @unchecked Sendable { @MainActor final class MockSoundEffect: SoundEffecting { var playStartCalled = false + var playStartCallCount = 0 + var stopStartCallCount = 0 var playEndCalled = false + var waitsForStartCompletion = false + private var startContinuation: CheckedContinuation? func playStartAndWait() async { playStartCalled = true + playStartCallCount += 1 + guard waitsForStartCompletion else { return } + await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func stopStart() { + stopStartCallCount += 1 + resumeStartSound() + } + + func finishStartSound() { + resumeStartSound() + } + + private func resumeStartSound() { + startContinuation?.resume() + startContinuation = nil } func playEnd() { diff --git a/SpeakyTests/TranscriptionCoordinatorTests.swift b/SpeakyTests/TranscriptionCoordinatorTests.swift index fe1a573..1931e3c 100644 --- a/SpeakyTests/TranscriptionCoordinatorTests.swift +++ b/SpeakyTests/TranscriptionCoordinatorTests.swift @@ -2,9 +2,16 @@ import Testing import Foundation @testable import Speaky -@Suite("TranscriptionCoordinator") +@Suite("TranscriptionCoordinator", .serialized) @MainActor struct TranscriptionCoordinatorTests { + private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async -> Bool { + for _ in 0..<100 { + if condition() { return true } + await Task.yield() + } + return false + } private func makeCoordinator( audioRecorder: MockAudioRecorder = MockAudioRecorder(), @@ -162,7 +169,7 @@ struct TranscriptionCoordinatorTests { #expect(playback.resumeCount == 1) } - @Test("playStartSoundAndMute mutes in muteSystemAudio mode") + @Test("recording feedback mutes in muteSystemAudio mode") func playStartSoundRespectsSettings() async { let sound = MockSoundEffect() let settings = AppSettings() @@ -181,12 +188,16 @@ struct TranscriptionCoordinatorTests { playbackController: MockPlaybackController() ) - await coordinator.playStartSoundAndMute() + coordinator.startRecordingFeedback { true } + let feedbackCompleted = await waitUntil { + sound.playStartCallCount == 1 && control.muteCount == 1 + } + #expect(feedbackCompleted) #expect(sound.playStartCalled) #expect(control.muteCount == 1) } - @Test("playStartSoundAndMute does not mute in pauseMedia mode") + @Test("recording feedback does not mute in pauseMedia mode") func playStartSoundSkipsMuteInPauseMode() async { let sound = MockSoundEffect() let settings = AppSettings() @@ -205,12 +216,13 @@ struct TranscriptionCoordinatorTests { playbackController: MockPlaybackController() ) - await coordinator.playStartSoundAndMute() + coordinator.startRecordingFeedback { true } + #expect(await waitUntil { sound.playStartCallCount == 1 }) #expect(sound.playStartCalled) #expect(control.muteCount == 0) } - @Test("playStartSoundAndMute skips everything when off") + @Test("recording feedback skips everything when off") func playStartSoundSkipsWhenOff() async { let sound = MockSoundEffect() let settings = AppSettings() @@ -229,8 +241,61 @@ struct TranscriptionCoordinatorTests { playbackController: MockPlaybackController() ) - await coordinator.playStartSoundAndMute() + coordinator.startRecordingFeedback { true } + await Task.yield() #expect(!sound.playStartCalled) #expect(control.muteCount == 0) } + + @Test("cancelled start feedback cannot mute a restarted recording") + func cancelledFeedbackCannotMuteRestart() async { + let defaults = UserDefaults.standard + let previousMode = defaults.object(forKey: "backgroundAudioMode") + let previousSoundSetting = defaults.object(forKey: "soundEffectsEnabled") + defer { + if let previousMode { + defaults.set(previousMode, forKey: "backgroundAudioMode") + } else { + defaults.removeObject(forKey: "backgroundAudioMode") + } + if let previousSoundSetting { + defaults.set(previousSoundSetting, forKey: "soundEffectsEnabled") + } else { + defaults.removeObject(forKey: "soundEffectsEnabled") + } + } + + let settings = AppSettings() + settings.backgroundAudioMode = .muteSystemAudio + settings.soundEffectsEnabled = true + let sound = MockSoundEffect() + sound.waitsForStartCompletion = true + let control = MockAudioControl() + let coordinator = TranscriptionCoordinator( + settings: settings, + audioRecorder: MockAudioRecorder(), + pasteService: MockPasteService(), + audioControl: control, + modelManager: MockModelManager(), + deviceGuard: MockDeviceGuard(), + soundEffect: sound, + playbackController: MockPlaybackController() + ) + + coordinator.startRecordingFeedback { true } + #expect(await waitUntil { sound.playStartCallCount == 1 }) + + coordinator.cancelRecordingFeedback() + coordinator.startRecordingFeedback { true } + #expect(await waitUntil { sound.playStartCallCount == 2 }) + + #expect(control.muteCount == 0) + + sound.finishStartSound() + #expect(await waitUntil { control.muteCount == 1 }) + + #expect(control.muteCount == 1) + #expect(sound.stopStartCallCount == 1) + coordinator.cancelRecordingFeedback() + } } From bd87d8ae34ed78b57b142437c574dabd5eb294bf Mon Sep 17 00:00:00 2001 From: Amr Shawqy Date: Fri, 24 Jul 2026 20:47:45 +0300 Subject: [PATCH 2/2] Refresh permission status when Speaky becomes active --- Speaky/AppState.swift | 47 +++++++++++++++--------- Speaky/Services/HotkeyManager.swift | 11 ++++++ Speaky/SpeakyApp.swift | 6 ++- SpeakyTests/PermissionWarningTests.swift | 28 ++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 SpeakyTests/PermissionWarningTests.swift diff --git a/Speaky/AppState.swift b/Speaky/AppState.swift index c1c80a6..9eccdaf 100644 --- a/Speaky/AppState.swift +++ b/Speaky/AppState.swift @@ -59,7 +59,7 @@ final class AppState { hotkeyManager.onEscapeMonitoringAvailabilityChanged = { [weak self] isAvailable in guard let self else { return } escapeMonitoringUnavailable = !isAvailable - refreshPermissionWarning() + refreshPermissionStatus() } coordinator.deviceGuard.onDeviceLost = { [weak self] in guard let self else { return } @@ -71,25 +71,38 @@ final class AppState { } } - /// Check permissions on launch and surface a warning banner if any are revoked. - func checkPermissionsOnLaunch() { - refreshPermissionWarning() - } - - private func refreshPermissionWarning() { + /// Refresh permission-dependent services and the warning shown in the main window. + func refreshPermissionStatus() { let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let accessibilityGranted = AXIsProcessTrusted() - if micStatus == .denied && !accessibilityGranted { - permissionWarning = "Microphone and Accessibility permissions are missing. Go to Settings > Permissions to fix this." - } else if micStatus == .denied { - permissionWarning = "Microphone access was revoked. Go to Settings > Permissions to restore it." - } else if !accessibilityGranted { - permissionWarning = "Accessibility access is missing — auto-paste and global Escape cancellation won't work. Go to Settings > Permissions to enable it." - } else if escapeMonitoringUnavailable { - permissionWarning = "Global Escape cancellation is unavailable. Re-enable Accessibility for Speaky in Settings > Permissions, then try again." - } else { - permissionWarning = nil + if accessibilityGranted { + escapeMonitoringUnavailable = !hotkeyManager.refreshEscapeMonitoringAvailability() + } + + permissionWarning = Self.permissionWarningMessage( + microphoneDenied: micStatus == .denied, + accessibilityGranted: accessibilityGranted, + escapeMonitoringAvailable: !escapeMonitoringUnavailable + ) + } + + nonisolated static func permissionWarningMessage( + microphoneDenied: Bool, + accessibilityGranted: Bool, + escapeMonitoringAvailable: Bool + ) -> String? { + switch (microphoneDenied, accessibilityGranted, escapeMonitoringAvailable) { + case (true, false, _): + "Microphone and Accessibility permissions are missing. Go to Settings > Permissions to fix this." + case (true, true, _): + "Microphone access was revoked. Go to Settings > Permissions to restore it." + case (false, false, _): + "Accessibility access is missing — auto-paste and global Escape cancellation won't work. Go to Settings > Permissions to enable it." + case (false, true, false): + "Global Escape cancellation is unavailable. Re-enable Accessibility for Speaky in Settings > Permissions, then try again." + case (false, true, true): + nil } } diff --git a/Speaky/Services/HotkeyManager.swift b/Speaky/Services/HotkeyManager.swift index 5a54d8a..e53ec9e 100644 --- a/Speaky/Services/HotkeyManager.swift +++ b/Speaky/Services/HotkeyManager.swift @@ -198,6 +198,10 @@ final class HotkeyManager: @unchecked Sendable { escapeTapSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: isEscapeCancellationEnabled) + if let monitor = globalEscapeMonitor { + NSEvent.removeMonitor(monitor) + globalEscapeMonitor = nil + } logger.info("Active session event tap for ESC installed successfully") onEscapeMonitoringAvailabilityChanged?(true) } else { @@ -248,6 +252,13 @@ final class HotkeyManager: @unchecked Sendable { } } + /// Retry the active Escape tap after Accessibility permission changes. + func refreshEscapeMonitoringAvailability() -> Bool { + guard usesSystemMonitoring else { return true } + installEscapeTapIfAvailable() + return escapeTapPort != nil + } + func handleMonitoredEscape() { guard isEscapeCancellationEnabled else { return } setEscapeCancellationEnabled(false) diff --git a/Speaky/SpeakyApp.swift b/Speaky/SpeakyApp.swift index 37fe2c1..7309c1d 100644 --- a/Speaky/SpeakyApp.swift +++ b/Speaky/SpeakyApp.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import AppKit @main struct SpeakyApp: App { @@ -69,10 +70,13 @@ struct ContentRootView: View { if hasCompletedOnboarding { appState.warmUpEngine() // Check if permissions were revoked since last launch - appState.checkPermissionsOnLaunch() + appState.refreshPermissionStatus() } // Start Sparkle updater appState.updaterManager.startIfEnabled(checkForUpdates: appState.settings.checkForUpdates) } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + appState.refreshPermissionStatus() + } } } diff --git a/SpeakyTests/PermissionWarningTests.swift b/SpeakyTests/PermissionWarningTests.swift new file mode 100644 index 0000000..f38d0b6 --- /dev/null +++ b/SpeakyTests/PermissionWarningTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import Speaky + +@Suite("Permission warnings") +struct PermissionWarningTests { + @Test( + "Warning reflects current permission state", + arguments: [ + (true, false, false, "Microphone and Accessibility permissions are missing. Go to Settings > Permissions to fix this."), + (true, true, false, "Microphone access was revoked. Go to Settings > Permissions to restore it."), + (false, false, false, "Accessibility access is missing — auto-paste and global Escape cancellation won't work. Go to Settings > Permissions to enable it."), + (false, true, false, "Global Escape cancellation is unavailable. Re-enable Accessibility for Speaky in Settings > Permissions, then try again."), + (false, true, true, nil) + ] + ) + func currentPermissionState( + microphoneDenied: Bool, + accessibilityGranted: Bool, + escapeMonitoringAvailable: Bool, + expectedWarning: String? + ) { + #expect(AppState.permissionWarningMessage( + microphoneDenied: microphoneDenied, + accessibilityGranted: accessibilityGranted, + escapeMonitoringAvailable: escapeMonitoringAvailable + ) == expectedWarning) + } +}