Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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

Expand Down
79 changes: 47 additions & 32 deletions Speaky/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var pasteWarningDismissTask: Task<Void, Never>?
private var escapeMonitoringUnavailable = false
private var recordingGeneration: UInt = 0

let settings = AppSettings()
let hotkeyManager = HotkeyManager()
Expand All @@ -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
refreshPermissionStatus()
}
coordinator.deviceGuard.onDeviceLost = { [weak self] in
guard let self else { return }
if self.isRecording {
Expand All @@ -62,19 +71,38 @@ final class AppState {
}
}

/// Check permissions on launch and surface a warning banner if any are revoked.
func checkPermissionsOnLaunch() {
/// 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 won't work. Go to Settings > Permissions to enable it."
} 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
}
}

Expand Down Expand Up @@ -135,17 +163,19 @@ final class AppState {
}

state = .recording
recordingGeneration &+= 1
let generation = recordingGeneration
recordingStartTime = Date()
showingCancelWarning = false
showingCelebration = false
audioLevels = Array(repeating: 0, count: 30)

showNotch()

// 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 {
Expand Down Expand Up @@ -304,25 +334,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)
Expand Down
1 change: 1 addition & 0 deletions Speaky/Protocols/SoundEffecting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
@MainActor
protocol SoundEffecting: AnyObject {
func playStartAndWait() async
func stopStart()
func playEnd()
}

Expand Down
Loading