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