diff --git a/macos/bundle_utils.sh b/macos/bundle_utils.sh index dff2ae947..31ff9268b 100644 --- a/macos/bundle_utils.sh +++ b/macos/bundle_utils.sh @@ -22,6 +22,7 @@ bundle_is_not_to_be_installed() { if [ "$1" = "CoreAudio" ]; then echo 1; fi if [ "$1" = "AudioToolbox" ]; then echo 1; fi if [ "$1" = "AudioUnit" ]; then echo 1; fi + if [ "$1" = "AVFoundation" ]; then echo 1; fi if [ "$1" = "libobjc.A.dylib" ]; then echo 1; fi if [ "$1" = "CFNetwork" ]; then echo 1; fi if [ "$1" = "SystemConfiguration" ]; then echo 1; fi @@ -217,15 +218,31 @@ bundle_create_plist() { echo ' CFBundleSignature'$5'' >> $8 echo ' CFBundleExecutable'$6'' >> $8 echo ' CFBundleIconFile'$7'' >> $8 + # macOS 10.14+ gates ALL audio input (including sound-card sources like the + # QMX/QDX IQ stream) behind the microphone TCC permission. Without this + # usage string macOS cannot present the consent prompt, so the input stream + # silently delivers zeros (empty waterfall). Applies to any audio-input + # capture in this bundle, not just QMX. + echo ' NSMicrophoneUsageDescriptionSDR++ needs microphone access to capture the IQ audio stream from sound-card SDR devices such as the QMX/QDX.' >> $8 echo ' ' >> $8 echo '' >> $8 } bundle_sign() { - if [ $# -ne 1 ]; then - echo "bundle_sign [bundle_dir]"; + if [ $# -lt 1 ]; then + echo "bundle_sign [bundle_dir] [entitlements_plist]"; return fi - codesign --force --deep -s - $1 + BUNDLE_DIR=$1 + ENTITLEMENTS=$2 + + # When an entitlements file is supplied, sign with the hardened runtime so + # the microphone (audio-input) entitlement is honored by TCC. Otherwise + # fall back to the previous plain ad-hoc signature. + if [ -n "$ENTITLEMENTS" ] && [ -f "$ENTITLEMENTS" ]; then + codesign --force --deep --options runtime --entitlements "$ENTITLEMENTS" -s - "$BUNDLE_DIR" + else + codesign --force --deep -s - "$BUNDLE_DIR" + fi } diff --git a/macos/sdrpp.entitlements b/macos/sdrpp.entitlements new file mode 100644 index 000000000..654072a5d --- /dev/null +++ b/macos/sdrpp.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.device.audio-input + + + com.apple.security.cs.disable-library-validation + + + diff --git a/macos/stage_macos_bundle.sh b/macos/stage_macos_bundle.sh index 6e4af51f7..dccf72f63 100644 --- a/macos/stage_macos_bundle.sh +++ b/macos/stage_macos_bundle.sh @@ -41,4 +41,4 @@ if [ -f "$SDRPLAY_LIB" ]; then bundle_install_binary "$BUNDLE" "$BUNDLE/Contents/Frameworks" "$SDRPLAY_LIB" fi -bundle_sign "$BUNDLE" \ No newline at end of file +bundle_sign "$BUNDLE" "$SCRIPT_DIR/sdrpp.entitlements" \ No newline at end of file diff --git a/source_modules/qmx_source/libqmx/CMakeLists.txt b/source_modules/qmx_source/libqmx/CMakeLists.txt index e7c812a0c..0816034b1 100644 --- a/source_modules/qmx_source/libqmx/CMakeLists.txt +++ b/source_modules/qmx_source/libqmx/CMakeLists.txt @@ -5,6 +5,13 @@ option(QMX_CAT_DEBUG_TIMING "Enable QMX CAT timing diagnostics" OFF) file(GLOB_RECURSE LIBQMX_SRC "src/*.cpp") +# Objective-C++ sources (e.g. the macOS microphone-permission helper) only +# compile on Apple platforms; keep them out of the build elsewhere. +if (APPLE) + file(GLOB_RECURSE LIBQMX_OBJCXX_SRC "src/*.mm") + list(APPEND LIBQMX_SRC ${LIBQMX_OBJCXX_SRC}) +endif () + add_library(qmx STATIC ${LIBQMX_SRC}) set_target_properties(qmx PROPERTIES POSITION_INDEPENDENT_CODE ON) target_compile_features(qmx PUBLIC cxx_std_17) @@ -20,6 +27,7 @@ elseif (APPLE) "-framework CoreAudio" "-framework AudioUnit" "-framework CoreFoundation" + "-framework AVFoundation" # AVCaptureDevice microphone authorization ) elseif (UNIX AND NOT APPLE) find_package(PkgConfig REQUIRED) diff --git a/source_modules/qmx_source/libqmx/src/MacOSBackend.cpp b/source_modules/qmx_source/libqmx/src/MacOSBackend.cpp index bf34b7d5f..4f6cb9193 100644 --- a/source_modules/qmx_source/libqmx/src/MacOSBackend.cpp +++ b/source_modules/qmx_source/libqmx/src/MacOSBackend.cpp @@ -1,6 +1,7 @@ #if defined(__APPLE__) #include "QmxDevice_internal.h" +#include "MacOSPermissions.h" #include "SerialCat.h" #include @@ -165,6 +166,25 @@ namespace qmx::detail { return false; } + // macOS gates audio input behind the microphone TCC permission. + // A HAL input unit opens and starts fine without it, but + // AudioUnitRender then only ever yields silence (empty waterfall), + // so verify/obtain consent up front and fail loudly otherwise. + switch (requestMicrophonePermission()) { + case MicrophonePermission::Granted: + break; + case MicrophonePermission::Denied: + error = "Microphone access denied. Allow it for SDR++ under " + "System Settings > Privacy & Security > Microphone, then start the stream again."; + return false; + case MicrophonePermission::Restricted: + error = "Microphone access is restricted by system policy, so the QMX IQ stream cannot be captured."; + return false; + case MicrophonePermission::NotDetermined: + error = "Microphone permission was not granted. Approve the access prompt, then start the stream again."; + return false; + } + AudioDeviceID deviceId = findDeviceByUid(options.audioDeviceId); if (deviceId == kAudioObjectUnknown) { error = "Selected QMX capture device is no longer available"; diff --git a/source_modules/qmx_source/libqmx/src/MacOSPermissions.h b/source_modules/qmx_source/libqmx/src/MacOSPermissions.h new file mode 100644 index 000000000..d12aa025e --- /dev/null +++ b/source_modules/qmx_source/libqmx/src/MacOSPermissions.h @@ -0,0 +1,24 @@ +#pragma once + +#if defined(__APPLE__) + +namespace qmx::detail { + enum class MicrophonePermission { + Granted, // Access authorized; capture will receive real samples. + Denied, // User (or a prior prompt) denied access. + Restricted, // Access blocked by policy (e.g. MDM/parental controls). + NotDetermined // No decision yet and the prompt could not be resolved. + }; + + // Returns the current microphone (audio-input) authorization status without + // prompting the user. + MicrophonePermission queryMicrophonePermission(); + + // Ensures microphone access is authorized, presenting the system consent + // prompt if the status is still undetermined. Blocks the calling thread + // until the user responds (or the timeout elapses). On macOS versions prior + // to 10.14, where audio input is not gated by TCC, this reports Granted. + MicrophonePermission requestMicrophonePermission(); +} + +#endif diff --git a/source_modules/qmx_source/libqmx/src/MacOSPermissions.mm b/source_modules/qmx_source/libqmx/src/MacOSPermissions.mm new file mode 100644 index 000000000..384c6d2f3 --- /dev/null +++ b/source_modules/qmx_source/libqmx/src/MacOSPermissions.mm @@ -0,0 +1,68 @@ +#if defined(__APPLE__) + +#include "MacOSPermissions.h" + +#import + +#include + +namespace qmx::detail { + namespace { + // How long we are willing to block start() while the user answers the + // one-time system consent dialog before treating it as a denial. The + // dialog is driven by a separate system process, so this only ever + // fires if the user walks away from the prompt. + constexpr int64_t kPromptTimeoutSeconds = 120; + + MicrophonePermission mapStatus(AVAuthorizationStatus status) { + switch (status) { + case AVAuthorizationStatusAuthorized: return MicrophonePermission::Granted; + case AVAuthorizationStatusDenied: return MicrophonePermission::Denied; + case AVAuthorizationStatusRestricted: return MicrophonePermission::Restricted; + case AVAuthorizationStatusNotDetermined: return MicrophonePermission::NotDetermined; + } + return MicrophonePermission::NotDetermined; + } + } + + MicrophonePermission queryMicrophonePermission() { + if (@available(macOS 10.14, *)) { + return mapStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]); + } + // Pre-10.14: audio input is not gated by TCC. + return MicrophonePermission::Granted; + } + + MicrophonePermission requestMicrophonePermission() { + if (@available(macOS 10.14, *)) { + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + if (status != AVAuthorizationStatusNotDetermined) { + return mapStatus(status); + } + + // First run for this device/binary: present the consent dialog and + // wait for the user's decision. AVFoundation delivers the callback + // on an internal queue (not the main queue), so blocking the caller + // here does not deadlock the app's run loop. + __block BOOL granted = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio + completionHandler:^(BOOL allowed) { + granted = allowed; + dispatch_semaphore_signal(sem); + }]; + + dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, kPromptTimeoutSeconds * NSEC_PER_SEC); + if (dispatch_semaphore_wait(sem, deadline) != 0) { + // Timed out waiting for a response; report the live status so a + // late "Allow" is still picked up on the next start attempt. + return mapStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]); + } + return granted ? MicrophonePermission::Granted : MicrophonePermission::Denied; + } + // Pre-10.14: audio input is not gated by TCC. + return MicrophonePermission::Granted; + } +} + +#endif