ref:73ccd68f3cd49a36254ce1046b2d66149cbe656d

fix(macOS): enable microphone permission flow (#5621)

SHA: 73ccd68f3cd49a36254ce1046b2d66149cbe656d
Author: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com>
Date: 2026-09-06 12:37
Parents: 71189ae
7 files changed +129 -0
Type
cmake/compile_definitions/macos.cmake +2 −0
@@ -40,6 +40,7 @@
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 @@
"${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})
cmake/packaging/macos.cmake +1 −0
@@ -123,6 +123,7 @@
# 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
)
src/platform/macos/av_audio.h +24 −0
@@ -17,5 +17,8 @@
#import <CoreAudio/AudioHardwareTapping.h>
#import <CoreAudio/CoreAudio.h>
// standard includes
#include <functional>
// lib includes
#include "third-party/TPCircularBuffer/TPCircularBuffer.h"
@@ -27,6 +30,27 @@
@class CATapDescription;
namespace platf {
using microphone_permission_callback_t = std::function<void(bool)>; ///< Completion callback for a microphone permission request.
using microphone_permission_request_t = std::function<void(microphone_permission_callback_t)>; ///< 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.
*
src/platform/macos/av_audio.mm +41 −0
@@ -11,6 +11,10 @@
*/
#import "av_audio.h"
// standard includes
#include <atomic>
// local includes
#include "coreaudio_helpers.h"
#include "src/logging.h"
#include "src/utility.h"
@@ -20,6 +24,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<bool> {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.
src/platform/macos/microphone.mm +4 −0
@@ -114,6 +114,10 @@
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;
src_assets/macos/entitlements.plist +8 −0
@@ -1,0 +1,8 @@
<?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>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
tests/unit/platform/macos/test_av_audio.mm +49 −0
@@ -64,6 +64,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<AVAuthorizationStatus>(-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.
*/