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/cmake/targets/macos.cmake b/cmake/targets/macos.cmake index a170776597d..14b798db967 100644 --- a/cmake/targets/macos.cmake +++ b/cmake/targets/macos.cmake @@ -19,6 +19,9 @@ 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/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..8542b0a3b56 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -563,7 +563,26 @@ 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. +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/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..fb5255488db 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" @@ -163,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; } @@ -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,41 @@ int main(int argc, char *argv[]) { #endif } +#ifdef __APPLE__ + std::jthread macos_audio_permission_requester; + if (!permission_restart_needed) { + macos_audio_permission_requester = std::jthread([]() { + platf::request_startup_system_audio_permission(); + }); + } +#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..c3755d2887f 100644 --- a/src/platform/macos/misc.h +++ b/src/platform/macos/misc.h @@ -7,10 +7,78 @@ // standard includes #include -// platform includes -#include +// local includes +#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. + * + * @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 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. + * + * @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..3d9d8e5a03c 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,262 @@ 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. + * + * @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; + } + + /** + * @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"; + switch (microphone_authorization) { + case AVAuthorizationStatusAuthorized: + microphone_status = "granted"; + break; + case AVAuthorizationStatusDenied: + case AVAuthorizationStatusRestricted: + microphone_status = "denied"; + break; + case AVAuthorizationStatusNotDetermined: + microphone_status = "not_determined"; + break; + } + + 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}, + }; + 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) { + 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") { + return request_screen_recording(false); + } + if (id == "input") { + return request_input_post_event(false); + } + 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") { + 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") { + 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; + } + + void request_startup_permissions(bool notifications_enabled) { + for (const auto &permission : get_permission_statuses()) { + if (should_request_startup_permission(permission, notifications_enabled)) { + 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. @@ -74,29 +336,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..b6705fd38e8 --- /dev/null +++ b/src/platform/permissions.h @@ -0,0 +1,57 @@ +/** + * @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. + * + * 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. + */ + 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/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/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..b1ae7ba0b7e 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, @@ -468,6 +546,8 @@ XCircle, } from '@lucide/vue' + const permissionOrder = ['screen_recording', 'input', 'notifications', 'microphone', 'system_audio', 'local_network']; + export default { components: { Navbar, @@ -479,6 +559,7 @@ ChevronUp, ChevronsDown, ChevronsUp, + Clock3, Copy, Download, ExternalLink, @@ -509,6 +590,10 @@ licenseKey: '', portalResetPressed: false, portalResetStatus: null, + permissions: [], + permissionBusy: '', + permissionError: '', + permissionHelp: '', restartPressed: false, showApplyMessage: false, platform: "", @@ -554,6 +639,16 @@ }; }, computed: { + /** Keep known permissions in troubleshooting order on every platform. */ + orderedPermissions() { + return this.permissions.slice().sort((left, right) => { + const leftRank = permissionOrder.indexOf(left.id); + const rightRank = permissionOrder.indexOf(right.id); + return (leftRank < 0 ? permissionOrder.length : leftRank) - + (rightRank < 0 ? permissionOrder.length : rightRank); + }); + }, + showVirtualhid() { return this.gamepadDriver !== 'none' && (this.platform === 'macos' || this.gamepadDriver !== 'vigembus'); }, @@ -715,6 +810,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 +822,7 @@ this.logInterval = setInterval(() => { this.refreshLogs(); + if (this.platform) this.refreshPermissions(); }, 5000); this.refreshLogs(); this.refreshClients(); @@ -735,6 +832,37 @@ 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')); + if (id === 'local_network' || (this.platform === 'macos' && (id === 'screen_recording' || id === 'input'))) { + this.permissionHelp = id; + } + await this.refreshPermissions(); + } catch (error) { + this.permissionError = error.message; + } finally { + this.permissionBusy = ''; + } + }, refreshLogs() { fetch("./api/logs",) .then((r) => r.text()) @@ -1165,11 +1293,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..c2f3db92d20 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,47 @@ "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_privacy_settings": "Open Privacy & Security", + "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_screen_recording_help": "If Sunshine is enabled in Screen & System Audio Recording but still shows Not granted, remove its entry and add the installed Sunshine app again. Development builds need a consistent Apple-issued signing identity to retain access across updates.", + "permission_input": "Keyboard and Mouse Control", + "permission_input_desc": "Needed to send keyboard and mouse events from Moonlight.", + "permission_input_help": "If Sunshine is enabled in Device Control and Data Access but still shows Not granted, remove its entry and add the installed Sunshine app again. Development builds need a consistent Apple-issued signing identity to retain access across updates.", + "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 requests access once at startup or when system audio capture is first used. This button opens recording settings after requesting access.", + "permission_local_network": "Local Network", + "permission_local_network_desc": "Bonjour discovery asks for access when Sunshine advertises itself.", + "permission_local_network_help": "In Privacy & Security, open Local Network to manage Sunshine's access.", + "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..18dfca8cc2b --- /dev/null +++ b/tests/unit/platform/macos/test_permissions.cpp @@ -0,0 +1,70 @@ +/** + * @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)); +} + +TEST(MacosPermissionsTest, CoreGraphicsPromptOnlyOnFirstRequest) { + using platf::permission_request_action_t; + EXPECT_EQ(platf::permission_request_action(true, 0, true, false), permission_request_action_t::none); + EXPECT_EQ(platf::permission_request_action(true, 1, false, true), permission_request_action_t::none); + EXPECT_EQ(platf::permission_request_action(false, 0, true, false), permission_request_action_t::prompt); + EXPECT_EQ(platf::permission_request_action(false, 1, true, true), permission_request_action_t::none); + EXPECT_EQ(platf::permission_request_action(false, 1, false, false), permission_request_action_t::settings); + EXPECT_EQ(platf::permission_request_action(false, 1, false, true), permission_request_action_t::prompt); + EXPECT_EQ(platf::permission_request_action(false, 2, true, false), permission_request_action_t::settings); +} + +TEST(MacosPermissionsTest, LocalNetworkPrivacyStartsWithMacOS15) { + EXPECT_FALSE(platf::supports_local_network_privacy(14)); + EXPECT_TRUE(platf::supports_local_network_privacy(15)); + EXPECT_TRUE(platf::supports_local_network_privacy(27)); +} + +TEST(MacosPermissionsTest, StartupSystemAudioProbeRunsOnlyOnce) { + EXPECT_TRUE(platf::should_request_startup_system_audio_permission(false, false)); + EXPECT_FALSE(platf::should_request_startup_system_audio_permission(false, true)); + EXPECT_FALSE(platf::should_request_startup_system_audio_permission(true, false)); + EXPECT_FALSE(platf::should_request_startup_system_audio_permission(true, 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/unit/test_system_tray.cpp b/tests/unit/test_system_tray.cpp index 8cc3af49772..f0cd8cdcdd7 100644 --- a/tests/unit/test_system_tray.cpp +++ b/tests/unit/test_system_tray.cpp @@ -29,6 +29,7 @@ // local includes #include #include + #include #if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) // lib includes @@ -670,7 +671,10 @@ TEST_F(SystemTrayTest, LifecycleMenuAndStateTransitions) { std::this_thread::sleep_for(100ms); std::ignore = system_tray::end_tray(); }); - EXPECT_NE(system_tray::process_tray_events(), 0); + auto shutdown_event = std::make_shared>(); + EXPECT_FALSE(shutdown_event->peek()); + system_tray::run_tray_until_exit(shutdown_event); + EXPECT_TRUE(shutdown_event->peek()); exit_thread.join(); EXPECT_FALSE(system_tray::tray_initialized_for_testing()); } 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..0aa30c91bb8 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,131 @@ afterEach(() => { }) describe('virtual input troubleshooting', () => { + it('orders permission rows consistently across platforms', async () => { + const permissions = [ + { id: 'local_network', status: 'on_use', required: true }, + { id: 'config_directory', status: 'granted', required: true }, + { id: 'microphone', status: 'granted', required: false }, + { id: 'system_audio', status: 'on_use', required: true }, + { id: 'notifications', status: 'granted', required: false }, + { id: 'input', status: 'granted', required: true }, + { id: 'screen_recording', status: 'granted', required: true }, + ] + for (const platform of ['macos', 'windows']) { + const wrapper = await mountTroubleshooting(platform, 'none', {}, permissions) + expect(wrapper.findAll('.permission-table-shell tbody tr').map(row => row.get('strong').text())).toEqual([ + 'troubleshooting.permission_screen_recording', + 'troubleshooting.permission_input', + 'troubleshooting.permission_notifications', + 'troubleshooting.permission_microphone', + 'troubleshooting.permission_system_audio', + 'troubleshooting.permission_local_network', + 'troubleshooting.permission_config_directory', + ]) + wrapper.unmount() + } + }) + + 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' }), + })) + expect(screenRow.text()).toContain('troubleshooting.permission_screen_recording_help') + wrapper.unmount() + }) + + it('shows macOS input access recovery steps when the request button is used', async () => { + const wrapper = await mountTroubleshooting('macos', 'all', {}, [ + { id: 'input', status: 'denied', required: true, requestable: true }, + ]) + + const row = wrapper.get('.permission-table-shell tbody tr') + await row.get('button').trigger('click') + await flushPromises() + expect(fetch).toHaveBeenCalledWith('./api/permissions/request', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ id: 'input' }), + })) + expect(row.text()).toContain('troubleshooting.permission_input_help') + wrapper.unmount() + }) + + it('shows Local Network setup steps after opening Privacy & Security', async () => { + const wrapper = await mountTroubleshooting('macos', 'all', {}, [ + { id: 'local_network', status: 'on_use', required: true, requestable: true }, + ]) + + const row = wrapper.get('.permission-table-shell tbody tr') + expect(row.get('button').text()).toBe('troubleshooting.permissions_privacy_settings') + await row.get('button').trigger('click') + await flushPromises() + expect(fetch).toHaveBeenCalledWith('./api/permissions/request', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ id: 'local_network' }), + })) + expect(row.text()).toContain('troubleshooting.permission_local_network_help') + 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 +204,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-warning', 'status-icon-success', 'status-icon-neutral', + ]) + expect(icons.map(icon => icon.attributes('title'))).toEqual([ + 'troubleshooting.permissions_status_unknown', + 'troubleshooting.permissions_status_granted', + 'troubleshooting.permissions_status_not_determined', + ]) + expect(wrapper.findAll('.permission-table-shell tbody button')).toHaveLength(2) wrapper.unmount() }) })