From 270b280afeffda64a3ba1ef6c7b79cd03d4516c6 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:07:13 -0400 Subject: [PATCH 1/3] feat(permissions): expose platform access and restart after grants Add a shared permission status model and authenticated Web UI endpoints. Show required and optional access on the Home and Troubleshooting pages, with native request actions or platform setup guidance as appropriate. Request screen capture, input, microphone, and optional notifications at macOS startup. Probe System Audio Recording with a temporary tap and show Local Network and System Audio as on-use where passive checks are unavailable. Check virtual input device access on Linux and FreeBSD and configuration directory access on Windows. When verifiable required access is missing at launch, restart Sunshine once after the running process can confirm it was granted. Document the platform limits and add native, HTTP, and Web UI tests for the new behavior. --- cmake/compile_definitions/common.cmake | 2 + cmake/compile_definitions/macos.cmake | 1 + cmake/dependencies/macos.cmake | 1 + docs/configuration.md | 12 ++ docs/getting_started.md | 13 +- src/confighttp.cpp | 84 +++++++++ src/confighttp.h | 28 +++ src/main.cpp | 47 +++++ src/platform/linux/misc.cpp | 21 ++- src/platform/macos/av_audio.mm | 10 + src/platform/macos/misc.h | 27 ++- src/platform/macos/misc.mm | 174 +++++++++++++++--- src/platform/permissions.cpp | 40 ++++ src/platform/permissions.h | 55 ++++++ src/platform/windows/misc.cpp | 13 ++ src_assets/common/assets/web/Home.vue | 22 +++ .../common/assets/web/Troubleshooting.vue | 144 ++++++++++++++- .../assets/web/public/assets/locale/en.json | 40 ++++ src_assets/common/assets/web/sunshine.css | 79 +++++++- tests/unit/platform/macos/test_av_audio.mm | 50 +++++ .../unit/platform/macos/test_permissions.cpp | 46 +++++ tests/unit/platform/test_permissions.cpp | 83 +++++++++ tests/unit/test_confighttp.cpp | 44 +++++ tests/web/Home.test.js | 24 ++- tests/web/Troubleshooting.test.js | 101 +++++++++- 25 files changed, 1121 insertions(+), 40 deletions(-) create mode 100644 src/platform/permissions.cpp create mode 100644 src/platform/permissions.h create mode 100644 tests/unit/platform/macos/test_permissions.cpp create mode 100644 tests/unit/platform/test_permissions.cpp diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 94fbb1426c7..f3b9390b3a4 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -178,6 +178,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/audio.cpp" "${CMAKE_SOURCE_DIR}/src/audio.h" "${CMAKE_SOURCE_DIR}/src/platform/common.h" + "${CMAKE_SOURCE_DIR}/src/platform/permissions.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/permissions.h" "${CMAKE_SOURCE_DIR}/src/process.cpp" "${CMAKE_SOURCE_DIR}/src/process.h" "${CMAKE_SOURCE_DIR}/src/network.cpp" diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 5c1608ce820..bb478dcfe8c 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -36,6 +36,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CORE_VIDEO_LIBRARY} ${FOUNDATION_LIBRARY} ${IOKIT_LIBRARY} + ${USER_NOTIFICATIONS_LIBRARY} ${VIDEO_TOOLBOX_LIBRARY}) set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in") diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 4a027ef9dd5..3dc396fe27c 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -10,6 +10,7 @@ FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia) FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo) FIND_LIBRARY(FOUNDATION_LIBRARY Foundation) FIND_LIBRARY(IOKIT_LIBRARY IOKit) +FIND_LIBRARY(USER_NOTIFICATIONS_LIBRARY UserNotifications) FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) if(SUNSHINE_ENABLE_TRAY) diff --git a/docs/configuration.md b/docs/configuration.md index 6d51304d62b..43bf4c69b63 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,6 +37,18 @@ editing the `conf` file in a text editor. Use the examples as reference. The web UI groups these settings into the sidebar categories documented below. Encoder categories are shown only when supported on the current platform. +## Permissions on every platform + +Open **Troubleshooting > Permissions** in the Web UI to see required and optional access for the current platform. +The Home page flags verifiable required access that is missing. Sunshine checks for access granted while it is running +and restarts once after all verifiable required access is available. On Unix, adding a user to a group takes effect only +after a new login session; Sunshine cannot detect the new group membership in the existing process. + +On Linux and FreeBSD, virtual keyboard, mouse, and gamepad input need read and write access to `/dev/uinput` (Linux +also checks `/dev/input/uinput`). The Web UI shows setup steps if that access is missing. On Windows, Sunshine checks +whether its account can list and create files in the `config` directory beside the executable. Windows provides no +consent prompt for directory ACLs, so the Web UI shows setup steps for correcting access. + ## General ### locale diff --git a/docs/getting_started.md b/docs/getting_started.md index 4f583676956..537cb99a799 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -563,7 +563,18 @@ systemctl --user --now enable app-dev.lizardbyte.app.Sunshine > XDG Desktop Portal, but it is also aliased to "sunshine.service" for convenience. ### macOS -The first time you start Sunshine, you will be asked to grant access to screen recording and your microphone. +On first launch, Sunshine requests Screen Recording and keyboard and mouse control when those features are enabled. +It also requests Microphone access if you configured a custom **Audio Sink**, and optional Notifications access when +the system tray is enabled. Open **Troubleshooting > Permissions** in the Web UI to review these permissions or +open their System Settings pages. Sunshine restarts once after missing required permissions are granted, including +when a permission was removed and later restored. + +macOS requests Local Network access when Sunshine advertises itself with Bonjour. When the other required permissions +are ready, Sunshine briefly starts an unmuted system audio tap to request System Audio Recording access before the +first stream. If that tap cannot start, the first stream can still prompt. macOS does not offer Sunshine a passive status +check for Local Network or System Audio Recording, so the Web UI identifies them as permissions handled when used. +Virtual gamepad access belongs to the separately installed Virtual HID Broker and is described in the macOS gamepad +setup above; Sunshine shows the broker's availability and license under **Troubleshooting > Virtual Gamepad**. Sunshine supports native system audio capture on macOS 14.0 (Sonoma) and newer via Apple’s Audio Tap API. To use it, simply leave the **Audio Sink** setting blank. diff --git a/src/confighttp.cpp b/src/confighttp.cpp index b0241854a8d..4e6ba138e16 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -38,6 +38,8 @@ #include #elif defined(__APPLE__) + #include "platform/macos/misc.h" + #include #endif @@ -54,6 +56,7 @@ #include "network.h" #include "nvhttp.h" #include "platform/common.h" +#include "platform/permissions.h" #include "process.h" #include "rtsp.h" #include "system_tray.h" @@ -89,6 +92,9 @@ namespace confighttp { namespace { using license_status_provider_t = std::function; ///< Provider for the current libvirtualhid license status. +#ifdef SUNSHINE_TESTS + std::optional permission_status_override; ///< Deterministic permission statuses for HTTP tests. +#endif #if defined(linux) || defined(__FreeBSD__) || defined(SUNSHINE_TESTS) using portal_token_path_provider_t = std::function; ///< Provider for the XDG Portal token path. #endif @@ -2005,6 +2011,82 @@ namespace confighttp { platf::restart(); } + /** + * @brief Return permission status for the current host platform. + * + * @api_examples{/api/permissions|:| GET|:| null} + */ + void getPermissions(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + nlohmann::json output_tree; + output_tree["permissions"] = nlohmann::json::array(); +#ifdef SUNSHINE_TESTS + if (permission_status_override) { + output_tree["permissions"] = *permission_status_override; + send_response(response, output_tree); + return; + } +#endif + for (const auto &permission : platf::get_permission_statuses()) { + output_tree["permissions"].push_back({ + {"id", permission.id}, + {"status", permission.status}, + {"required", permission.required}, + {"verifiable", permission.verifiable}, + {"requestable", permission.requestable}, + }); + } + send_response(response, output_tree); + } + +#ifdef SUNSHINE_TESTS + void set_permission_statuses_for_testing(nlohmann::json permissions) { + permission_status_override = std::move(permissions); + } + + void reset_permission_statuses_for_testing() { + permission_status_override.reset(); + } +#endif + + /** + * @brief Start a native permission request or open its settings pane. + * + * @api_examples{/api/permissions/request|:| POST|:| {"id":"screen_recording"}} + */ + void requestPermission(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + const auto client_id = get_client_id(request); + if (!validate_csrf_token(response, request, client_id)) { + return; + } + if (!check_content_type(response, request, "application/json")) { + return; + } + + try { + const auto input = nlohmann::json::parse(request->content.string()); + if (!input.is_object() || !input.contains("id") || !input["id"].is_string()) { + bad_request(response, request, "A permission ID is required"); + return; + } + + const bool requested = platf::request_permission(input["id"].get()); + if (!requested) { + bad_request(response, request, "Unknown or unavailable permission"); + return; + } + send_response(response, {{"status", true}}); + } catch (const nlohmann::json::exception &) { + bad_request(response, request, "Invalid permission request"); + } + } + /** * @brief Build Virtual HID Broker version and installation status. * @@ -2424,6 +2506,8 @@ namespace confighttp { server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence; server.resource["^/api/reset-portal-token$"]["POST"] = resetPortalToken; server.resource["^/api/restart$"]["POST"] = restart; + server.resource["^/api/permissions$"]["GET"] = getPermissions; + server.resource["^/api/permissions/request$"]["POST"] = requestPermission; server.resource["^/api/virtual-input/license$"]["GET"] = getVirtualInputLicense; server.resource["^/api/virtual-input/license$"]["POST"] = updateVirtualInputLicense; server.resource["^/api/virtual-input/status$"]["GET"] = getVirtualInputStatus; diff --git a/src/confighttp.h b/src/confighttp.h index 1e6d88ebda0..2e1e53d1c0a 100644 --- a/src/confighttp.h +++ b/src/confighttp.h @@ -175,6 +175,22 @@ namespace confighttp { void getVirtualInputStatus(const resp_https_t &response, const req_https_t &request); + /** + * @brief Return the host's permission statuses to the Web UI. + * + * @param response HTTPS response receiving the status JSON. + * @param request Authenticated HTTPS request. + */ + void getPermissions(const resp_https_t &response, const req_https_t &request); + + /** + * @brief Initiate a native permission request from the Web UI. + * + * @param response HTTPS response receiving the request result. + * @param request Authenticated, CSRF protected request. + */ + void requestPermission(const resp_https_t &response, const req_https_t &request); + void getVirtualInputLicense(const resp_https_t &response, const req_https_t &request); void updateVirtualInputLicense(const resp_https_t &response, const req_https_t &request); @@ -182,6 +198,18 @@ namespace confighttp { void resetPortalToken(const resp_https_t &response, const req_https_t &request); #ifdef SUNSHINE_TESTS + /** + * @brief Replace native permission statuses for deterministic HTTP tests. + * + * @param permissions JSON array returned by the status endpoint. + */ + void set_permission_statuses_for_testing(nlohmann::json permissions); + + /** + * @brief Restore native permission status queries after HTTP tests. + */ + void reset_permission_statuses_for_testing(); + using virtual_input_license_status_provider_t = std::function; ///< Test provider for current libvirtualhid license status. using portal_token_path_provider_t = std::function; ///< Test provider for the XDG Portal token path. diff --git a/src/main.cpp b/src/main.cpp index e6fee67b88c..c72387117aa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,8 @@ // platform includes #ifdef __APPLE__ + #include "platform/macos/misc.h" + #include #endif #ifdef __linux__ @@ -38,6 +40,7 @@ #include "logging.h" #include "main.h" #include "nvhttp.h" +#include "platform/permissions.h" #include "process.h" #include "system_tray.h" #include "upnp.h" @@ -440,6 +443,13 @@ int main(int argc, char *argv[]) { BOOST_LOG(error) << "Platform failed to initialize"sv; } + // Capture the pre-request state so access granted during this launch causes + // one clean restart after every verifiable required permission is available. + const bool permission_restart_needed = !platf::required_permissions_granted(platf::get_permission_statuses()); +#ifdef __APPLE__ + platf::request_startup_permissions(tray_is_enabled && config::sunshine.system_tray); +#endif + auto proc_deinit_guard = proc::init(); if (!proc_deinit_guard) { BOOST_LOG(error) << "Proc failed to initialize"sv; @@ -512,8 +522,45 @@ int main(int argc, char *argv[]) { #endif } +#ifdef __APPLE__ + // Core Audio has no passive system-audio authorization check. A short, + // unmuted tap asks macOS for access before the first stream starts. + std::jthread macos_audio_permission_requester; + if (!permission_restart_needed && config::audio.sink.empty()) { + macos_audio_permission_requester = std::jthread([]() { + if (!platf::request_system_audio_permission()) { + BOOST_LOG(warning) << "System audio recording permission or tap setup is unavailable"sv; + } + }); + } +#endif + + std::jthread permission_watcher; + if (permission_restart_needed) { + permission_watcher = std::jthread([](std::stop_token stop) { + while (!stop.stop_requested()) { + if (platf::required_permissions_granted(platf::get_permission_statuses())) { + BOOST_LOG(info) << "Required permissions granted; restarting Sunshine"sv; + platf::restart(); + return; + } + std::this_thread::sleep_for(2s); + } + }); + } + mainThreadLoop(shutdown_event); + permission_watcher.request_stop(); + if (permission_watcher.joinable()) { + permission_watcher.join(); + } +#ifdef __APPLE__ + if (macos_audio_permission_requester.joinable()) { + macos_audio_permission_requester.join(); + } +#endif + httpThread.join(); configThread.join(); rtspThread.join(); diff --git a/src/platform/linux/misc.cpp b/src/platform/linux/misc.cpp index 85aa1b7a02c..66a0c784ce4 100644 --- a/src/platform/linux/misc.cpp +++ b/src/platform/linux/misc.cpp @@ -24,6 +24,7 @@ // platform includes #include #include +#include #include // For RTKit #include #include @@ -31,6 +32,7 @@ #include #include // For setpriority #include +#include #if !defined(__FreeBSD__) #include @@ -45,9 +47,7 @@ // lib includes #include #include -#include #include -#include #ifdef SUNSHINE_BUILD_DRM #include @@ -64,6 +64,7 @@ #include "src/globals.h" #include "src/logging.h" #include "src/platform/common.h" +#include "src/platform/permissions.h" #include "vaapi.h" #ifdef __GNUC__ @@ -153,6 +154,22 @@ namespace dyn { } // namespace dyn namespace platf { + std::vector get_permission_statuses() { + // Match libvirtualhid's device paths and its read/write access check. + bool input_access = access("/dev/uinput", R_OK | W_OK) == 0; +#ifndef __FreeBSD__ + input_access = input_access || access("/dev/input/uinput", R_OK | W_OK) == 0; +#endif + return {{"input", input_access ? "granted" : "denied", config::input.keyboard || config::input.mouse || config::input.controller, true}}; + } + + bool request_permission(std::string_view id) { + // Unix device access has no process-local permission prompt. The Web UI + // presents the group/device setup steps for this known permission. + (void) id; + return false; + } + namespace { constexpr std::array privileged_gui_environment_variables { "GDK_PIXBUF_MODULEDIR", diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 8372d258d56..af1927ed104 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -9,6 +9,7 @@ * The implementation handles format conversion, real-time audio processing, and provides * a unified interface for both capture methods through a shared circular buffer. */ +// header include #import "av_audio.h" // standard includes @@ -19,6 +20,7 @@ #include "src/logging.h" #include "src/utility.h" +// platform includes #import #import @@ -62,6 +64,14 @@ bool request_microphone_permission() { }); } + bool request_system_audio_permission() { + AVAudio *probe = [[AVAudio alloc] init]; + probe.hostAudioEnabled = YES; + const bool started = [probe setupSystemTap:48000 frameSize:512 channels:2] == 0; + [probe release]; + return started; + } + /** * @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/misc.h b/src/platform/macos/misc.h index 0af5d1ae252..2efe8a005e3 100644 --- a/src/platform/macos/misc.h +++ b/src/platform/macos/misc.h @@ -7,10 +7,33 @@ // standard includes #include -// platform includes -#include +// local includes +#include "src/platform/permissions.h" namespace platf { + /** + * @brief Decide whether startup should initiate a native permission request. + * + * @param permission Permission status and requirement for the active configuration. + * @param notifications_enabled Whether the system tray uses notifications. + * @return True when a missing permission should be requested. + */ + bool should_request_startup_permission(const permission_status_t &permission, bool notifications_enabled); + + /** + * @brief Request missing permissions needed at startup. + * + * @param notifications_enabled Whether the running build shows tray notifications. + */ + void request_startup_permissions(bool notifications_enabled); + + /** + * @brief Start and stop an unmuted Core Audio tap to request system audio access. + * + * @return True when the temporary tap started successfully. + */ + bool request_system_audio_permission(); + /** * @brief Check whether macOS has granted screen-capture permission. * diff --git a/src/platform/macos/misc.mm b/src/platform/macos/misc.mm index d35f44eee6a..e85bdd24bff 100644 --- a/src/platform/macos/misc.mm +++ b/src/platform/macos/misc.mm @@ -13,17 +13,24 @@ #endif // standard includes -#include -#include +#include +#include +#include // platform includes +#include #include +#include +#include #include +#include #include +#include #include #include #include #include +#include // lib includes #include @@ -32,6 +39,7 @@ // local includes #include "misc.h" #include "src/boost_process_compat.h" +#include "src/config.h" #include "src/entry_handler.h" #include "src/logging.h" #include "src/platform/common.h" @@ -63,8 +71,148 @@ namespace { auto screen_capture_allowed = std::atomic {false}; + + /** + * @brief Check screen recording without showing a macOS prompt. + * + * @return True when capture is allowed or the old OS has no privacy gate. + */ + bool screen_recording_granted() { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability-new" +#pragma clang diagnostic ignored "-Wtautological-pointer-compare" + return ![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {10, 15, 0})] || + CGPreflightScreenCaptureAccess == nullptr || CGPreflightScreenCaptureAccess(); +#pragma clang diagnostic pop + } + + /** + * @brief Query notification authorization without holding a callback stack frame alive. + * + * @return Authorization state name for the Web UI. + */ + std::string notification_status() { + auto result = std::make_shared>(); + auto future = result->get_future(); + [[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) { + result->set_value(settings.authorizationStatus); + }]; + if (future.wait_for(2s) != std::future_status::ready) { + return "unknown"; + } + switch (future.get()) { + case UNAuthorizationStatusAuthorized: + case UNAuthorizationStatusProvisional: + return "granted"; + case UNAuthorizationStatusDenied: + return "denied"; + case UNAuthorizationStatusNotDetermined: + return "not_determined"; + } + return "unknown"; + } + + /** + * @brief Open a fixed macOS privacy settings pane. + * + * @param pane Privacy pane anchor, selected from known Sunshine permissions. + * @return True when macOS accepted the settings URL. + */ + bool open_privacy_settings(NSString *pane) { + NSString *address = [@"x-apple.systempreferences:com.apple.preference.security?" stringByAppendingString:pane]; + return [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:address]] == YES; + } } // namespace + std::vector get_permission_statuses() { + const auto microphone_authorization = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + std::string microphone_status = "unknown"; + switch (microphone_authorization) { + case AVAuthorizationStatusAuthorized: + microphone_status = "granted"; + break; + case AVAuthorizationStatusDenied: + case AVAuthorizationStatusRestricted: + microphone_status = "denied"; + break; + case AVAuthorizationStatusNotDetermined: + microphone_status = "not_determined"; + break; + } + + return { + {"screen_recording", screen_recording_granted() ? "granted" : "denied", true, true, true}, + {"input", CGPreflightPostEventAccess() ? "granted" : "denied", config::input.keyboard || config::input.mouse, true, true}, + {"microphone", microphone_status, !config::audio.sink.empty(), true, true}, + {"system_audio", "on_use", config::audio.sink.empty(), false, true}, + {"local_network", "on_use", true, false, true}, + {"notifications", notification_status(), false, true, true}, + }; + } + + bool should_request_startup_permission(const permission_status_t &permission, bool notifications_enabled) { + if (permission.id == "notifications") { + return notifications_enabled && permission.status == "not_determined"; + } + if (!permission.required) { + return false; + } + if (permission.id == "microphone") { + return permission.status == "not_determined" || permission.status == "denied"; + } + return (permission.id == "screen_recording" || permission.id == "input") && + (permission.status == "not_determined" || permission.status == "denied"); + } + + bool request_permission(std::string_view id) { + if (id == "screen_recording") { + if (screen_recording_granted()) { + return true; + } +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability-new" + if (!CGRequestScreenCaptureAccess()) { + return open_privacy_settings(@"Privacy_ScreenCapture"); + } +#pragma clang diagnostic pop + return true; + } + if (id == "input") { + return CGPreflightPostEventAccess() || CGRequestPostEventAccess() || open_privacy_settings(@"Privacy_Accessibility"); + } + if (id == "microphone") { + const auto authorization = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + if (authorization == AVAuthorizationStatusNotDetermined) { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL) {}]; + return true; + } + return authorization == AVAuthorizationStatusAuthorized || open_privacy_settings(@"Privacy_Microphone"); + } + if (id == "notifications") { + if (notification_status() == "denied") { + return [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.notifications"]] == YES; + } + [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound) + completionHandler:^(BOOL, NSError *) {}]; + return true; + } + if (id == "system_audio") { + return request_system_audio_permission() || open_privacy_settings(@"Privacy_ScreenCapture"); + } + if (id == "local_network") { + return open_privacy_settings(@"Privacy_LocalNetwork"); + } + return false; + } + + void request_startup_permissions(bool notifications_enabled) { + for (const auto &permission : get_permission_statuses()) { + if (should_request_startup_permission(permission, notifications_enabled)) { + request_permission(permission.id); + } + } + } + // Return whether screen capture is allowed for this process. /** * @brief Check whether screen capture allowed. @@ -74,29 +222,11 @@ bool is_screen_capture_allowed() { } std::unique_ptr init() { - // This will generate a warning about CGPreflightScreenCaptureAccess and - // CGRequestScreenCaptureAccess being unavailable before macOS 10.15, but - // we have a guard to prevent it from being called on those earlier systems. - // Unfortunately the supported way to silence this warning, using @available, - // produces linker errors for __isPlatformVersionAtLeast, so we have to use - // a different method. - // We also ignore "tautological-pointer-compare" because when compiling with - // Xcode 12.2 and later, these functions are not weakly linked and will never - // be null, and therefore generate this warning. Since we are weakly linking - // when compiling with earlier Xcode versions, the check for null is - // necessary, and so we ignore the warning. -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunguarded-availability-new" -#pragma clang diagnostic ignored "-Wtautological-pointer-compare" - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {10, 15, 0})] && - // Double check that these weakly-linked symbols have been loaded: - CGPreflightScreenCaptureAccess != nullptr && CGRequestScreenCaptureAccess != nullptr && !CGPreflightScreenCaptureAccess()) { + if (!screen_recording_granted()) { BOOST_LOG(error) << "No screen capture permission!"sv; - BOOST_LOG(error) << "Please activate it in 'System Preferences' -> 'Privacy' -> 'Screen Recording'"sv; - CGRequestScreenCaptureAccess(); + BOOST_LOG(error) << "Please activate Sunshine in System Settings -> Privacy & Security -> Screen & System Audio Recording"sv; return nullptr; } -#pragma clang diagnostic pop // Record that we determined that we have the screen capture permission. screen_capture_allowed = true; return std::make_unique(); diff --git a/src/platform/permissions.cpp b/src/platform/permissions.cpp new file mode 100644 index 00000000000..1c52f20446b --- /dev/null +++ b/src/platform/permissions.cpp @@ -0,0 +1,40 @@ +/** + * @file src/platform/permissions.cpp + * @brief Shared permission policy and file access checks. + */ +// header include +#include "permissions.h" + +// standard includes +#include + +// platform includes +#ifdef _WIN32 + #include +#else + #include +#endif + +namespace platf { + bool required_permissions_granted(const std::vector &permissions) { + return std::ranges::all_of(permissions, [](const auto &permission) { + return !permission.required || !permission.verifiable || permission.status == "granted"; + }); + } + + bool can_access_directory(const std::filesystem::path &path) { +#ifdef _WIN32 + // Request directory rights from the effective token; filesystem::perms does + // not reflect Windows ACLs or deny entries. + HANDLE handle = CreateFileW(path.c_str(), FILE_LIST_DIRECTORY | FILE_ADD_FILE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + return false; + } + CloseHandle(handle); + return true; +#else + std::error_code error; + return std::filesystem::is_directory(path, error) && !error && access(path.c_str(), R_OK | W_OK | X_OK) == 0; +#endif + } +} // namespace platf diff --git a/src/platform/permissions.h b/src/platform/permissions.h new file mode 100644 index 00000000000..0c27b9f55ce --- /dev/null +++ b/src/platform/permissions.h @@ -0,0 +1,55 @@ +/** + * @file src/platform/permissions.h + * @brief Cross-platform permission status and request interface. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +namespace platf { + /** + * @brief Access needed by the current Sunshine configuration. + */ + struct permission_status_t { + std::string id; ///< Stable identifier used by the Web UI. + std::string status; ///< Granted, denied, not_determined, on_use, or unknown. + bool required; ///< Whether the current configuration needs this access. + bool verifiable; ///< Whether the OS permits a passive status check. + bool requestable = false; ///< Whether Sunshine can initiate a native request or settings action. + }; + + /** + * @brief Query access needed by Sunshine on the current platform. + * + * @return Permission status records for the Web UI. + */ + std::vector get_permission_statuses(); + + /** + * @brief Determine whether all verifiable required permissions are granted. + * + * @param permissions Current permission status records. + * @return True when no verifiable required permission is missing. + */ + bool required_permissions_granted(const std::vector &permissions); + + /** + * @brief Initiate a native request or settings action for an access item. + * + * @param id Stable identifier of the access item. + * @return True when the action was recognized and initiated. + */ + bool request_permission(std::string_view id); + + /** + * @brief Check whether this process can list and create files in a directory. + * + * @param path Directory to check without modifying its contents. + * @return True when the directory is present and accessible. + */ + bool can_access_directory(const std::filesystem::path &path); +} // namespace platf diff --git a/src/platform/windows/misc.cpp b/src/platform/windows/misc.cpp index ad3e660cfcf..cbd6f3c0a9c 100644 --- a/src/platform/windows/misc.cpp +++ b/src/platform/windows/misc.cpp @@ -19,6 +19,7 @@ // local includes required before platform includes #include "src/boost_process_compat.h" +// platform includes // prevent clang format from "optimizing" the header include order // clang-format off #include @@ -51,6 +52,7 @@ #include "src/globals.h" #include "src/logging.h" #include "src/platform/common.h" +#include "src/platform/permissions.h" #include "src/utility.h" #include "utf_utils.h" @@ -124,6 +126,17 @@ namespace bp = boost::process::v1; using namespace std::literals; namespace platf { + std::vector get_permission_statuses() { + return {{"config_directory", can_access_directory(appdata()) ? "granted" : "denied", true, true}}; + } + + bool request_permission(std::string_view id) { + // Windows does not offer a consent prompt for arbitrary directory ACLs. + // The Web UI shows the setup steps for this known permission. + (void) id; + return false; + } + /** * @brief Owning pointer for `GetAdaptersAddresses` results. */ diff --git a/src_assets/common/assets/web/Home.vue b/src_assets/common/assets/web/Home.vue index 8d6ac5217c3..261e3c6ac62 100644 --- a/src_assets/common/assets/web/Home.vue +++ b/src_assets/common/assets/web/Home.vue @@ -21,6 +21,17 @@ +
+
+ + {{ $t('index.permissions_missing_title') }} +
+

{{ $t('index.permissions_missing_desc') }}

+ + {{ $t('index.permissions_review') }} + +
+
@@ -172,6 +183,7 @@ virtualhid: null, virtualhidLicense: null, vigembus: null, + permissions: [], } }, async created() { @@ -182,6 +194,12 @@ this.controllerEnabled = config.controller !== "disabled"; this.gamepadDriver = config.gamepad_driver || ''; this.version = new SunshineVersion(null, config.version); + try { + const response = await fetch('./api/permissions'); + this.permissions = (await response.json()).permissions || []; + } catch (e) { + console.error('Failed to fetch permission status:', e); + } console.log("Version: ", this.version.version) this.githubVersion = new SunshineVersion(await fetch("https://api.github.com/repos/LizardByte/Sunshine/releases/latest").then((r) => r.json()), null); console.log("GitHub Version: ", this.githubVersion.version) @@ -218,6 +236,10 @@ this.loading = false; }, computed: { + /** Return required permissions that the current platform can check and has not granted. */ + missingPermissions() { + return this.permissions.filter(permission => permission.required && permission.verifiable && permission.status !== 'granted'); + }, /** * Build the virtual-input message shown on the home page. * Warn about broker or gamepad driver issues when virtual gamepads are enabled. diff --git a/src_assets/common/assets/web/Troubleshooting.vue b/src_assets/common/assets/web/Troubleshooting.vue index de679d63e4a..c86662f2d02 100644 --- a/src_assets/common/assets/web/Troubleshooting.vue +++ b/src_assets/common/assets/web/Troubleshooting.vue @@ -2,6 +2,75 @@

{{ $t('troubleshooting.troubleshooting') }}

+
+
+
+

{{ $t('troubleshooting.permissions_title') }}

+ +
+

{{ $t('troubleshooting.permissions_desc') }}

+ +
+ + + + + + + + + + + + + + + + + +
{{ $t('troubleshooting.permissions_name') }}{{ $t('troubleshooting.permissions_status') }}{{ $t('troubleshooting.permissions_requirement') }} + {{ $t('troubleshooting.permissions_action') }} +
+ {{ $t('troubleshooting.permission_' + permission.id + + (permission.id === 'input' && (platform === 'linux' || platform === 'freebsd') ? '_unix' : '')) }} +

{{ $t('troubleshooting.permission_' + permission.id + '_desc' + + (permission.id === 'input' && (platform === 'linux' || platform === 'freebsd') ? '_unix' : '')) }}

+ + {{ $t('troubleshooting.permission_' + permission.id + '_help' + + (permission.id === 'input' && platform !== 'macos' ? '_' + platform : '')) }} + +
+ + + + + + + {{ $t('troubleshooting.permissions_status_' + permission.status) }} + + + + {{ $t(permission.required ? 'troubleshooting.permissions_required' : 'troubleshooting.permissions_optional') }} + + + + +
+
+
+
@@ -113,8 +182,12 @@ {{ virtualhid.supported_versions }} - - {{ driverStatusText(virtualhid) }} + + + + + {{ driverStatusText(virtualhid) }} @@ -138,8 +211,12 @@ {{ vigembus.supported_versions }} - - {{ driverStatusText(vigembus) }} + + + + + {{ driverStatusText(vigembus) }} @@ -453,6 +530,7 @@ ChevronUp, ChevronsDown, ChevronsUp, + Clock3, Copy, Download, ExternalLink, @@ -479,6 +557,7 @@ ChevronUp, ChevronsDown, ChevronsUp, + Clock3, Copy, Download, ExternalLink, @@ -509,6 +588,10 @@ licenseKey: '', portalResetPressed: false, portalResetStatus: null, + permissions: [], + permissionBusy: '', + permissionError: '', + permissionHelp: '', restartPressed: false, showApplyMessage: false, platform: "", @@ -715,6 +798,7 @@ .then((r) => { this.platform = r.platform; this.gamepadDriver = r.gamepad_driver || ''; + this.refreshPermissions(); // The Windows broker also backs relative mouse input when gamepads are disabled. if (this.platform === 'windows' || this.platform === 'macos') { this.refreshDriverInformation(); @@ -726,6 +810,7 @@ this.logInterval = setInterval(() => { this.refreshLogs(); + if (this.platform) this.refreshPermissions(); }, 5000); this.refreshLogs(); this.refreshClients(); @@ -735,6 +820,34 @@ if (this._logsCopyTimeout) clearTimeout(this._logsCopyTimeout); }, methods: { + /** Refresh the current platform's permission status. */ + async refreshPermissions() { + try { + const response = await fetch('./api/permissions'); + if (!response.ok) throw new Error(this.$t('troubleshooting.permissions_error')); + this.permissions = (await response.json()).permissions || []; + this.permissionError = ''; + } catch (error) { + this.permissionError = error.message; + } + }, + /** Initiate the selected native permission action. */ + async requestPermission(id) { + this.permissionBusy = id; + try { + const response = await apiFetch('./api/permissions/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!response.ok) throw new Error(this.$t('troubleshooting.permissions_error')); + await this.refreshPermissions(); + } catch (error) { + this.permissionError = error.message; + } finally { + this.permissionBusy = ''; + } + }, refreshLogs() { fetch("./api/logs",) .then((r) => r.text()) @@ -1165,11 +1278,30 @@ } return driver.version || this.$t('troubleshooting.driver_version_unknown'); }, + /** + * @brief Choose a visual state for a permission status icon. + * + * @param {string} status Permission status returned by the platform. + * @return {string} Theme-aware status icon class. + */ + permissionStatusClass(status) { + if (status === 'granted') return 'status-icon-success'; + if (status === 'denied') return 'status-icon-danger'; + if (status === 'on_use') return 'status-icon-primary'; + if (status === 'not_determined') return 'status-icon-neutral'; + return 'status-icon-warning'; + }, + /** + * @brief Choose a visual state for a virtual input backend status icon. + * + * @param {object} driver Installed backend status. + * @return {string} Theme-aware status icon class. + */ driverStatusClass(driver) { if (!driver.installed) { - return 'badge text-bg-secondary'; + return 'status-icon-neutral'; } - return driver.version_compatible ? 'badge text-bg-success' : 'badge text-bg-danger'; + return driver.version_compatible ? 'status-icon-success' : 'status-icon-danger'; }, driverStatusText(driver) { if (!driver.installed) { diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 882a29be111..08bf848fe1c 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -476,6 +476,9 @@ "wan_encryption_mode_desc": "This determines when encryption will be used when streaming over the Internet. Encryption can reduce streaming performance, particularly on less powerful hosts and clients." }, "index": { + "permissions_missing_title": "Sunshine needs permissions", + "permissions_missing_desc": "Grant the missing access to enable streaming and input. Sunshine restarts after it detects all required access.", + "permissions_review": "Review Permissions", "description": "Sunshine is a self-hosted game stream host for Moonlight.", "download": "Download", "choose_gamepad_driver": "Choose Gamepad Backend", @@ -590,6 +593,43 @@ "third_party_notice": "Third Party Notice" }, "troubleshooting": { + "permissions_title": "Permissions", + "permissions_desc": "Grant the access needed by your Sunshine configuration. Sunshine restarts after it detects all required access.", + "permissions_refresh": "Refresh Permissions", + "permissions_name": "Permission", + "permissions_requirement": "Requirement", + "permissions_status": "Status", + "permissions_action": "Action", + "permissions_required": "Required", + "permissions_optional": "Optional", + "permissions_request": "Request or Manage Access", + "permissions_instructions": "Show Setup Steps", + "permissions_settings": "Open System Settings", + "permissions_error": "Unable to check or request permission.", + "permissions_status_granted": "Granted", + "permissions_status_denied": "Not granted", + "permissions_status_not_determined": "Not requested", + "permissions_status_on_use": "macOS asks when this feature is used; access cannot be checked here", + "permissions_status_unknown": "Unable to check", + "permission_screen_recording": "Screen Recording", + "permission_screen_recording_desc": "Needed to capture the display for streaming.", + "permission_input": "Keyboard and Mouse Control", + "permission_input_desc": "Needed to send keyboard and mouse events from Moonlight.", + "permission_input_unix": "Virtual Input Devices", + "permission_input_desc_unix": "Needed to send keyboard, mouse, and gamepad events from Moonlight.", + "permission_input_help_linux": "Allow Sunshine's user read and write access to /dev/uinput (or /dev/input/uinput). Check the device's group and udev rules. If you add the user to a group, log out and back in before restarting Sunshine.", + "permission_input_help_freebsd": "Add Sunshine's user to the input group with pw groupmod input -m USER, then log out and back in before restarting Sunshine.", + "permission_config_directory": "Configuration Directory", + "permission_config_directory_desc": "Sunshine needs read and create access to the config directory beside its executable.", + "permission_config_directory_help": "Grant the account running Sunshine permission to list this directory and create files in it. If Sunshine runs as a service, update permissions for the service account.", + "permission_microphone": "Microphone", + "permission_microphone_desc": "Needed only when a custom Audio Sink is configured.", + "permission_system_audio": "System Audio Recording", + "permission_system_audio_desc": "Sunshine asks at startup with a brief unmuted audio tap, or when the first stream starts if startup access was unavailable.", + "permission_local_network": "Local Network", + "permission_local_network_desc": "Bonjour discovery asks for access when Sunshine advertises itself.", + "permission_notifications": "Notifications", + "permission_notifications_desc": "Allows optional menu bar and streaming alerts.", "change_gamepad_driver": "Change Gamepad Backend", "dd_reset": "Reset Persistent Display Device Settings", "dd_reset_desc": "If Sunshine is stuck trying to restore the changed display device settings, you can reset the settings and proceed to restore the display state manually.", diff --git a/src_assets/common/assets/web/sunshine.css b/src_assets/common/assets/web/sunshine.css index ce3680ab70c..107c33c8198 100644 --- a/src_assets/common/assets/web/sunshine.css +++ b/src_assets/common/assets/web/sunshine.css @@ -1634,20 +1634,24 @@ p { white-space: nowrap; } -.driver-table-shell { +.driver-table-shell, +.permission-table-shell { border: 1px solid var(--color-border); border-radius: var(--radius-lg); } -.driver-table-shell .table thead th:first-child { +.driver-table-shell .table thead th:first-child, +.permission-table-shell .table thead th:first-child { border-top-left-radius: var(--radius-lg); } -.driver-table-shell .table thead th:last-child { +.driver-table-shell .table thead th:last-child, +.permission-table-shell .table thead th:last-child { border-top-right-radius: var(--radius-lg); } -.driver-table-shell .table tbody tr:last-child { +.driver-table-shell .table tbody tr:last-child, +.permission-table-shell .table tbody tr:last-child { border-bottom: none; } @@ -1656,6 +1660,73 @@ p { white-space: nowrap; } +.permission-table-shell .table { + min-width: 42rem; +} + +.permission-table-shell .table tbody th { + font-weight: normal; + min-width: 18rem; + padding: var(--spacing-md) var(--spacing-lg); +} + +.permission-detail, +.permission-help { + color: var(--color-text-muted); + font-size: 0.875rem; + line-height: 1.45; + margin-top: var(--spacing-xs); +} + +.permission-help { + color: var(--color-text-base); +} + +.permission-action-column { + text-align: right; + white-space: nowrap; +} + +.status-icon { + align-items: center; + border-radius: 50%; + cursor: help; + display: inline-flex; + height: 2rem; + justify-content: center; + width: 2rem; +} + +.status-icon:hover { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.status-icon-success { + background-color: var(--color-success-light); + color: var(--color-success); +} + +.status-icon-danger { + background-color: var(--color-danger-light); + color: var(--color-danger); +} + +.status-icon-warning { + background-color: var(--color-warning-light); + color: var(--color-warning); +} + +.status-icon-primary { + background-color: var(--color-primary-light); + color: var(--color-primary); +} + +.status-icon-neutral { + background-color: var(--color-bg-subtle); + color: var(--color-text-muted); +} + .driver-release-state { align-items: center; display: flex; diff --git a/tests/unit/platform/macos/test_av_audio.mm b/tests/unit/platform/macos/test_av_audio.mm index 69f7b9c12db..0df9d42604d 100644 --- a/tests/unit/platform/macos/test_av_audio.mm +++ b/tests/unit/platform/macos/test_av_audio.mm @@ -6,13 +6,42 @@ // Only compile these tests on macOS #ifdef __APPLE__ + // test includes #include "../../../tests_common.h" + // platform includes #import #import #import #import + #import + + // local includes #import + #include + +namespace { + bool permission_probe_unmuted = false; ///< Whether the test tap leaves host audio audible. + int permission_probe_result = 0; ///< Simulated Core Audio tap setup result. + + /** + * @brief Replace native tap setup while testing the startup permission probe. + * + * @param audio Temporary capture object created by the permission request. + * @param selector Objective-C selector for the intercepted setup call. + * @param sample_rate Requested capture sample rate. + * @param frame_size Requested audio frame size. + * @param channels Requested channel count. + * @return Simulated tap setup result. + */ + int test_permission_tap_setup(AVAudio *audio, [[maybe_unused]] SEL selector, UInt32 sample_rate, UInt32 frame_size, UInt8 channels) { + permission_probe_unmuted = audio.hostAudioEnabled == YES; + EXPECT_EQ(sample_rate, 48000U); + EXPECT_EQ(frame_size, 512U); + EXPECT_EQ(channels, 2U); + return permission_probe_result; + } +} // namespace /** * @brief Test parameters for processSystemAudioIOProc tests. @@ -112,6 +141,27 @@ EXPECT_TRUE(request_called); } +/** + * @brief Verify the startup probe never mutes host audio and reports tap failure. + */ +TEST(MacosAudioPermissionTest, SystemAudioPermissionProbeUsesUnmutedTemporaryTap) { + Method method = class_getInstanceMethod([AVAudio class], @selector(setupSystemTap:frameSize:channels:)); + ASSERT_NE(method, nullptr); + IMP original = method_setImplementation(method, reinterpret_cast(test_permission_tap_setup)); + + permission_probe_result = 0; + permission_probe_unmuted = false; + EXPECT_TRUE(platf::request_system_audio_permission()); + EXPECT_TRUE(permission_probe_unmuted); + + permission_probe_result = -1; + permission_probe_unmuted = false; + EXPECT_FALSE(platf::request_system_audio_permission()); + EXPECT_TRUE(permission_probe_unmuted); + + method_setImplementation(method, original); +} + /** * @brief Test that setupMicrophone handles nil device input properly. * Verifies the method returns an error code when passed a nil device. diff --git a/tests/unit/platform/macos/test_permissions.cpp b/tests/unit/platform/macos/test_permissions.cpp new file mode 100644 index 00000000000..d011c4b3474 --- /dev/null +++ b/tests/unit/platform/macos/test_permissions.cpp @@ -0,0 +1,46 @@ +/** + * @file tests/unit/platform/macos/test_permissions.cpp + * @brief Tests for macOS startup permission policy. + */ + +#ifdef __APPLE__ + // test includes + #include "../../../tests_common.h" + + // local includes + #include "src/platform/macos/misc.h" + +TEST(MacosPermissionsTest, RequiredPermissionsIgnoreOptionalAndOnUseStatuses) { + using platf::permission_status_t; + EXPECT_TRUE(platf::required_permissions_granted({ + permission_status_t {"screen_recording", "granted", true, true}, + permission_status_t {"system_audio", "on_use", true, false}, + permission_status_t {"local_network", "on_use", true, false}, + permission_status_t {"notifications", "denied", false, true}, + })); + EXPECT_FALSE(platf::required_permissions_granted({ + permission_status_t {"screen_recording", "denied", true, true}, + permission_status_t {"input", "granted", true, true}, + })); + EXPECT_FALSE(platf::required_permissions_granted({ + permission_status_t {"microphone", "not_determined", true, true}, + })); + EXPECT_TRUE(platf::required_permissions_granted({ + permission_status_t {"microphone", "not_determined", false, true}, + })); +} + +TEST(MacosPermissionsTest, StartupRequestsOnlyMissingPermissionsInUse) { + using platf::permission_status_t; + EXPECT_TRUE(platf::should_request_startup_permission({"screen_recording", "denied", true, true}, false)); + EXPECT_TRUE(platf::should_request_startup_permission({"input", "not_determined", true, true}, false)); + EXPECT_TRUE(platf::should_request_startup_permission({"microphone", "not_determined", true, true}, false)); + EXPECT_TRUE(platf::should_request_startup_permission({"notifications", "not_determined", false, true}, true)); + EXPECT_FALSE(platf::should_request_startup_permission({"screen_recording", "granted", true, true}, true)); + EXPECT_FALSE(platf::should_request_startup_permission({"input", "denied", false, true}, true)); + EXPECT_TRUE(platf::should_request_startup_permission({"microphone", "denied", true, true}, true)); + EXPECT_FALSE(platf::should_request_startup_permission({"microphone", "denied", false, true}, true)); + EXPECT_FALSE(platf::should_request_startup_permission({"notifications", "not_determined", false, true}, false)); + EXPECT_FALSE(platf::should_request_startup_permission({"system_audio", "on_use", true, false}, true)); +} +#endif diff --git a/tests/unit/platform/test_permissions.cpp b/tests/unit/platform/test_permissions.cpp new file mode 100644 index 00000000000..3b063bc46e3 --- /dev/null +++ b/tests/unit/platform/test_permissions.cpp @@ -0,0 +1,83 @@ +/** + * @file tests/unit/platform/test_permissions.cpp + * @brief Tests for shared permission policy and directory checks. + */ + +// test includes +#include "../../tests_common.h" + +// standard includes +#include +#include +#include + +// platform includes +#ifndef _WIN32 + #include +#endif + +// local includes +#include "src/platform/common.h" +#include "src/platform/permissions.h" +#if defined(__linux__) || defined(__FreeBSD__) + #include "src/config.h" +#endif + +TEST(PlatformPermissionsTest, RequiredPermissionsIgnoreOptionalAndOnUseStatuses) { + using platf::permission_status_t; + EXPECT_TRUE(platf::required_permissions_granted({ + permission_status_t {"input", "granted", true, true}, + permission_status_t {"system_audio", "on_use", true, false}, + permission_status_t {"notifications", "denied", false, true}, + })); + EXPECT_FALSE(platf::required_permissions_granted({ + permission_status_t {"config_directory", "denied", true, true}, + })); + EXPECT_TRUE(platf::required_permissions_granted({})); +} + +TEST(PlatformPermissionsTest, DirectoryAccessRequiresAnExistingDirectory) { + namespace fs = std::filesystem; + const auto path = fs::current_path() / + std::format("sunshine-permissions-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + ASSERT_TRUE(fs::create_directory(path)); + EXPECT_TRUE(platf::can_access_directory(path)); + EXPECT_FALSE(platf::can_access_directory(path / "missing")); +#ifndef _WIN32 + if (geteuid() != 0) { + fs::permissions(path, fs::perms::owner_read | fs::perms::owner_exec, fs::perm_options::replace); + EXPECT_FALSE(platf::can_access_directory(path)); + fs::permissions(path, fs::perms::owner_all, fs::perm_options::replace); + } +#endif + fs::remove(path); + EXPECT_FALSE(platf::can_access_directory(path)); +} + +#ifdef _WIN32 +TEST(PlatformPermissionsTest, WindowsConfigStatusMatchesDirectoryAccess) { + const auto permissions = platf::get_permission_statuses(); + ASSERT_EQ(permissions.size(), 1U); + EXPECT_EQ(permissions.front().id, "config_directory"); + EXPECT_EQ(permissions.front().status, platf::can_access_directory(platf::appdata()) ? "granted" : "denied"); + EXPECT_TRUE(permissions.front().required); + EXPECT_FALSE(permissions.front().requestable); + EXPECT_FALSE(platf::request_permission("config_directory")); +} +#endif + +#if defined(__linux__) || defined(__FreeBSD__) +TEST(PlatformPermissionsTest, UnixInputStatusMatchesVirtualInputDeviceAccess) { + bool input_access = access("/dev/uinput", R_OK | W_OK) == 0; + #ifndef __FreeBSD__ + input_access = input_access || access("/dev/input/uinput", R_OK | W_OK) == 0; + #endif + const auto permissions = platf::get_permission_statuses(); + ASSERT_EQ(permissions.size(), 1U); + EXPECT_EQ(permissions.front().id, "input"); + EXPECT_EQ(permissions.front().status, input_access ? "granted" : "denied"); + EXPECT_EQ(permissions.front().required, config::input.keyboard || config::input.mouse || config::input.controller); + EXPECT_FALSE(permissions.front().requestable); + EXPECT_FALSE(platf::request_permission("input")); +} +#endif diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp index 1da26db0cb0..944c2e00429 100644 --- a/tests/unit/test_confighttp.cpp +++ b/tests/unit/test_confighttp.cpp @@ -120,6 +120,10 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member license.message = "Test license status"; return lvh::LicenseResult {lvh::OperationStatus::success(), std::move(license)}; }); + confighttp::set_permission_statuses_for_testing(nlohmann::json::array({ + {{"id", "screen_recording"}, {"status", "denied"}, {"required", true}, {"verifiable", true}}, + {{"id", "notifications"}, {"status", "granted"}, {"required", false}, {"verifiable", true}}, + })); // Save current config saved_username = config::sunshine.username; @@ -328,6 +332,8 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member server->resource["^/virtual-input-status-test$"]["GET"] = confighttp::getVirtualInputStatus; server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense; server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense; + server->resource["^/permissions-test$"]["GET"] = confighttp::getPermissions; + server->resource["^/permissions-test$"]["POST"] = confighttp::requestPermission; server->resource["^/pairing-test$"]["DELETE"] = confighttp::cancelPairing; server->resource["^/pairing-test$"]["GET"] = confighttp::getPendingPairings; server->resource["^/pairing-test$"]["POST"] = confighttp::savePin; @@ -366,6 +372,7 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member server_thread.join(); } confighttp::reset_virtual_input_license_status_provider_for_testing(); + confighttp::reset_permission_statuses_for_testing(); confighttp::reset_portal_token_path_provider_for_testing(); config::sunshine.username = saved_username; @@ -1674,6 +1681,43 @@ TEST_F(ConfigHttpTest, VirtualInputStatusReturnsBothBackends) { EXPECT_TRUE(body.at("vigembus").contains("installed")); } +TEST_F(ConfigHttpTest, PermissionsEndpointRequiresAuthenticationAndCsrf) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + EXPECT_EQ(client->request("GET", "/permissions-test", "", headers)->status_code, "401 Unauthorized"); + EXPECT_EQ(client->request("POST", "/permissions-test", R"({"id":"screen_recording"})", headers)->status_code, "401 Unauthorized"); + + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + headers.emplace("Origin", "https://example.invalid"); + const auto response = client->request("POST", "/permissions-test", R"({"id":"screen_recording"})", headers); + EXPECT_EQ(response->status_code, "400 Bad Request"); + EXPECT_TRUE(response->content.string().contains("Missing CSRF token")); +} + +TEST_F(ConfigHttpTest, PermissionsEndpointReturnsRequiredAndOptionalStatuses) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + + const auto response = client->request("GET", "/permissions-test", "", headers); + ASSERT_EQ(response->status_code, "200 OK"); + const auto body = nlohmann::json::parse(response->content.string()); + ASSERT_EQ(body.at("permissions").size(), 2U); + EXPECT_EQ(body.at("permissions").at(0).at("id"), "screen_recording"); + EXPECT_TRUE(body.at("permissions").at(0).at("required").get()); + EXPECT_FALSE(body.at("permissions").at(1).at("required").get()); +} + +TEST_F(ConfigHttpTest, PermissionsEndpointRejectsMalformedAndUnknownRequests) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + headers.emplace("Origin", std::format("https://localhost:{}", port)); + + EXPECT_EQ(client->request("POST", "/permissions-test", "not-json", headers)->status_code, "400 Bad Request"); + EXPECT_EQ(client->request("POST", "/permissions-test", R"({"id":1})", headers)->status_code, "400 Bad Request"); + EXPECT_EQ(client->request("POST", "/permissions-test", R"({"id":"unknown"})", headers)->status_code, "400 Bad Request"); +} + TEST_F(ConfigHttpTest, VirtualInputLicenseReturnsCurrentStatus) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Authorization", create_auth_header("testuser", "testpass")); diff --git a/tests/web/Home.test.js b/tests/web/Home.test.js index 6f75e1128d3..8e7f7e039c5 100644 --- a/tests/web/Home.test.js +++ b/tests/web/Home.test.js @@ -10,7 +10,7 @@ vi.mock('../../src_assets/common/assets/web/ResourceCard.vue', () => ({ import Home from '../../src_assets/common/assets/web/Home.vue' -async function mountHome(platform, { developmentVersion = true, licensed = true, serviceAvailable = true, gamepadDriver = 'virtualhid' } = {}) { +async function mountHome(platform, { developmentVersion = true, licensed = true, serviceAvailable = true, gamepadDriver = 'virtualhid', permissions = [] } = {}) { vi.stubGlobal('fetch', vi.fn(async url => { if (url === './api/config') { return { json: async () => ({ platform, controller: 'enabled', gamepad_driver: gamepadDriver, version: '2026.927.1200' }) } @@ -23,6 +23,9 @@ async function mountHome(platform, { developmentVersion = true, licensed = true, }), } } + if (url === './api/permissions') { + return { json: async () => ({ permissions }) } + } if (url === './api/virtual-input/license') { return { json: async () => ({ licensed, service_available: serviceAvailable }) } } @@ -53,6 +56,25 @@ afterEach(() => { }) describe('development broker home notice', () => { + it('links to permission controls when macOS is missing required access', async () => { + const wrapper = await mountHome('macos', { permissions: [ + { id: 'screen_recording', status: 'denied', required: true, verifiable: true }, + { id: 'notifications', status: 'denied', required: false, verifiable: true }, + { id: 'local_network', status: 'on_use', required: true, verifiable: false }, + ] }) + expect(wrapper.text()).toContain('index.permissions_missing_title') + expect(wrapper.findAll('.alert-warning a, .alert-warning router-link-stub').length).toBeGreaterThan(0) + wrapper.unmount() + }) + + it('links to permission guidance when Windows cannot access its config directory', async () => { + const wrapper = await mountHome('windows', { permissions: [ + { id: 'config_directory', status: 'denied', required: true, verifiable: true }, + ] }) + expect(wrapper.text()).toContain('index.permissions_missing_title') + wrapper.unmount() + }) + it.each(['windows', 'macos'])('shows the development card on %s', async platform => { const wrapper = await mountHome(platform) diff --git a/tests/web/Troubleshooting.test.js b/tests/web/Troubleshooting.test.js index 01d770299f3..79b5b95658e 100644 --- a/tests/web/Troubleshooting.test.js +++ b/tests/web/Troubleshooting.test.js @@ -7,8 +7,8 @@ vi.mock('../../src_assets/common/assets/web/Navbar.vue', () => ({ import Troubleshooting from '../../src_assets/common/assets/web/Troubleshooting.vue' -async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}) { - vi.stubGlobal('fetch', vi.fn(async url => { +async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, permissions = []) { + vi.stubGlobal('fetch', vi.fn(async (url) => { if (url === '/api/config') { return { json: async () => ({ platform, gamepad_driver: gamepadDriver }) } } @@ -28,6 +28,12 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}) if (url === '/api/virtual-input/license') { return { ok: true, json: async () => ({ service_available: true, state: 'licensed', licensed: true, ...licenseStatus }) } } + if (url === './api/permissions') { + return { ok: true, json: async () => ({ permissions }) } + } + if (url === './api/permissions/request') { + return { ok: true, json: async () => ({ status: true }) } + } if (url === './api/logs') { return { text: async () => '' } } @@ -55,12 +61,72 @@ afterEach(() => { }) describe('virtual input troubleshooting', () => { + it('shows macOS permission status and requests access from the row button', async () => { + const wrapper = await mountTroubleshooting('macos', 'all', {}, [ + { id: 'screen_recording', status: 'denied', required: true, requestable: true }, + { id: 'notifications', status: 'granted', required: false }, + { id: 'system_audio', status: 'on_use', required: true, requestable: true }, + ]) + + expect(wrapper.get('#permissions').text()).toBe('troubleshooting.permissions_title') + const card = wrapper.findAll('.card').find(item => item.find('#permissions').exists()) + expect(card.findAll('.permission-table-shell tbody tr')).toHaveLength(3) + expect(card.get('.permission-table-shell thead').text()).toContain('troubleshooting.permissions_requirement') + expect(card.findAll('.permission-table-shell tbody button')).toHaveLength(2) + const screenRow = card.findAll('.permission-table-shell tbody tr').find(row => row.text().includes('permission_screen_recording')) + expect(screenRow.get('.status-icon').classes()).toContain('status-icon-danger') + expect(screenRow.get('.status-icon svg').exists()).toBe(true) + expect(screenRow.get('.status-icon .visually-hidden').text()).toBe('troubleshooting.permissions_status_denied') + expect(screenRow.get('.status-icon').attributes('title')).toBe('troubleshooting.permissions_status_denied') + expect(screenRow.get('.status-icon').attributes('role')).toBeUndefined() + expect(screenRow.get('.status-icon').attributes('tabindex')).toBeUndefined() + const audioRow = card.findAll('.permission-table-shell tbody tr').find(row => row.text().includes('permission_system_audio')) + expect(audioRow.get('.status-icon').classes()).toContain('status-icon-primary') + expect(audioRow.get('.status-icon').attributes('title')).toBe('troubleshooting.permissions_status_on_use') + await screenRow.get('button').trigger('click') + await flushPromises() + expect(fetch).toHaveBeenCalledWith('./api/permissions/request', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ id: 'screen_recording' }), + })) + wrapper.unmount() + }) + + it('shows Windows directory access with setup steps', async () => { + const wrapper = await mountTroubleshooting('windows', 'all', {}, [ + { id: 'config_directory', status: 'denied', required: true, verifiable: true, requestable: false }, + ]) + expect(wrapper.find('#permissions').exists()).toBe(true) + const row = wrapper.get('.permission-table-shell tbody tr') + await row.get('button').trigger('click') + expect(row.find('output').exists()).toBe(true) + expect(row.text()).toContain('troubleshooting.permission_config_directory_help') + expect(fetch).not.toHaveBeenCalledWith('./api/permissions/request', expect.anything()) + wrapper.unmount() + }) + + it('shows Linux input setup steps', async () => { + const wrapper = await mountTroubleshooting('linux', 'none', {}, [ + { id: 'input', status: 'denied', required: true, verifiable: true, requestable: false }, + ]) + await wrapper.get('.permission-table-shell tbody button').trigger('click') + expect(wrapper.text()).toContain('troubleshooting.permission_input_help_linux') + wrapper.unmount() + }) + it('shows the broker version table without ViGEmBus on macOS', async () => { const wrapper = await mountTroubleshooting('macos', 'all') expect(wrapper.find('.virtual-gamepad-section').exists()).toBe(true) expect(wrapper.findAll('.driver-table-shell tbody tr')).toHaveLength(1) expect(wrapper.find('.driver-table-shell').text()).toContain('2026.914.1218') + const status = wrapper.get('.driver-status-icon') + expect(status.classes()).toContain('status-icon-success') + expect(status.get('svg').exists()).toBe(true) + expect(status.get('.visually-hidden').text()).toBe('troubleshooting.driver_status_compatible') + expect(status.attributes('title')).toBe('troubleshooting.driver_status_compatible') + expect(status.attributes('role')).toBeUndefined() + expect(status.attributes('tabindex')).toBeUndefined() expect(fetch).toHaveBeenCalledWith('/api/virtual-input/status') wrapper.unmount() }) @@ -79,6 +145,37 @@ describe('virtual input troubleshooting', () => { const wrapper = await mountTroubleshooting('windows', 'all') expect(wrapper.findAll('.driver-table-shell tbody tr')).toHaveLength(2) + const vigembusStatus = wrapper.get('#vigembus .driver-status-icon') + expect(vigembusStatus.classes()).toContain('status-icon-neutral') + expect(vigembusStatus.attributes('title')).toBe('troubleshooting.driver_status_not_installed') + expect(vigembusStatus.get('.visually-hidden').text()).toBe('troubleshooting.driver_status_not_installed') + wrapper.vm.virtualhid.version_compatible = false + await wrapper.vm.$nextTick() + const unsupportedStatus = wrapper.get('.driver-table-shell tbody tr:first-child .driver-status-icon') + expect(unsupportedStatus.classes()).toContain('status-icon-danger') + expect(unsupportedStatus.attributes('title')).toBe('troubleshooting.driver_status_unsupported') + expect(unsupportedStatus.get('.visually-hidden').text()).toBe('troubleshooting.driver_status_unsupported') + wrapper.unmount() + }) + + it('shows distinct icons for granted, unrequested, and unknown permissions', async () => { + const wrapper = await mountTroubleshooting('macos', 'all', {}, [ + { id: 'notifications', status: 'granted', required: false }, + { id: 'microphone', status: 'not_determined', required: false, requestable: true }, + { id: 'input', status: 'unknown', required: true }, + ]) + + const icons = wrapper.findAll('.permission-table-shell tbody .status-icon') + expect(icons.every(icon => icon.find('svg').exists())).toBe(true) + expect(icons.map(icon => icon.classes().find(name => name.startsWith('status-icon-')))).toEqual([ + 'status-icon-success', 'status-icon-neutral', 'status-icon-warning', + ]) + expect(icons.map(icon => icon.attributes('title'))).toEqual([ + 'troubleshooting.permissions_status_granted', + 'troubleshooting.permissions_status_not_determined', + 'troubleshooting.permissions_status_unknown', + ]) + expect(wrapper.findAll('.permission-table-shell tbody button')).toHaveLength(2) wrapper.unmount() }) }) From 28205dedb8bcf79d0eada8ffed8678314a139162 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:18:29 -0400 Subject: [PATCH 2/3] fix(permissions): stabilize macOS permission requests Persist screen capture and Post Event request history so startup avoids repeating native prompts while still guiding users to Settings after consent is removed. Retry Post Event on demand, open recording settings before probing system audio, and route Local Network to Network Settings on macOS 15 and newer. Propagate tray exit to global shutdown so macOS Quit & Reopen releases Sunshine's ports before relaunch. Update Web UI guidance, documentation, and tests for macOS 14 through 27. --- cmake/targets/macos.cmake | 1 + docs/getting_started.md | 22 ++- src/main.cpp | 10 +- src/platform/macos/misc.h | 45 ++++++ src/platform/macos/misc.mm | 152 +++++++++++++++--- src/platform/permissions.h | 2 + src/system_tray.cpp | 6 + src/system_tray.h | 13 ++ .../common/assets/web/Troubleshooting.vue | 19 ++- .../assets/web/public/assets/locale/en.json | 6 +- .../unit/platform/macos/test_permissions.cpp | 24 +++ tests/unit/test_system_tray.cpp | 6 +- tests/web/Troubleshooting.test.js | 63 +++++++- 13 files changed, 330 insertions(+), 39 deletions(-) diff --git a/cmake/targets/macos.cmake b/cmake/targets/macos.cmake index a170776597d..42bf97a8607 100644 --- a/cmake/targets/macos.cmake +++ b/cmake/targets/macos.cmake @@ -19,6 +19,7 @@ else() COMMENT "Copying bundle resources to build tree" COMMAND "${CMAKE_COMMAND}" -E make_directory "${_bundle_resources_dir}" COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_BINARY_DIR}/assets" "${_bundle_resources_dir}/assets" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${PROJECT_SOURCE_DIR}/src_assets/macos/build/sunshine.icns" "${_bundle_resources_dir}/sunshine.icns" VERBATIM) endif() diff --git a/docs/getting_started.md b/docs/getting_started.md index 537cb99a799..8542b0a3b56 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -568,13 +568,21 @@ It also requests Microphone access if you configured a custom **Audio Sink**, an the system tray is enabled. Open **Troubleshooting > Permissions** in the Web UI to review these permissions or open their System Settings pages. Sunshine restarts once after missing required permissions are granted, including when a permission was removed and later restored. - -macOS requests Local Network access when Sunshine advertises itself with Bonjour. When the other required permissions -are ready, Sunshine briefly starts an unmuted system audio tap to request System Audio Recording access before the -first stream. If that tap cannot start, the first stream can still prompt. macOS does not offer Sunshine a passive status -check for Local Network or System Audio Recording, so the Web UI identifies them as permissions handled when used. -Virtual gamepad access belongs to the separately installed Virtual HID Broker and is described in the macOS gamepad -setup above; Sunshine shows the broker's availability and license under **Troubleshooting > Virtual Gamepad**. +If macOS offers **Quit & Reopen** after you allow Screen Recording, choose it so the running process receives the new +access. Sunshine exits its tray and server threads before macOS reopens it. + +On macOS 15 and newer, macOS requests Local Network access when Sunshine advertises itself with Bonjour. System Audio +Recording access is requested once at startup with a brief audio tap, when the first stream captures audio if startup +access was unavailable, or when you use its Web UI button. The button also opens Screen & System Audio Recording +settings. macOS does not offer Sunshine a passive status check for Local Network +or System Audio Recording, so the Web UI identifies them as permissions handled when used. The Local Network button +opens **Privacy & Security**; choose **Local Network** there to manage app access. +Sunshine uses libvirtualhid to send keyboard and mouse events through CoreGraphics. Virtual gamepads use the separately +installed Virtual HID Broker described in the macOS gamepad setup above. Sunshine shows the broker's availability and +license under **Troubleshooting > Virtual Input**. +If a macOS privacy switch is enabled but Sunshine still reports access denied, remove that Sunshine entry and add the +installed app again. Development builds should use a consistent Apple-issued signing identity so macOS can recognize +the app across updates. Sunshine supports native system audio capture on macOS 14.0 (Sonoma) and newer via Apple’s Audio Tap API. To use it, simply leave the **Audio Sink** setting blank. diff --git a/src/main.cpp b/src/main.cpp index c72387117aa..fb5255488db 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -166,7 +166,7 @@ void mainThreadLoop(const std::shared_ptr> &shutdown_event) // Main thread event loop BOOST_LOG(info) << "Starting main loop"sv; #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 - while (system_tray::process_tray_events() == 0); + system_tray::run_tray_until_exit(shutdown_event); #endif BOOST_LOG(info) << "Main loop has exited"sv; } @@ -523,14 +523,10 @@ int main(int argc, char *argv[]) { } #ifdef __APPLE__ - // Core Audio has no passive system-audio authorization check. A short, - // unmuted tap asks macOS for access before the first stream starts. std::jthread macos_audio_permission_requester; - if (!permission_restart_needed && config::audio.sink.empty()) { + if (!permission_restart_needed) { macos_audio_permission_requester = std::jthread([]() { - if (!platf::request_system_audio_permission()) { - BOOST_LOG(warning) << "System audio recording permission or tap setup is unavailable"sv; - } + platf::request_startup_system_audio_permission(); }); } #endif diff --git a/src/platform/macos/misc.h b/src/platform/macos/misc.h index 2efe8a005e3..c3755d2887f 100644 --- a/src/platform/macos/misc.h +++ b/src/platform/macos/misc.h @@ -11,6 +11,34 @@ #include "src/platform/permissions.h" namespace platf { + /** + * @brief Action to take when a macOS privacy permission is unavailable. + */ + enum class permission_request_action_t { + none, ///< No startup action is needed after an earlier request. + prompt, ///< Ask macOS for the native permission. + settings, ///< Open the permission's settings pane. + }; + + /** + * @brief Choose a privacy action without repeating a macOS startup prompt. + * + * @param granted Whether the native preflight check reports access. + * @param previous_state Zero before a request, one after a request, or two after a grant. + * @param startup Whether this action is part of automatic startup checks. + * @param retry_manual_request Whether a user-initiated action should retry the native request. + * @return The action to perform. + */ + permission_request_action_t permission_request_action(bool granted, int previous_state, bool startup, bool retry_manual_request); + + /** + * @brief Check whether the macOS version has Local Network privacy controls. + * + * @param major_version macOS major version number. + * @return True on macOS 15 and newer. + */ + bool supports_local_network_privacy(int major_version); + /** * @brief Decide whether startup should initiate a native permission request. * @@ -27,6 +55,23 @@ namespace platf { */ void request_startup_permissions(bool notifications_enabled); + /** + * @brief Probe system audio once on startup when no custom sink is configured. + * + * The operating system has no passive tap authorization query. Later streams + * may request access again if the user removes permission. + */ + void request_startup_system_audio_permission(); + + /** + * @brief Decide whether a one-time startup system audio probe is due. + * + * @param has_custom_sink Whether Sunshine uses a custom audio sink. + * @param previously_requested Whether a startup probe was already attempted. + * @return True only for the first native system audio startup probe. + */ + bool should_request_startup_system_audio_permission(bool has_custom_sink, bool previously_requested); + /** * @brief Start and stop an unmuted Core Audio tap to request system audio access. * diff --git a/src/platform/macos/misc.mm b/src/platform/macos/misc.mm index e85bdd24bff..3d9d8e5a03c 100644 --- a/src/platform/macos/misc.mm +++ b/src/platform/macos/misc.mm @@ -71,6 +71,9 @@ namespace { auto screen_capture_allowed = std::atomic {false}; + NSString *const screen_recording_state_key = @"screenRecordingPermissionState"; ///< Last screen recording request state for this user. + NSString *const input_post_event_state_key = @"inputPostEventPermissionState"; ///< Last keyboard and mouse request state for this user. + NSString *const system_audio_requested_key = @"systemAudioStartupRequested"; ///< Whether Sunshine already started a startup tap. /** * @brief Check screen recording without showing a macOS prompt. @@ -122,8 +125,91 @@ bool open_privacy_settings(NSString *pane) { NSString *address = [@"x-apple.systempreferences:com.apple.preference.security?" stringByAppendingString:pane]; return [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:address]] == YES; } + + /** + * @brief Request a CoreGraphics permission or open Settings when it was previously requested. + * + * @param startup Whether Sunshine is performing automatic startup checks. + * @param state_key User defaults key recording this permission's previous state. + * @param granted Whether the native preflight check reports access. + * @param pane Settings pane to open for manual authorization. + * @param request Native function that initiates the initial authorization request. + * @param retry_manual_request Whether a user action should retry the native request and open Settings. + * @return True when access exists or a native action was started. + */ + bool request_coregraphics_permission(bool startup, NSString *state_key, bool granted, NSString *pane, bool (*request)(), bool retry_manual_request) { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + const auto previous_state = static_cast([defaults integerForKey:state_key]); + const auto action = permission_request_action(granted, previous_state, startup, retry_manual_request); + if (granted) { + [defaults setInteger:2 forKey:state_key]; + } + if (action == permission_request_action_t::none) { + return granted; + } + if (action == permission_request_action_t::settings) { + [defaults setInteger:1 forKey:state_key]; + return open_privacy_settings(pane); + } + [defaults setInteger:1 forKey:state_key]; + if (request()) { + [defaults setInteger:2 forKey:state_key]; + if (!startup && retry_manual_request) { + open_privacy_settings(pane); + } + return true; + } + return open_privacy_settings(pane); + } + + /** + * @brief Request screen capture access without repeating an earlier startup prompt. + * + * @param startup Whether this is an automatic startup request. + * @return True when access exists or the request was opened. + */ + bool request_screen_recording(bool startup) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability-new" + return request_coregraphics_permission(startup, screen_recording_state_key, screen_recording_granted(), @"Privacy_ScreenCapture", CGRequestScreenCaptureAccess, false); +#pragma clang diagnostic pop + } + + /** + * @brief Request Post Event access for libvirtualhid keyboard and mouse input. + * + * @param startup Whether this is an automatic startup request. + * @return True when access exists or the request was opened. + */ + bool request_input_post_event(bool startup) { + return request_coregraphics_permission(startup, input_post_event_state_key, CGPreflightPostEventAccess(), @"Privacy_Accessibility", CGRequestPostEventAccess, true); + } } // namespace + permission_request_action_t permission_request_action(bool granted, int previous_state, bool startup, bool retry_manual_request) { + if (granted) { + return permission_request_action_t::none; + } + if (!startup && retry_manual_request) { + return permission_request_action_t::prompt; + } + if (previous_state <= 0) { + return permission_request_action_t::prompt; + } + if (previous_state == 2 || !startup) { + return permission_request_action_t::settings; + } + return permission_request_action_t::none; + } + + bool supports_local_network_privacy(int major_version) { + return major_version >= 15; + } + + bool should_request_startup_system_audio_permission(bool has_custom_sink, bool previously_requested) { + return !has_custom_sink && !previously_requested; + } + std::vector get_permission_statuses() { const auto microphone_authorization = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; std::string microphone_status = "unknown"; @@ -140,14 +226,27 @@ bool open_privacy_settings(NSString *pane) { break; } - return { - {"screen_recording", screen_recording_granted() ? "granted" : "denied", true, true, true}, - {"input", CGPreflightPostEventAccess() ? "granted" : "denied", config::input.keyboard || config::input.mouse, true, true}, + const auto screen_granted = screen_recording_granted(); + const auto input_granted = CGPreflightPostEventAccess(); + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if (screen_granted) { + [defaults setInteger:2 forKey:screen_recording_state_key]; + } + if (input_granted) { + [defaults setInteger:2 forKey:input_post_event_state_key]; + } + + std::vector statuses { + {"screen_recording", screen_granted ? "granted" : "denied", true, true, true}, + {"input", input_granted ? "granted" : "denied", config::input.keyboard || config::input.mouse, true, true}, {"microphone", microphone_status, !config::audio.sink.empty(), true, true}, {"system_audio", "on_use", config::audio.sink.empty(), false, true}, - {"local_network", "on_use", true, false, true}, - {"notifications", notification_status(), false, true, true}, }; + if (supports_local_network_privacy(static_cast([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion))) { + statuses.push_back({"local_network", "on_use", true, false, true}); + } + statuses.push_back({"notifications", notification_status(), false, true, true}); + return statuses; } bool should_request_startup_permission(const permission_status_t &permission, bool notifications_enabled) { @@ -166,19 +265,10 @@ bool should_request_startup_permission(const permission_status_t &permission, bo bool request_permission(std::string_view id) { if (id == "screen_recording") { - if (screen_recording_granted()) { - return true; - } -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunguarded-availability-new" - if (!CGRequestScreenCaptureAccess()) { - return open_privacy_settings(@"Privacy_ScreenCapture"); - } -#pragma clang diagnostic pop - return true; + return request_screen_recording(false); } if (id == "input") { - return CGPreflightPostEventAccess() || CGRequestPostEventAccess() || open_privacy_settings(@"Privacy_Accessibility"); + return request_input_post_event(false); } if (id == "microphone") { const auto authorization = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; @@ -197,10 +287,17 @@ bool request_permission(std::string_view id) { return true; } if (id == "system_audio") { - return request_system_audio_permission() || open_privacy_settings(@"Privacy_ScreenCapture"); + const bool settings_opened = open_privacy_settings(@"Privacy_ScreenCapture"); + if (!request_system_audio_permission()) { + BOOST_LOG(warning) << "System audio recording permission or tap setup is unavailable"sv; + } + return settings_opened; } if (id == "local_network") { - return open_privacy_settings(@"Privacy_LocalNetwork"); + if (!supports_local_network_privacy(static_cast([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion))) { + return false; + } + return [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension"]] == YES; } return false; } @@ -208,11 +305,28 @@ bool request_permission(std::string_view id) { void request_startup_permissions(bool notifications_enabled) { for (const auto &permission : get_permission_statuses()) { if (should_request_startup_permission(permission, notifications_enabled)) { - request_permission(permission.id); + if (permission.id == "screen_recording") { + request_screen_recording(true); + } else if (permission.id == "input") { + request_input_post_event(true); + } else { + request_permission(permission.id); + } } } } + void request_startup_system_audio_permission() { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if (!should_request_startup_system_audio_permission(!config::audio.sink.empty(), [defaults boolForKey:system_audio_requested_key])) { + return; + } + [defaults setBool:YES forKey:system_audio_requested_key]; + if (!request_system_audio_permission()) { + BOOST_LOG(warning) << "System audio recording permission or tap setup is unavailable"sv; + } + } + // Return whether screen capture is allowed for this process. /** * @brief Check whether screen capture allowed. diff --git a/src/platform/permissions.h b/src/platform/permissions.h index 0c27b9f55ce..b6705fd38e8 100644 --- a/src/platform/permissions.h +++ b/src/platform/permissions.h @@ -40,6 +40,8 @@ namespace platf { /** * @brief Initiate a native request or settings action for an access item. * + * On macOS, Local Network opens Privacy & Security, where the user selects Local Network. + * * @param id Stable identifier of the access item. * @return True when the action was recognized and initiated. */ diff --git a/src/system_tray.cpp b/src/system_tray.cpp index 53fc797d391..5dc0f37ec90 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -78,6 +78,7 @@ #include "process.h" #include "src/entry_handler.h" #include "system_tray.h" + #include "thread_safe.h" #ifdef _WIN32 #include "platform/windows/utf_utils.h" #endif @@ -818,6 +819,11 @@ namespace system_tray { return tray_loop(1); } + void run_tray_until_exit(const std::shared_ptr> &shutdown_event) { + while (process_tray_events() == 0); + shutdown_event->raise(true); + } + int end_tray() { auto &worker = tray_worker_thread(); worker.request_stop(); diff --git a/src/system_tray.h b/src/system_tray.h index c1383121d80..e0eaf6e6472 100644 --- a/src/system_tray.h +++ b/src/system_tray.h @@ -5,9 +5,15 @@ #pragma once // standard includes +#include #include #include +namespace safe { + template + class event_t; +} + #if defined(_WIN32) || defined(__APPLE__) namespace lvh { struct LicenseStatus; @@ -86,6 +92,13 @@ namespace system_tray { */ int process_tray_events(); + /** + * @brief Process tray events until exit and notify the application's workers. + * + * @param shutdown_event Event used to stop Sunshine's server threads. + */ + void run_tray_until_exit(const std::shared_ptr> &shutdown_event); + /** * @brief Exit the system tray. * @return 0 after exiting the system tray. diff --git a/src_assets/common/assets/web/Troubleshooting.vue b/src_assets/common/assets/web/Troubleshooting.vue index c86662f2d02..b1ae7ba0b7e 100644 --- a/src_assets/common/assets/web/Troubleshooting.vue +++ b/src_assets/common/assets/web/Troubleshooting.vue @@ -26,7 +26,7 @@ - + {{ $t('troubleshooting.permission_' + permission.id + (permission.id === 'input' && (platform === 'linux' || platform === 'freebsd') ? '_unix' : '')) }} @@ -58,7 +58,7 @@ type="button" :disabled="permissionBusy === permission.id" @click="requestPermission(permission.id)"> {{ $t(permission.id === 'local_network' - ? 'troubleshooting.permissions_settings' : 'troubleshooting.permissions_request') }} + ? 'troubleshooting.permissions_privacy_settings' : 'troubleshooting.permissions_request') }}