diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 1d184929ac9..acf336c1013 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -40,6 +40,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in") set(APPLE_PLIST_FILE "${CMAKE_BINARY_DIR}/Info.plist") +set(APPLE_ENTITLEMENTS_FILE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/entitlements.plist") configure_file("${APPLE_PLIST_TEMPLATE}" "${APPLE_PLIST_FILE}" @ONLY) set(PLATFORM_TARGET_FILES @@ -58,4 +59,5 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/publish.cpp" "${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.c" "${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.h" + ${APPLE_ENTITLEMENTS_FILE} ${APPLE_PLIST_FILE}) diff --git a/cmake/packaging/macos.cmake b/cmake/packaging/macos.cmake index 4b26f528b0d..65d06d94968 100644 --- a/cmake/packaging/macos.cmake +++ b/cmake/packaging/macos.cmake @@ -123,6 +123,7 @@ qt6_deploy_runtime_dependencies( # Sign the app last execute_process(COMMAND /usr/bin/codesign --verbose=2 --sign \"${APPLE_CODESIGN_IDENTITY}\" \"\${_app}\" + --entitlements \"${APPLE_ENTITLEMENTS_FILE}\" --force --timestamp --options=runtime RESULT_VARIABLE rc3 ) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index fda7adcb792..4a656721330 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -17,6 +17,9 @@ #import #import +// standard includes +#include + // lib includes #include "third-party/TPCircularBuffer/TPCircularBuffer.h" @@ -27,6 +30,27 @@ NS_ASSUME_NONNULL_BEGIN @class CATapDescription; namespace platf { + using microphone_permission_callback_t = std::function; ///< Completion callback for a microphone permission request. + using microphone_permission_request_t = std::function; ///< Function that starts a microphone permission request. + + /** + * @brief Resolve microphone access from an AVFoundation authorization state. + * + * @param authorization_status Current authorization state for audio capture. + * @param request_access Function used to request access when authorization has not been determined. + * @return `true` when microphone access is authorized. + */ + bool request_microphone_permission(AVAuthorizationStatus authorization_status, const microphone_permission_request_t &request_access); + + /** + * @brief Ensure Sunshine has permission to capture microphone audio. + * + * Requests access and waits for the user's response when authorization has not yet been determined. + * + * @return `true` when microphone access is authorized. + */ + bool request_microphone_permission(); + /** * @brief Provide captured PCM frames to AudioConverter. * diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 1b179aa1ad1..cf45436a7db 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -11,6 +11,10 @@ */ #import "av_audio.h" +// standard includes +#include + +// local includes #include "coreaudio_helpers.h" #include "src/logging.h" #include "src/utility.h" @@ -21,6 +25,43 @@ namespace platf { using namespace std::literals; + bool request_microphone_permission(AVAuthorizationStatus authorization_status, const microphone_permission_request_t &request_access) { + if (authorization_status == AVAuthorizationStatusNotDetermined) { + BOOST_LOG(info) << "Requesting microphone permission for the configured audio sink."sv; + } + + auto permission_granted = std::atomic {authorization_status == AVAuthorizationStatusAuthorized}; + if (authorization_status == AVAuthorizationStatusNotDetermined) { + auto permission_resolved = dispatch_semaphore_create(0); + request_access([&](bool granted) { + permission_granted = granted; + dispatch_semaphore_signal(permission_resolved); + }); + + dispatch_semaphore_wait(permission_resolved, DISPATCH_TIME_FOREVER); + dispatch_release(permission_resolved); + } + + if (!permission_granted.load()) { + if (authorization_status == AVAuthorizationStatusRestricted) { + BOOST_LOG(error) << "Microphone access is restricted by macOS."sv; + } else { + BOOST_LOG(error) << "Microphone access was denied. Enable Sunshine in System Settings -> Privacy & Security -> Microphone."sv; + } + } + + return permission_granted.load(); + } + + bool request_microphone_permission() { + return request_microphone_permission([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio], [](microphone_permission_callback_t callback) { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio + completionHandler:^(BOOL granted) { + callback(granted == YES); + }]; + }); + } + /** * @brief Real-time AudioConverter input callback for format conversion. * Provides audio data to AudioConverter during format conversion process using pure C++ for optimal performance. diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index db877712db8..72776e73d14 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -114,6 +114,10 @@ int set_sink(const std::string &sink) override { const char *audio_sink = config::audio.sink.c_str(); BOOST_LOG(info) << "Using configured audio sink "sv << audio_sink << " for capture."sv; + if (!request_microphone_permission()) { + return nullptr; + } + if ((audio_capture_device = [AVAudio findMicrophone:[NSString stringWithUTF8String:audio_sink]]) == nullptr) { BOOST_LOG(error) << "opening microphone '"sv << audio_sink << "' failed. Please set a valid input source in the Sunshine config."sv; BOOST_LOG(error) << "Available inputs:"sv; diff --git a/src_assets/macos/entitlements.plist b/src_assets/macos/entitlements.plist new file mode 100644 index 00000000000..f35f2d4289c --- /dev/null +++ b/src_assets/macos/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/tests/unit/platform/macos/test_av_audio.mm b/tests/unit/platform/macos/test_av_audio.mm index 8c6ee7ac621..7317f607923 100644 --- a/tests/unit/platform/macos/test_av_audio.mm +++ b/tests/unit/platform/macos/test_av_audio.mm @@ -63,6 +63,55 @@ } } +/** + * @brief Test that existing microphone permission is accepted without another request. + */ +TEST_F(AVAudioTest, AuthorizedMicrophonePermissionDoesNotRequestAccess) { + bool request_called = false; + + EXPECT_TRUE(platf::request_microphone_permission(AVAuthorizationStatusAuthorized, [&](platf::microphone_permission_callback_t callback) { + request_called = true; + callback(false); + })); + EXPECT_FALSE(request_called); +} + +/** + * @brief Test that denied, restricted, and unknown microphone states are rejected without another request. + */ +TEST_F(AVAudioTest, UnavailableMicrophonePermissionDoesNotRequestAccess) { + bool request_called = false; + const auto request_access = [&](platf::microphone_permission_callback_t callback) { + request_called = true; + callback(true); + }; + + EXPECT_FALSE(platf::request_microphone_permission(AVAuthorizationStatusDenied, request_access)); + EXPECT_FALSE(platf::request_microphone_permission(AVAuthorizationStatusRestricted, request_access)); + EXPECT_FALSE(platf::request_microphone_permission(static_cast(-1), request_access)); + EXPECT_FALSE(request_called); +} + +/** + * @brief Test that an undetermined microphone permission returns the user's decision. + */ +TEST_F(AVAudioTest, UndeterminedMicrophonePermissionRequestsAccess) { + bool request_called = false; + + EXPECT_TRUE(platf::request_microphone_permission(AVAuthorizationStatusNotDetermined, [&](platf::microphone_permission_callback_t callback) { + request_called = true; + callback(true); + })); + EXPECT_TRUE(request_called); + + request_called = false; + EXPECT_FALSE(platf::request_microphone_permission(AVAuthorizationStatusNotDetermined, [&](platf::microphone_permission_callback_t callback) { + request_called = true; + callback(false); + })); + EXPECT_TRUE(request_called); +} + /** * @brief Test that setupMicrophone handles nil device input properly. * Verifies the method returns an error code when passed a nil device.