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
23 changes: 20 additions & 3 deletions macos/bundle_utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,15 +218,31 @@ bundle_create_plist() {
echo ' <key>CFBundleSignature</key><string>'$5'</string>' >> $8
echo ' <key>CFBundleExecutable</key><string>'$6'</string>' >> $8
echo ' <key>CFBundleIconFile</key><string>'$7'</string>' >> $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 ' <key>NSMicrophoneUsageDescription</key><string>SDR++ needs microphone access to capture the IQ audio stream from sound-card SDR devices such as the QMX/QDX.</string>' >> $8
echo ' </dict>' >> $8
echo '</plist>' >> $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
}
17 changes: 17 additions & 0 deletions macos/sdrpp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Grants the hardened runtime the right to open audio-input devices.
Required so the microphone (sound-card IQ) capture used by the QMX
source is not blocked once the runtime hardening is enabled. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- The bundle ships its own third-party dylibs that are only ad-hoc
signed, not signed by the same team identifier as the main binary.
Without this exception the hardened runtime would refuse to load
them and the app would fail to launch. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
2 changes: 1 addition & 1 deletion macos/stage_macos_bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ if [ -f "$SDRPLAY_LIB" ]; then
bundle_install_binary "$BUNDLE" "$BUNDLE/Contents/Frameworks" "$SDRPLAY_LIB"
fi

bundle_sign "$BUNDLE"
bundle_sign "$BUNDLE" "$SCRIPT_DIR/sdrpp.entitlements"
8 changes: 8 additions & 0 deletions source_modules/qmx_source/libqmx/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions source_modules/qmx_source/libqmx/src/MacOSBackend.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if defined(__APPLE__)

#include "QmxDevice_internal.h"
#include "MacOSPermissions.h"
#include "SerialCat.h"

#include <algorithm>
Expand Down Expand Up @@ -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";
Expand Down
24 changes: 24 additions & 0 deletions source_modules/qmx_source/libqmx/src/MacOSPermissions.h
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions source_modules/qmx_source/libqmx/src/MacOSPermissions.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#if defined(__APPLE__)

#include "MacOSPermissions.h"

#import <AVFoundation/AVFoundation.h>

#include <dispatch/dispatch.h>

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