Skip to content
Merged
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: 12 additions & 0 deletions Fader/Audio/HALProperty.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ extension HALError: LocalizedError {
}
}

extension HALError {
/// Core Audio uses a dedicated status for denied device/capture access.
/// Other setup failures (dead device, bad aggregate, illegal transient
/// operation) are recovery problems and must not masquerade as TCC denial.
var isPermissionDenied: Bool {
switch self {
case let .osStatus(status, _), let .operation(status, _):
status == kAudioDevicePermissionsError
}
}
}

/// Output vs. input side of the audio system. Everything device-related —
/// default-device selector, channel/volume scope — hangs off this.
enum AudioDirection {
Expand Down
5 changes: 3 additions & 2 deletions Fader/Audio/MixerEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,9 @@ final class MixerEngine {
needsAudioCapturePermission = false
} catch {
Self.logger.error("Tap failed for \(app.bundleID): \(error.localizedDescription)")
// TCC denial surfaces as a tap creation failure; offer the user a way out.
needsAudioCapturePermission = true
if let halError = error as? HALError, halError.isPermissionDenied {
needsAudioCapturePermission = true
}
}
}

Expand Down
23 changes: 23 additions & 0 deletions Tests/HALErrorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import CoreAudio
import Testing

@Suite("HALError permission classification")
struct HALErrorTests {
@Test("device permission status is classified for calls and properties")
func permissionStatus() {
let operation = HALError.operation(kAudioDevicePermissionsError, "start aggregate")
let property = HALError.osStatus(kAudioDevicePermissionsError, kAudioDevicePropertyMute)

#expect(operation.isPermissionDenied)
#expect(property.isPermissionDenied)
}

@Test("transient HAL failures do not show the permission banner")
func transientFailures() {
let deadDevice = HALError.operation(kAudioHardwareBadDeviceError, "start aggregate")
let churn = HALError.operation(kAudioHardwareIllegalOperationError, "create aggregate")

#expect(!deadDevice.isPermissionDenied)
#expect(!churn.isPermissionDenied)
}
}