diff --git a/.codeql-prebuild-cpp-Windows.sh b/.codeql-prebuild-cpp-Windows.sh index 31ee6cd9..7bee4f65 100644 --- a/.codeql-prebuild-cpp-Windows.sh +++ b/.codeql-prebuild-cpp-Windows.sh @@ -2,43 +2,36 @@ set -e # update pacman -pacman --noconfirm -Suy - -# install wget -pacman --noconfirm -S \ - wget - -# download working curl -wget https://repo.msys2.org/mingw/ucrt64/mingw-w64-ucrt-x86_64-curl-8.8.0-1-any.pkg.tar.zst +pacman --noconfirm -Syu # install dependencies -pacman -U --noconfirm mingw-w64-ucrt-x86_64-curl-8.8.0-1-any.pkg.tar.zst -pacman -Syu --noconfirm --ignore=mingw-w64-ucrt-x86_64-curl \ - base-devel \ - diffutils \ - gcc \ - git \ - make \ - mingw-w64-ucrt-x86_64-cmake \ - mingw-w64-ucrt-x86_64-cppwinrt \ - mingw-w64-ucrt-x86_64-graphviz \ - mingw-w64-ucrt-x86_64-miniupnpc \ - mingw-w64-ucrt-x86_64-nlohmann-json \ - mingw-w64-ucrt-x86_64-nodejs \ - mingw-w64-ucrt-x86_64-nsis \ - mingw-w64-ucrt-x86_64-onevpl \ - mingw-w64-ucrt-x86_64-openssl \ - mingw-w64-ucrt-x86_64-opus \ - mingw-w64-ucrt-x86_64-rust \ - mingw-w64-ucrt-x86_64-toolchain +dependencies=( + "git" + "mingw-w64-ucrt-x86_64-boost" + "mingw-w64-ucrt-x86_64-cmake" + "mingw-w64-ucrt-x86_64-cppwinrt" + "mingw-w64-ucrt-x86_64-curl-winssl" + "mingw-w64-ucrt-x86_64-MinHook" + "mingw-w64-ucrt-x86_64-miniupnpc" + "mingw-w64-ucrt-x86_64-nlohmann-json" + "mingw-w64-ucrt-x86_64-nodejs" + "mingw-w64-ucrt-x86_64-nsis" + "mingw-w64-ucrt-x86_64-onevpl" + "mingw-w64-ucrt-x86_64-openssl" + "mingw-w64-ucrt-x86_64-opus" + "mingw-w64-ucrt-x86_64-toolchain" +) +pacman -S --noconfirm "${dependencies[@]}" # build mkdir -p build -cd build || exit 1 cmake \ + -B build \ + -G Ninja \ + -S . \ -DBUILD_DOCS=OFF \ - -G "MinGW Makefiles" .. -mingw32-make -j"$(nproc)" + -DBUILD_WERROR=ON +ninja -C build # skip autobuild echo "skip_autobuild=true" >> "$GITHUB_OUTPUT" diff --git a/cmake/dependencies/common.cmake b/cmake/dependencies/common.cmake index b6ca8447..8d8eb88f 100644 --- a/cmake/dependencies/common.cmake +++ b/cmake/dependencies/common.cmake @@ -28,7 +28,7 @@ include_directories(SYSTEM ${MINIUPNP_INCLUDE_DIRS}) # ffmpeg pre-compiled binaries if(NOT DEFINED FFMPEG_PREPARED_BINARIES) if(WIN32) - set(FFMPEG_PLATFORM_LIBRARIES mfplat ole32 strmiids mfuuid vpl) + set(FFMPEG_PLATFORM_LIBRARIES mfplat ole32 strmiids mfuuid vpl MinHook) elseif(UNIX AND NOT APPLE) set(FFMPEG_PLATFORM_LIBRARIES numa va va-drm va-x11 X11) endif() diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index f80f9cbd..5c1b77d0 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -11,7 +11,6 @@ install(TARGETS dxgi-info RUNTIME DESTINATION "tools" COMPONENT dxgi) install(TARGETS audio-info RUNTIME DESTINATION "tools" COMPONENT audio) # Mandatory tools -install(TARGETS ddprobe RUNTIME DESTINATION "tools" COMPONENT application) install(TARGETS sunshinesvc RUNTIME DESTINATION "tools" COMPONENT application) # Drivers @@ -68,7 +67,7 @@ set(CPACK_PACKAGE_INSTALL_DIRECTORY "${CPACK_PACKAGE_NAME}") SET(CPACK_NSIS_EXTRA_INSTALL_COMMANDS "${CPACK_NSIS_EXTRA_INSTALL_COMMANDS} IfSilent +2 0 - # ExecShell 'open' 'https://sunshinestream.readthedocs.io/' + # ExecShell 'open' 'https://docs.lizardbyte.dev/projects/sunshine' nsExec::ExecToLog 'icacls \\\"$INSTDIR\\\" /reset' nsExec::ExecToLog '\\\"$INSTDIR\\\\drivers\\\\sudovda\\\\install.bat\\\"' nsExec::ExecToLog '\\\"$INSTDIR\\\\scripts\\\\migrate-config.bat\\\"' @@ -126,7 +125,7 @@ set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL "ON") # set(CPACK_NSIS_CONTACT "${CMAKE_PROJECT_HOMEPAGE_URL}/support") # set(CPACK_NSIS_MENU_LINKS -# "https://sunshinestream.readthedocs.io" "Sunshine documentation" +# "https://docs.lizardbyte.dev/projects/sunshine" "Sunshine documentation" # "https://app.lizardbyte.dev" "LizardByte Web Site" # "https://app.lizardbyte.dev/support" "LizardByte Support") set(CPACK_NSIS_MANIFEST_DPI_AWARE true) diff --git a/cmake/prep/options.cmake b/cmake/prep/options.cmake index e3d0b19f..c7e71dc2 100644 --- a/cmake/prep/options.cmake +++ b/cmake/prep/options.cmake @@ -12,10 +12,6 @@ option(BUILD_TESTS "Build tests" ON) option(NPM_OFFLINE "Use offline npm packages. You must ensure packages are in your npm cache." OFF) option(TESTS_ENABLE_PYTHON_TESTS "Enable Python tests" ON) -# DirectX11 is not available in GitHub runners, so even software encoding fails -set(TESTS_SOFTWARE_ENCODER_UNAVAILABLE "fail" - CACHE STRING "How to handle unavailable software encoders in tests. 'fail/skip'") - option(BUILD_WERROR "Enable -Werror flag." OFF) # if this option is set, the build will exit after configuring special package configuration files diff --git a/docs/Doxyfile b/docs/Doxyfile index 7c08c4f0..5fa4ee6c 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -31,7 +31,7 @@ PROJECT_NAME = Sunshine # project specific settings DOT_GRAPH_MAX_NODES = 60 -IMAGE_PATH = ../docs/images +# IMAGE_PATH = ../docs/images PREDEFINED += SUNSHINE_BUILD_WAYLAND PREDEFINED += SUNSHINE_TRAY=1 @@ -62,4 +62,8 @@ INPUT = ../README.md \ HTML_EXTRA_STYLESHEET += doc-styles.css # extra js +HTML_EXTRA_FILES += api.js HTML_EXTRA_FILES += configuration.js + +# custom aliases +ALIASES += api_examples{3|}="@htmlonly@endhtmlonly" diff --git a/docs/api.js b/docs/api.js new file mode 100644 index 00000000..5f887e94 --- /dev/null +++ b/docs/api.js @@ -0,0 +1,130 @@ +function generateExamples(endpoint, method, body = null) { + let curlBodyString = ''; + let psBodyString = ''; + + if (body) { + const curlJsonString = JSON.stringify(body).replace(/"/g, '\\"'); + curlBodyString = ` -d "${curlJsonString}"`; + psBodyString = `-Body (ConvertTo-Json ${JSON.stringify(body)})`; + } + + return { + cURL: `curl -u user:pass -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`, + Python: `import json +import requests +from requests.auth import HTTPBasicAuth + +requests.${method.trim().toLowerCase()}( + auth=HTTPBasicAuth('user', 'pass'), + url='https://localhost:47990${endpoint.trim()}', + verify=False,${body ? `\n json=${JSON.stringify(body)},` : ''} +).json()`, + JavaScript: `fetch('https://localhost:47990${endpoint.trim()}', { + method: '${method.trim()}', + headers: { + 'Authorization': 'Basic ' + btoa('user:pass'), + 'Content-Type': 'application/json', + }${body ? `,\n body: JSON.stringify(${JSON.stringify(body)}),` : ''} +}) +.then(response => response.json()) +.then(data => console.log(data));`, + PowerShell: `Invoke-RestMethod \` + -SkipCertificateCheck \` + -Uri 'https://localhost:47990${endpoint.trim()}' \` + -Method ${method.trim()} \` + -Headers @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes('user:pass'))} + ${psBodyString}` + }; +} + +function hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +function createTabs(examples) { + const languages = Object.keys(examples); + let tabs = '
'; + let content = '
'; + + languages.forEach((lang, index) => { + const hash = hashString(examples[lang]); + tabs += ``; + content += `
+
+
+ ${examples[lang].split('\n').map(line => `
${line}
`).join('')} +
+ + + + + + +
+
`; + }); + + tabs += '
'; + content += '
'; + + setTimeout(() => { + languages.forEach((lang, index) => { + const hash = hashString(examples[lang]); + const copyButton = document.getElementById(`copy-button-${lang}-${hash}`); + copyButton.addEventListener('click', copyContent); + }); + }, 0); + + return tabs + content; +} + +function copyContent() { + const content = this.previousElementSibling.cloneNode(true); + if (content instanceof Element) { + // filter out line number from file listings + content.querySelectorAll(".lineno, .ttc").forEach((node) => { + node.remove(); + }); + let textContent = Array.from(content.querySelectorAll('.line')) + .map(line => line.innerText) + .join('\n') + .trim(); // Join lines with newline characters and trim leading/trailing whitespace + navigator.clipboard.writeText(textContent); + this.classList.add("success"); + this.innerHTML = ``; + window.setTimeout(() => { + this.classList.remove("success"); + this.innerHTML = ``; + }, 980); + } else { + console.error('Failed to copy: content is not a DOM element'); + } +} + +function openTab(evt, lang) { + const tabcontent = document.getElementsByClassName("tabcontent"); + for (const content of tabcontent) { + content.style.display = "none"; + } + + const tablinks = document.getElementsByClassName("tab-button"); + for (const link of tablinks) { + link.className = link.className.replace(" active", ""); + } + + const selectedTabs = document.querySelectorAll(`#${lang}`); + for (const tab of selectedTabs) { + tab.style.display = "block"; + } + + const selectedButtons = document.querySelectorAll(`.tab-button[onclick*="${lang}"]`); + for (const button of selectedButtons) { + button.className += " active"; + } +} diff --git a/docs/api.md b/docs/api.md index 2c6e6409..e93f500c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,6 +5,10 @@ Sunshine has a RESTful API which can be used to interact with the service. Unless otherwise specified, authentication is required for all API calls. You can authenticate using basic authentication with the admin username and password. +@htmlonly + +@endhtmlonly + ## GET /api/apps @copydoc confighttp::getApps() @@ -14,7 +18,7 @@ basic authentication with the admin username and password. ## POST /api/apps @copydoc confighttp::saveApp() -## DELETE /api/apps{index} +## DELETE /api/apps/{index} @copydoc confighttp::deleteApp() ## POST /api/covers/upload @@ -32,6 +36,9 @@ basic authentication with the admin username and password. ## POST /api/restart @copydoc confighttp::restart() +## POST /api/reset-display-device-persistence +@copydoc confighttp::resetDisplayDevicePersistence() + ## POST /api/password @copydoc confighttp::savePassword() @@ -47,7 +54,7 @@ basic authentication with the admin username and password. ## GET /api/clients/list @copydoc confighttp::listClients() -## GET /api/apps/close +## POST /api/apps/close @copydoc confighttp::closeApp()
diff --git a/docs/app_examples.md b/docs/app_examples.md index 015fb791..0db2ad93 100644 --- a/docs/app_examples.md +++ b/docs/app_examples.md @@ -167,7 +167,7 @@ process is killed.} | Undo | @code{}xrandr --output HDMI-1 --mode 3840x2160 --rate 120@endcode | @hint{The above only works if the xrandr mode already exists. You will need to create new modes to stream to macOS -and iOS devices, since they use non standard resolutions. +and iOS devices, since they use non-standard resolutions. You can update the ``Do`` command to this: ```bash @@ -257,22 +257,10 @@ hard-coding their corresponding number (e.g. ``kscreen-doctor output.HDMI-A1.mod ###### NVIDIA -| Prep Step | Command | -|-----------|-------------------------------------------------------------------------------------------------------------| -| Do | @code{}sh -c "${HOME}/scripts/set-custom-res.sh ${SUNSHINE_CLIENT_WIDTH} ${SUNSHINE_CLIENT_HEIGHT}"@endcode | -| Undo | @code{}sh -c "${HOME}/scripts/set-custom-res.sh 3840 2160"@endcode | - -The ``set-custom-res.sh`` will have this content: -```bash -#!/bin/bash -set -e - -# Get params and set any defaults -width=${1:-1920} -height=${2:-1080} -output=${3:-HDMI-1} -nvidia-settings -a CurrentMetaMode="${output}: nvidia-auto-select { ViewPortIn=${width}x${height}, ViewPortOut=${width}x${height}+0+0 }" -``` +| Prep Step | Command | +|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Do | @code{}sh -c "nvidia-settings -a CurrentMetaMode=\"HDMI-1: nvidia-auto-select { ViewPortIn=${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}, ViewPortOut=${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}+0+0 }\""@endcode | +| Undo | @code{}nvidia-settings -a CurrentMetaMode=\"HDMI-1: nvidia-auto-select { ViewPortIn=3840x2160, ViewPortOut=3840x2160+0+0 }"@endcode | ##### macOS @@ -281,21 +269,23 @@ nvidia-settings -a CurrentMetaMode="${output}: nvidia-auto-select { ViewPortIn=$ This tool can be installed following instructions in their [GitHub repository](https://github.com/jakehilborn/displayplacer)}. -| Prep Step | Command | -|-----------|----------------------------------------------------------------------------------------------------| -| Do | @code{}displayplacer "id: res:1920x1080 hz:60 scaling:on origin:(0,0) degree:0"@endcode | -| Undo | @code{}displayplacer "id: res:3840x2160 hz:120 scaling:on origin:(0,0) degree:0"@endcode | +| Prep Step | Command | +|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Do | @code{}sh -c "displayplacer \"id: res:${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT} hz:${SUNSHINE_CLIENT_FPS} scaling:on origin:(0,0) degree:0\""@endcode | +| Undo | @code{}displayplacer "id: res:3840x2160 hz:120 scaling:on origin:(0,0) degree:0"@endcode | ##### Windows +Sunshine has built-in support for changing the resolution and refresh rate on Windows. If you prefer to use a +third-party tool, you can use *QRes* as an example. ###### QRes @note{This example uses the *QRes* tool to change the resolution and refresh rate. -This tool can be downloaded from their [SourceForge repository](https://sourceforge.net/projects/qres).}. +This tool can be downloaded from their [SourceForge repository](https://sourceforge.net/projects/qres).} -| Prep Step | Command | -|-----------|-------------------------------------------------------------------------------------------------------------------------| -| Do | @code{}cmd /C FullPath\qres.exe /x:%SUNSHINE_CLIENT_WIDTH% /y:%SUNSHINE_CLIENT_HEIGHT% /r:%SUNSHINE_CLIENT_FPS%@endcode | -| Undo | @code{}cmd /C FullPath\qres.exe /x:3840 /y:2160 /r:120@endcode | +| Prep Step | Command | +|-----------|---------------------------------------------------------------------------------------------------------------------------| +| Do | @code{}cmd /C "FullPath\qres.exe /x:%SUNSHINE_CLIENT_WIDTH% /y:%SUNSHINE_CLIENT_HEIGHT% /r:%SUNSHINE_CLIENT_FPS%"@endcode | +| Undo | @code{}FullPath\qres.exe /x:3840 /y:2160 /r:120@endcode | ### Additional Considerations diff --git a/docs/building.md b/docs/building.md index f0a064e6..3c27ac82 100644 --- a/docs/building.md +++ b/docs/building.md @@ -83,13 +83,14 @@ pacman -Syu ##### Install dependencies ```bash dependencies=( - "doxygen" # Optional, for docs "git" "mingw-w64-ucrt-x86_64-boost" # Optional "mingw-w64-ucrt-x86_64-cmake" "mingw-w64-ucrt-x86_64-cppwinrt" - "mingw-w64-ucrt-x86_64-curl" + "mingw-w64-ucrt-x86_64-curl-winssl" + "mingw-w64-ucrt-x86_64-doxygen" # Optional, for docs... better to install official Doxygen "mingw-w64-ucrt-x86_64-graphviz" # Optional, for docs + "mingw-w64-ucrt-x86_64-MinHook" "mingw-w64-ucrt-x86_64-miniupnpc" "mingw-w64-ucrt-x86_64-nlohmann-json" "mingw-w64-ucrt-x86_64-nodejs" diff --git a/docs/configuration.md b/docs/configuration.md index 659e3d29..9a08d0c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,11 @@ editing the `conf` file in a text editor. Use the examples as reference. @endcode - Choices + Choices + bg + Bulgarian + + de German @@ -89,10 +93,22 @@ editing the `conf` file in a text editor. Use the examples as reference. ja Japanese + + ko + Korean + + + pl + Polish + pt Portuguese + + pt_BR + Portuguese (Brazilian) + ru Russian @@ -105,6 +121,10 @@ editing the `conf` file in a text editor. Use the examples as reference. tr Turkish + + uk + Ukranian + zh Chinese (Simplified) @@ -942,6 +962,333 @@ editing the `conf` file in a text editor. Use the examples as reference. +### dd_configuration_option + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform mandatory verification and additional configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}verify_only@endcode
Example@code{} + dd_configuration_option = ensure_only_display + @endcode
ChoicesdisabledPerform no additional configuration (disables all `dd_` configuration options).
verify_onlyVerify that display is active only (this is a mandatory step without any extra steps to verify display state).
ensure_activeActivate the display if it's currently inactive.
ensure_primaryActivate the display if it's currently inactive and make it primary.
ensure_only_displayActivate the display if it's currently inactive and disable all others.
+ +### dd_resolution_option + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional resolution configuration for the display device. + @note{"Optimize game settings" must be enabled in Moonlight for this option to work.} + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_resolution_option = manual + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange resolution to the requested resolution from the client.
manualChange resolution to the user specified one (set via [dd_manual_resolution](#dd_manual_resolution)).
+ +### dd_manual_resolution + + + + + + + + + + + + + + +
Description + Specify manual resolution to be used. + @note{[dd_resolution_option](#dd_resolution_option) must be set to `manual`} + @note{Applies to Windows only.} +
Defaultn/a
Example@code{} + dd_manual_resolution = 1920x1080 + @endcode
+ +### dd_refresh_rate_option + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional refresh rate configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_refresh_rate_option = manual + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange refresh rate to the requested FPS value from the client.
manualChange refresh rate to the user specified one (set via [dd_manual_refresh_rate](#dd_manual_refresh_rate)).
+ +### dd_manual_refresh_rate + + + + + + + + + + + + + + +
Description + Specify manual refresh rate to be used. + @note{[dd_refresh_rate_option](#dd_refresh_rate_option) must be set to `manual`} + @note{Applies to Windows only.} +
Defaultn/a
Example@code{} + dd_manual_resolution = 120 + dd_manual_resolution = 59.95 + @endcode
+ +### dd_hdr_option + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional HDR configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_hdr_option = disabled + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange HDR to the requested state from the client if the display supports it.
+ +### dd_wa_hdr_toggle + + + + + + + + + + + + + + +
Description + When using virtual display device as for streaming, it might display incorrect (high-contrast) color. + With this option enabled, Sunshine will try to mitigate this issue. + @note{This option works independently of [dd_hdr_option](#dd_hdr_option)} + @note{Applies to Windows only.} +
Default@code{} + disabled + @endcode
Example@code{} + dd_wa_hdr_toggle = enabled + @endcode
+ +### dd_config_revert_delay + + + + + + + + + + + + + + +
Description + Additional delay in milliseconds to wait before reverting configuration when the app has been closed or the last session terminated. + Main purpose is to provide a smoother transition when quickly switching between apps. + @note{Applies to Windows only.} +
Default@code{}3000@endcode
Example@code{} + dd_config_revert_delay = 1500 + @endcode
+ +### dd_mode_remapping + + + + + + + + + + + + + + +
Description + Remap the requested resolution and FPS to another display mode.
+ Depending on the [dd_resolution_option](#dd_resolution_option) and + [dd_refresh_rate_option](#dd_refresh_rate_option) values, the following mapping + groups are available: +
    +
  • `mixed` - both options are set to `auto`.
  • +
  • + `resolution_only` - only [dd_resolution_option](#dd_resolution_option) is set to `auto`. +
  • +
  • + `refresh_rate_only` - only [dd_refresh_rate_option](#dd_refresh_rate_option) is set to `auto`. +
  • +
+ For each of those groups, a list of fields can be configured to perform remapping: +
    +
  • + `requested_resolution` - resolution that needs to be matched in order to use this remapping entry. +
  • +
  • `requested_fps` - FPS that needs to be matched in order to use this remapping entry.
  • +
  • `final_resolution` - resolution value to be used if the entry was matched.
  • +
  • `final_refresh_rate` - refresh rate value to be used if the entry was matched.
  • +
+ If `requested_*` field is left empty, it will match everything.
+ If `final_*` field is left empty, the original value will not be remapped and either a requested, manual + or current value is used. However, at least one `final_*` must be set, otherwise the entry is considered + invalid.
+ @note{"Optimize game settings" must be enabled on client side for ANY entry with `resolution` + field to be considered.} + @note{First entry to be matched in the list is the one that will be used.} + @tip{`requested_resolution` and `final_resolution` can be omitted for `refresh_rate_only` group.} + @tip{`requested_fps` and `final_refresh_rate` can be omitted for `resolution_only` group.} + @note{Applies to Windows only.} +
Default@code{} + dd_mode_remapping = { + "mixed": [], + "resolution_only": [], + "refresh_rate_only": [] + } + @endcode +
Example@code{} + dd_mode_remapping = { + "mixed": [ + { + "requested_fps": "60", + "final_refresh_rate": "119.95", + "requested_resolution": "1920x1080", + "final_resolution": "2560x1440" + }, + { + "requested_fps": "60", + "final_refresh_rate": "120", + "requested_resolution": "", + "final_resolution": "" + } + ], + "resolution_only": [ + { + "requested_resolution": "1920x1080", + "final_resolution": "2560x1440" + } + ], + "refresh_rate_only": [ + { + "requested_fps": "60", + "final_refresh_rate": "119.95" + } + ] + }@endcode +
+ ### min_fps_factor diff --git a/docs/contributing.md b/docs/contributing.md index dfc98c3c..57f44c6f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,6 +1,6 @@ # Contributing Read our contribution guide in our organization level -[docs](https://lizardbyte.readthedocs.io/en/latest/developers/contributing.html). +[docs](https://docs.lizardbyte.dev/latest/developers/contributing.html). ## Project Patterns diff --git a/docs/getting_started.md b/docs/getting_started.md index e100d5cc..7d915761 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -271,42 +271,6 @@ brew uninstall sunshine @tip{For beta you can replace `sunshine` with `sunshine-beta` in the above commands.} -#### Portfile -This package requires that you have [MacPorts](https://www.macports.org/install.php) installed. - -##### Install -1. Update the Macports sources. - ```bash - sudo nano /opt/local/etc/macports/sources.conf - ``` - - Add this line, replacing your username, below the line that starts with `rsync`. - ```bash - file:///Users//ports - ``` - - `Ctrl+x`, then `Y` to exit and save changes. - -2. Download and install by running the following commands. - ```bash - mkdir -p ~/ports/multimedia/sunshine - cd ~/ports/multimedia/sunshine - curl -OL https://github.com/LizardByte/Sunshine/releases/latest/download/Portfile - cd ~/ports - portindex - sudo port install sunshine - ``` - -##### Install service (optional) -```bash -sudo port load sunshine -``` - -##### Uninstall -```bash -sudo port uninstall sunshine -``` - ### Windows #### Installer (recommended) @@ -456,7 +420,7 @@ ssh @ 'startx &; export DISPLAY=:0; sunshine' @tip{You could also utilize the `~/.bash_profile` or `~/.bashrc` files to set up the `DISPLAY` variable.} -@seealso{ See [Remote SSH Headless Setup](md_docs_2guides.html#remote-ssh-headless-setup) +@seealso{ See [Remote SSH Headless Setup](https://app.lizardbyte.dev/2023-09-14-remote-ssh-headless-sunshine-setup) on how to set up a headless streaming server without autologin and dummy plugs (X11 + NVidia GPUs)} ### Configuration diff --git a/docs/guides.md b/docs/guides.md index 182399e9..d7271c7e 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -1,1099 +1,9 @@ # Guides -@admonition{Community | This collection of guides is written by the community! -Feel free to contribute your own tips and trips by making a PR.} - - -## Linux - -### Discord call cancellation - -| Author | [RickAndTired](https://github.com/RickAndTired) | -|------------|-------------------------------------------------| -| Difficulty | Easy | - -* Set your normal *Sound Output* volume to 100% - - ![](images/discord_calls_01.png) - -* Start Sunshine - -* Set *Sound Output* to *sink-sunshine-stereo* (if it isn't automatic) - - ![](images/discord_calls_02.png) - -* In Discord, right click *Deafen* and select your normal *Output Device*. - This is also where you will need to adjust output volume for Discord calls - - ![](images/discord_calls_03.png) - -* Open *qpwgraph* - - ![](images/discord_calls_04.png) - -* Connect `sunshine [sunshine-record]` to your normal *Output Device* - * Drag `monitor_FL` to `playback_FL` - * Drag `monitor_FR` to `playback_FR` - - ![](images/discord_calls_05.png) - -### Remote SSH Headless Setup - -| Author | [Eric Dong](https://github.com/e-dong) | -|------------|----------------------------------------| -| Difficulty | Intermediate | - -This is a guide to setup remote SSH into host to startup X server and Sunshine without physical login and dummy plug. -The virtual display is accelerated by the NVidia GPU using the TwinView configuration. - -@attention{This guide is specific for Xorg and NVidia GPUs. I start the X server using the `startx` command. -I also only tested this on an Artix runit init system on LAN. -I didn't have to do anything special with pulseaudio (pipewire untested). - -Pipewire does not seem to work when Sunshine is started over an SSH session. -A workaround to this problem is to kill the Sunshine instance started via SSH, and start a new one -with the permissions of the desktop session. See [Autostart on boot without auto-login](#autostart-on-boot-without-auto-login) - -Keep your monitors plugged in until the [Checkpoint](#checkpoint) step.} - -@tip{Prior to editing any system configurations, you should make a copy of the original file. -This will allow you to use it for reference or revert your changes easily.} - -#### The Big Picture -Once you are done, you will need to perform these 3 steps: - -1. Turn on the host machine -2. Start Sunshine on remote host with a script that: - - * Edits permissions of `/dev/uinput` (added sudo config to execute script with no password prompt) - * Starts X server with `startx` on virtual display - * Starts Sunshine - -3. Startup Moonlight on the client of interest and connect to host - -@hint{As an alternative to SSH... - -**Step 2** can be replaced with autologin and starting Sunshine as a service or putting -`sunshine &` in your `.xinitrc` file if you start your X server with `startx`. -In this case, the workaround for `/dev/uinput` permissions is not needed because the udev rule would be triggered -for "physical" login. See [Linux Setup](md_docs_2getting__started.html#linux). I personally think autologin compromises -the security of the PC, so I went with the remote SSH route. I use the PC more than for gaming, so I don't need a -virtual display everytime I turn on the PC (E.g running updates, config changes, file/media server).} - -First we will [setup the host](#host-setup) and then the [SSH Client](#ssh-client-setup) -(Which may not be the same as the machine running the moonlight client). - -#### Host Setup -We will be setting up: - -1. [Static IP Setup](#static-ip-setup) -2. [SSH Server Setup](#ssh-server-setup) -3. [Virtual Display Setup](#virtual-display-setup) -4. [Uinput Permissions Workaround](#uinput-permissions-workaround) -5. [Stream Launcher Script](#stream-launcher-script) - -#### Static IP Setup -Setup static IP Address for host. For LAN connections you can use DHCP reservation within your assigned range. -e.g. 192.168.x.x. This will allow you to ssh to the host consistently, so the assigned IP address does -not change. It is preferred to set this through your router config. - -#### SSH Server Setup -@note{Most distros have OpenSSH already installed. If it is not present, install OpenSSH using your package manager.} - -@tabs{ - @tab{Debian based | ```bash - sudo apt update - sudo apt install openssh-server - ```} - @tab{Arch based | ```bash - sudo pacman -S openssh - # Install openssh- if you are not using SystemD - # e.g. sudo pacman -S openssh-runit - ```} - @tab{Alpine based | ```bash - sudo apk update - sudo apk add openssh - ```} - @tab{Fedora based (dnf) | ```bash - sudo dnf install openssh-server - ```} - @tab{Fedora based (yum) | ```bash - sudo yum install openssh-server - ```} -} - -Next make sure the OpenSSH daemon is enabled to run when the system starts. - -@tabs{ - @tab{SystemD | ```bash - sudo systemctl enable sshd.service - sudo systemctl start sshd.service # Starts the service now - sudo systemctl status sshd.service # See if the service is running - ```} - @tab{Runit | ```bash - sudo ln -s /etc/runit/sv/sshd /run/runit/service # Enables the OpenSSH daemon to run when system starts - sudo sv start sshd # Starts the service now - sudo sv status sshd # See if the service is running - ```} - @tab{OpenRC | ```bash - rc-update add sshd # Enables service - rc-status # List services to verify sshd is enabled - rc-service sshd start # Starts the service now - ```} -} - -**Disabling PAM in sshd** - -I noticed when the ssh session is disconnected for any reason, `pulseaudio` would disconnect. -This is due to PAM handling sessions. When running `dmesg`, I noticed `elogind` would say removed user session. -In this [Gentoo Forums post](https://forums.gentoo.org/viewtopic-t-1090186-start-0.html), -someone had a similar issue. Starting the X server in the background and exiting out of the console would cause your -session to be removed. - -@caution{According to this [article](https://devicetests.com/ssh-usepam-security-session-status) -disabling PAM increases security, but reduces certain functionality in terms of session handling. -*Do so at your own risk!*} - -Edit the ``sshd_config`` file with the following to disable PAM. - -```txt -usePAM no -``` - -After making changes to the `sshd_config`, restart the sshd service for changes to take effect. - -@tip{Run the command to check the ssh configuration prior to restarting the sshd service. -```bash -sudo sshd -t -f /etc/ssh/sshd_config -``` - -An incorrect configuration will prevent the sshd service from starting, which might mean -losing SSH access to the server.} - -@tabs{ - @tab{SystemD | ```bash - sudo systemctl restart sshd.service - ```} - @tab{Runit | ```bash - sudo sv restart sshd - ```} - @tab{OpenRC | ```bash - sudo rc-service sshd restart - ```} -} - -#### Virtual Display Setup -As an alternative to a dummy dongle, you can use this config to create a virtual display. - -@important{This is only available for NVidia GPUs using Xorg.} - -@hint{Use ``xrandr`` to see name of your active display output. Usually it starts with ``DP`` or ``HDMI``. For me, it is ``DP-0``. -Put this name for the ``ConnectedMonitor`` option under the ``Device`` section. - -```bash -xrandr | grep " connected" | awk '{ print $1 }' -``` -} - -```xorg -Section "ServerLayout" - Identifier "TwinLayout" - Screen 0 "metaScreen" 0 0 -EndSection - -Section "Monitor" - Identifier "Monitor0" - Option "Enable" "true" -EndSection - -Section "Device" - Identifier "Card0" - Driver "nvidia" - VendorName "NVIDIA Corporation" - Option "MetaModes" "1920x1080" - Option "ConnectedMonitor" "DP-0" - Option "ModeValidation" "NoDFPNativeResolutionCheck,NoVirtualSizeCheck,NoMaxPClkCheck,NoHorizSyncCheck,NoVertRefreshCheck,NoWidthAlignmentCheck" -EndSection - -Section "Screen" - Identifier "metaScreen" - Device "Card0" - Monitor "Monitor0" - DefaultDepth 24 - Option "TwinView" "True" - SubSection "Display" - Modes "1920x1080" - EndSubSection -EndSection -``` - -@note{The `ConnectedMonitor` tricks the GPU into thinking a monitor is connected, -even if there is none actually connected! This allows a virtual display to be created that is accelerated with -your GPU! The `ModeValidation` option disables valid resolution checks, so you can choose any -resolution on the host! - -**References** - -* [issue comment on virtual-display-linux](https://github.com/dianariyanto/virtual-display-linux/issues/9#issuecomment-786389065) -* [Nvidia Documentation on Configuring TwinView](https://download.nvidia.com/XFree86/Linux-x86/270.29/README/configtwinview.html) -* [Arch Wiki Nvidia#TwinView](https://wiki.archlinux.org/title/NVIDIA#TwinView) -* [Unix Stack Exchange - How to add virtual display monitor with Nvidia proprietary driver](https://unix.stackexchange.com/questions/559918/how-to-add-virtual-monitor-with-nvidia-proprietary-driver) -} - -#### Uinput Permissions Workaround - -##### Steps -We can use `chown` to change the permissions from a script. Since this requires `sudo`, -we will need to update the sudo configuration to execute this without being prompted for a password. - -1. Create a `sunshine-setup.sh` script to update permissions on `/dev/uinput`. Since we aren't logged into the host, - the udev rule doesn't apply. -2. Update user sudo configuration `/etc/sudoers.d/` to allow the `sunshine-setup.sh` - script to be executed with `sudo`. - -@note{After I setup the :ref:`udev rule ` to get access to `/dev/uinput`, I noticed when I sshed -into the host without physical login, the ACL permissions on `/dev/uinput` were not changed. So I asked -[reddit](https://www.reddit.com/r/linux_gaming/comments/14htuzv/does_sshing_into_host_trigger_udev_rule_on_the). -I discovered that SSH sessions are not the same as a physical login. -I suppose it's not possible for SSH to trigger a udev rule or create a physical login session.} - -##### Setup Script -This script will take care of any preconditions prior to starting up Sunshine. - -Run the following to create a script named something like `sunshine-setup.sh`: - -```bash -echo "chown $(id -un):$(id -gn) /dev/uinput" > sunshine-setup.sh && \ - chmod +x sunshine-setup.sh -``` - -(**Optional**) To Ensure ethernet is being used for streaming, you can block Wi-Fi with `rfkill`. - -Run this command to append the rfkill block command to the script: - -```bash -echo "rfkill block $(rfkill list | grep "Wireless LAN" \ - | sed 's/^\([[:digit:]]\).*/\1/')" >> sunshine-setup.sh -``` - -##### Sudo Configuration -We will manually change the permissions of `/dev/uinput` using `chown`. -You need to use `sudo` to make this change, so add/update the entry in `/etc/sudoers.d/${USER}`. - -@danger{Do so at your own risk! It is more secure to give sudo and no password prompt to a single script, -than a generic executable like chown.} - -@warning{Be very careful of messing this config up. If you make a typo, *YOU LOSE THE ABILITY TO USE SUDO*. -Fortunately, your system is not borked, you will need to login as root to fix the config. -You may want to setup a backup user / SSH into the host as root to fix the config if this happens. -Otherwise, you will need to plug your machine back into a monitor and login as root to fix this. -To enable root login over SSH edit your SSHD config, and add `PermitRootLogin yes`, and restart the SSH server.} - -1. First make a backup of your `/etc/sudoers.d/${USER}` file. - - ```bash - sudo cp /etc/sudoers.d/${USER} /etc/sudoers.d/${USER}.backup - ``` - -2. `cd` to the parent dir of the `sunshine-setup.sh` script and take note of the full filepath. -3. Execute the following to edit your sudoer config file. - -@danger{NEVER modify a file in ``sudoers.d`` directly. Always use the ``visudo`` command. This command checks your changes -before saving the file, and if the resulting changes would break sudo on your system, it will prompt you to fix -them. Modifying the file with nano or vim directly does not give you this sanity check and introduces the -possibility of losing sudo access to your machine. Tread carefully, and make a backup.} - -```bash -sudo visudo /etc/sudoers.d/${USER} -``` - -Copy the below configuration into the text editor. Change `${USER}` wherever it occurs to your username -(e.g. if your username is `sunshineisaawesome` you should change `${USER}` to `sunshineisawesome`) -or modify the path if you placed `sunshine-setup.sh` in a different area. - -``` -${USER} ALL=(ALL:ALL) ALL, NOPASSWD: /home/${USER}/scripts/sunshine-setup.sh -``` - -These changes allow the script to use sudo without being prompted with a password. - -e.g. `sudo $(pwd)/sunshine-setup.sh` - -#### Stream Launcher Script -This is the main entrypoint script that will run the `sunshine-setup.sh` script, start up X server, and Sunshine. -The client will call this script that runs on the host via ssh. - - -##### Sunshine Startup Script -This guide will refer to this script as `~/scripts/sunshine.sh`. -The setup script will be referred as `~/scripts/sunshine-setup.sh`. - -```bash -#!/bin/bash -set -e - -export DISPLAY=:0 - -# Check existing X server -ps -e | grep X >/dev/null -[[ ${?} -ne 0 ]] && { - echo "Starting X server" - startx &>/dev/null & - [[ ${?} -eq 0 ]] && { - echo "X server started successfully" - } || echo "X server failed to start" -} || echo "X server already running" - -# Check if sunshine is already running -ps -e | grep -e .*sunshine$ >/dev/null -[[ ${?} -ne 0 ]] && { - sudo ~/scripts/sunshine-setup.sh - echo "Starting Sunshine!" - sunshine > /dev/null & - [[ ${?} -eq 0 ]] && { - echo "Sunshine started successfully" - } || echo "Sunshine failed to start" -} || echo "Sunshine is already running" - -# Add any other Programs that you want to startup automatically -# e.g. -# steam &> /dev/null & -# firefox &> /dev/null & -# kdeconnect-app &> /dev/null & -``` - -#### SSH Client Setup -We will be setting up: - -1. [SSH Key Authentication Setup](#ssh-key-authentication-setup) -2. [SSH Client Script (Optional)](#ssh-client-script-optional) - -##### SSH Key Authentication Setup -1. Setup your SSH keys with `ssh-keygen` and use `ssh-copy-id` to authorize remote login to your host. - Run `ssh @` to login to your host. - SSH keys automate login so you don't need to input your password! -2. Optionally setup a `~/.ssh/config` file to simplify the `ssh` command - - ```txt - Host - Hostname - User - IdentityFile ~/.ssh/ - ``` - - Now you can use `ssh `. - `ssh ` will execute the command or script on the remote host. - -##### Checkpoint -As a sanity check, let's make sure your setup is working so far! - -###### Test Steps -With your monitor still plugged into your Sunshine host PC: - -1. `ssh ` -2. `~/scripts/sunshine.sh` -3. `nvidia-smi` - - You should see the Sunshine and Xorg processing running: - - ```bash - nvidia-smi - ``` - - *Output:* - ```txt - +---------------------------------------------------------------------------------------+ - | NVIDIA-SMI 535.104.05 Driver Version: 535.104.05 CUDA Version: 12.2 | - |-----------------------------------------+----------------------+----------------------+ - | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | - | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | - | | | MIG M. | - |=========================================+======================+======================| - | 0 NVIDIA GeForce RTX 3070 Off | 00000000:01:00.0 On | N/A | - | 30% 46C P2 45W / 220W | 549MiB / 8192MiB | 2% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - - +---------------------------------------------------------------------------------------+ - | Processes: | - | GPU GI CI PID Type Process name GPU Memory | - | ID ID Usage | - |=======================================================================================| - | 0 N/A N/A 1393 G /usr/lib/Xorg 86MiB | - | 0 N/A N/A 1440 C+G sunshine 293MiB | - +---------------------------------------------------------------------------------------+ - ``` - -4. Check `/dev/uinput` permissions - - ```bash - ls -l /dev/uinput - ``` - - *Output:* - - ```console - crw------- 1 10, 223 Aug 29 17:31 /dev/uinput - ``` - -5. Connect to Sunshine host from a moonlight client - -Now kill X and Sunshine by running `pkill X` on the host, unplug your monitors from your GPU, and repeat steps 1 - 5. -You should get the same result. -With this setup you don't need to modify the Xorg config regardless if monitors are plugged in or not. - -```bash -pkill X -``` - -##### SSH Client Script (Optional) -At this point you have a working setup! For convenience, I created this bash script to automate the -startup of the X server and Sunshine on the host. -This can be run on Unix systems, or on Windows using the `git-bash` or any bash shell. - -For Android/iOS you can install Linux emulators, e.g. `Userland` for Android and `ISH` for iOS. -The neat part is that you can execute one script to launch Sunshine from your phone or tablet! - -```bash -#!/bin/bash -set -e - -ssh_args="@192.168.X.X" # Or use alias set in ~/.ssh/config - -check_ssh(){ - result=1 - # Note this checks infinitely, you could update this to have a max # of retries - while [[ $result -ne 0 ]] - do - echo "checking host..." - ssh $ssh_args "exit 0" 2>/dev/null - result=$? - [[ $result -ne 0 ]] && { - echo "Failed to ssh to $ssh_args, with exit code $result" - } - sleep 3 - done - echo "Host is ready for streaming!" -} - -start_stream(){ - echo "Starting sunshine server on host..." - echo "Start moonlight on your client of choice" - # -f runs ssh in the background - ssh -f $ssh_args "~/scripts/sunshine.sh &" -} - -check_ssh -start_stream -exit_code=${?} - -sleep 3 -exit ${exit_code} -``` - -#### Next Steps -Congratulations, you can now stream your desktop headless! When trying this the first time, -keep your monitors close by incase something isn't working right. - -@seealso{Now that you have a virtual display, you may want to automate changing the resolution -and refresh rate prior to connecting to an app. See -[Changing Resolution and Refresh Rate](md_docs_2app__examples#changing-resolution-and-refresh-rate) -for more information.} - -### Autostart on boot without auto-login - -| Author | [MidwesternRodent](https://github.com/midwesternrodent) | -| ---------- | ------------------------------------------------------- | -| Difficulty | Intermediate | - -After following this guide you will be able to: -1. Turn on the Sunshine host via Moonlight's Wake on LAN (WoL) feature. -2. Have Sunshine initialize to the login screen ready for you to enter your credentials. -3. Login to your desktop session remotely, and have your pipewire audio and Sunshine tray icon work appropriately. - -#### Specifications -This guide was created with the following software on the host: -1. OpenSSH server and client (both on the host machine) -2. Sunshine v2024.1003.1754422 -3. Debian 12 w/ KDE Plasma, SDDM, Wayland (also tested through xorg), and pipewire for audio. - -The host hardware that was used in developing this guide: -1. AMD 7900XTX -2. AMD Ryzen 7 7800X3D -3. 128GB DDR5 RAM -4. 4 displays in total. 2 1080p displays, 1 3440x1440 display, and 1 4k Roku TV which is used as the always-on display -for streaming. (could be subbed with a dummy plug). - -If you have used this guide on any alternative hardware or software (including non-debian based distros) -please, feel free to modify this guide and keep it growing! - -#### Caveats -1. When you login the machine will close your connection and you will have to reconnect. This is necessary due to an -issue similar to why the [Uinput Permissions Workaround](#uinput-permissions-workaround) is needed since SSH -connections are not treated the same as graphical logins. This causes weirdness like sound not working through -pipewire, and the tray icon for Sunshine not appearing. To get around this, we need to close the SSH initiated Sunshine -service, and start a new Sunshine service with the permissions of the graphical desktop. Unfortunately, this closes the -connection and forces you to reconnect through Moonlight. There is no way around this to the best of my knowledge -without enabling auto-login. -3. This guide does not cover using virtual displays. If you are using Nvidia graphics, -see [Remote SSH Headless Setup](#remote-ssh-headless-setup). If you are using AMD hardware, let me know -if you find something or feel free to add it to this guide. -4. I haven't (yet) found a way to disable sleep on the login screen, so if you wait too long after starting your PC, -the display may go to sleep and Moonlight will have trouble connecting. Shutting down and using WoL works great -though. - -@attention{This is definitely safer than enabling auto-login directly, especially for a dual-use PC that is not only -streamed via Moonlight, but is also used as a standard desktop. *However* I do not know the implications of having an -always running SSH client to the localhost on the same machine. It may be possible for someone with significant -knowledge and physical access to the machine to compromise your user account due to this always-running SSH session. -However, that's better than just having the desktop always available, or opening up SSH even just your LAN since this -guide specifically disables non-localhost connections, so I believe this is safer to use than auto-login for general -users. As always, your [threat model](https://en.wikipedia.org/wiki/Threat_model) may vary.} - -#### Prerequisites -In [Remote SSH Headless Setup](#remote-ssh-headless-setup) complete the following sections. - -1. [Static IP Setup](#static-ip-setup) -2. [SSH Server Setup](#ssh-server-setup) -3. [Virtual Display Setup](#virtual-display-setup) -4. [Uinput Permissions Workaround](#uinput-permissions-workaround) -5. [Stream Launcher Script](#stream-launcher-script) - -@note{On a default Debian 12 install using KDE Plasma, you are using the Simple Desktop Display Manager (SDDM). -Even if you are logging in to a Wayland session, SDDM by default starts with an xorg session, so this script -does not need to be modified if you primarily use a Wayland session (the default) when you login.} - -#### Instructions - -##### Enable Wake on LAN - -Wake on LAN (WoL) will allow you to send a magic packet to turn your PC on remotely. This is handled automatically by -Moonlight's "send wake on lan" option in the app but you do need to enable it on your host machine first. The -[instructions on the debian.org](https://wiki.debian.org/WakeOnLan#Enabling_WOL) site are a little hard to parse, so -I've simplified them below. - -@note{This may not work on all deb based distributions. If you know of a better way for POP OS, Ubuntu, or another -debian based distro please feel free to edit the guide yourself, or let me know.} - -First, find the name of your ethernet interface. - -```bash -ip link show -``` - -When I run this command, these are the results I receive -``` -1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 -   link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 -2: enp117s0: mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 -   link/ether 9c:6b:00:59:33:c1 brd ff:ff:ff:ff:ff:ff -``` - -We can ignore the loopback interface, and I can see my ethernet interface is called `enp117s0`. You might see -wireless interfaces here as well but they can also be ignored. - -@note{If your PC is only connected via Wi-Fi, it is still technically possible to get this working, but it is outside -the scope of this guide and requires more networking knowledge and a Wi-Fi chip that supports WoL. If this is your -first foray into linux, I'd recommend just getting a cable.} - -Now I can install ethtool and modify my interface to allow Wake on LAN. For your use, replace `enp117s0` with whatever -the name of your ethernet interface is from the command `ip link show` - -```bash -sudo apt update -sudo apt install ethtool -sudo ethtool -s enp117s0 wol g -``` - -##### SSH Client Setup -To start, we need to install an SSH client (which is different from the *server* in [Remote SSH Headless Setup](#remote-ssh-headless-setup)) -on our machine if this not already done. Open a terminal and enter the following commands. - -```bash -sudo apt update -sudo apt install openssh-client -``` - -Next we need to generate the keys we will use to connect to our SSH session. This is as simple as running the -following in a terminal: - -```bash -ssh-keygen -``` - -and simply pressing enter through the default options. This will place a file called `id_rsa` and `id_rsa.pub` -in the hidden folder `~/.ssh/`. This is the default key used when this user initiates an SSH session. - -Next, we'll copy that public key to the `~/.ssh/authorized_users` file. These are the public keys -allowed to access this machine over SSH, and will allow us to establish an SSH connection with this user -to the SSH server running on the localhost. - -```bash -cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys -``` - -@tip{If you also want any other machines (e.g. a laptop or Steam Deck) to connect to your machine remotely over ssh, -be sure to generate a pubkey on that machine and append it to the authorized_keys file like we did above.} - -###### SSH Server Modifications - -We'll want to make a few modification to the SSH server on the Sunshine host, both for convenience and security. - -Modify `/etc/ssh/sshd_config` with the following changes: - -@tabs{ - @tab{nano | ```bash - sudo nano /etc/ssh/sshd_config - ```} - @tab{vim | ```bash - sudo vi /etc/ssh/sshd_config - ```} -} - -Find the line with `PasswordAuthentication` and make sure it is set to `no` (removed the `#` if present. -Then find the line `PubkeyAuthentication` and make sure it is set to `yes` and remove the `#` from the beginning -if present. When you're done you should have these two lines in your config somewhere. - -``` -PubkeyAuthentication yes -PasswordAuthentication no -``` - -@tip{Using publickey encryption for SSH connections significantly increases your protection against brute force -attacks, and protects you against a rogue machine pretending to be your SSH server and stealing your password.} - -The next step is optional, but if you do not plan on connecting to your computer remotely via ssh and only have -installed SSH for the purposes of using Sunshine, it's a good idea to disable listening for remote SSH connections. -Do this by changing the following lines near the top of your ``sshd_config``: - -``` -#ListenAddress 0.0.0.0 -#ListenAddress :: -``` - -To the following: - -``` -ListenAddress 127.0.0.1 -ListenAddress ::1 -``` - -This will only allow SSH connections coming from your computer itself. - -@tip{on some distributions, the maintainers have added some files in ``/etc/ssh/sshd_config.d/`` which are pulled into -your ``sshd_config``. These modifications can conflict with what we've just done. If you have any files in -``/etc/ssh/sshd_config.d/``, make sure they do not include any of the changes we've just made or you will experience -problems. If they do, you can comment out those lines by placing a ``#`` at their beginning, or delete the files safely -if you don't plan to use SSH for anything other than Sunshine.} - -###### Quick Test and Accept Host Authenticity. - -Next, let's reboot the machine and try to connect! Accept any warnings about the unidentified host at this time, -you'll never see those appear again unless something changes with your setup. - -```bash -ssh $(whoami)@localhost -``` - -You should see a new login prompt for the machine you're already on, and when you type `exit` you should just see - -```bash -logout -Connection to localhost closed. -``` - -##### Run sunshine-setup on boot over SSH - -Thanks to [this comment from Gavin Haynes on Unix Stack exchange](https://unix.stackexchange.com/questions/669389/how-do-i-get-an-ssh-command-to-run-on-boot/669476#669476), -we can establish an SSH connection on boot to run the sunshine-setup script via a systemd service. - -###### Disable default Sunshine services - -These service files are sometimes overwritten when updating Sunshine with the .deb. -So we'll be making new ones and disabling the included service files for our purposes. - -``` -sudo sytstemctl disable sunshine -systemctl --user disable sunshine -``` - -@note{In order to disable the user service, you must be logged in to the graphical desktop environment and run the -command from a GUI terminal. You'll also likely need to approve a polkit request (a graphical popup that asks -for your password). Trying to disable the user service without being signed in to the graphical environment is a -recipe for pain, and is why ``sudo`` is not invoked on the second line in the command above.} - -###### Create the autossh-sunshine-start script - -@tabs{ - @tab{nano | ```bash - sudo nano /usr/local/bin/autossh-sunshine-start - ```} - @tab{vim | ```bash - sudo vi /usr/local/bin/autossh-sunshine-start - ```} -} - -Copy the below script to that location and replace `{USERNAME}` wherever it occurs with the username you created -the SSH public key for in the previous section. - -```bash -#!/bin/bash -ssh -i /home/{USERNAME}/.ssh/id_rsa {USERNAME}@localhost -"/home/{USERNAME}/scripts/sunshine.sh" -``` - -@attention{This script uses the location of the script in [Stream Launcher Script](#stream-launcher-script). -Please complete that section before continuing.} - -Once you've created the script, be sure to make it executable by running: - -```bash -sudo chmod +x /usr/local/bin/autossh-sunshine-start -``` - -###### Create the autossh systemd service file - -@tabs{ - @tab{nano | ```bash - sudo nano /etc/systemd/system/autossh-sunshine.service - ```} - @tab{vim | ```bash - sudo vi /etc/systemd/system/autossh-sunshine.service - ```} -} - -Copy and paste the below systemd file and save it to the location in the commands above. - -``` -[Unit] -Description=Start sunshine over an localhost SSH connection on boot -Requires=sshd.service -After=sshd.service - -[Service] -ExecStartPre=/bin/sleep 5 -ExecStart=/usr/local/bin/autossh-sunshine-start -Restart=on-failure -RestartSec=5s - -[Install] -WantedBy=multi-user.target -``` - -Make it executable, and enable the service when you're done. - -```bash -sudo chmod +x /etc/systemd/system/autossh-sunshine.service -sudo systemctl start autossh-sunshine -sudo systemctl enable autossh-sunshine -``` - -This point is a good time for a sanity check, so restart your PC and try to sign in to your desktop via Moonlight. -You should be able to access the login screen, enter your credentials, and control the user session. At this point -you'll notice the reason for the next section as your audio will be non-functional and you won't see any tray icon -for Sunshine. If you don't care about audio (and maybe a couple other bugs you might encounter from time to time due -to the permissions difference between an SSH session and the desktop session), you can consider yourself finished at -this point! - -@note{You might also notice some issues if you have multiple monitors setup (including the dummy plug), like the mouse -cursor not being on the right screen for you to login. We will address this in the last step of this guide. It requires -messing with some configs for SDDM.} - -##### Getting the audio working - -To get the audio (and tray icon, etc...) working we will create a systemd user service, that will start on a graphical -login, kill the autossh-sunshine system service, and start Sunshine just like the standard Sunshine service. -This service will also need to call the autossh-sunshine system service before it is stopped as the user service will -be killed when we log out of the graphical session, so we want to make sure we restart that SSH service so we don't -lose the ability to log back in if we need to. - -@tabs{ - @tab{nano | ```bash - sudo nano /usr/lib/systemd/user/sunshine-after-login.service - ```} - @tab{vim | ```bash - sudo vi /usr/lib/systemd/user/sunshine-after-login.service - ```} -} - -Once again, copy the below service file into your text editor at the location above. - -``` -[Unit] -Description=Start Sunshine with the permissions of the graphical desktop session -StartLimitIntervalSec=500 -StartLimitBurst=5 - -[Service] -# Avoid starting Sunshine before the desktop is fully initialized. -ExecStartPre=/usr/bin/pkill sunshine -ExecStartPre=/bin/sleep 5 -ExecStart=/usr/bin/sunshine -ExecStopPost=/usr/bin/systemctl start autossh-sunshine - -Restart=on-failure -RestartSec=5s - -[Install] -WantedBy=xdg-desktop-autostart.target -``` - -Make it executable, and enable it. - -```bash -sudo chmod +x /usr/lib/systemd/user/sunshine-after-login.service -systemctl --user enable sunshine-after-login -``` - -###### Polkit Rules for Sunshine User Service - -Since this is being run with the permissions of the graphical session, we need to make a polkit modification to allow -it to call the system service autossh-sunshine when this user service is stopped, without prompting us for a password. - -@tabs{ - @tab{nano | ```bash - sudo nano /etc/polkit-1/rules.d/sunshine.rules - ```} - @tab{vim | ```bash - sudo vi /etc/polkit-1/rules.d/sunshine.rules - ```} -} - -Once again, copy the below to the .rules file in your text editor. - -```js -polkit.addRule(function(action, subject) { -   if (action.id == "org.freedesktop.systemd1.manage-units" && -       action.lookup("unit") == "autossh-sunshine.service") -   { -       return polkit.Result.YES; -   } -}) -``` - -###### Modifications to sudoers.d files - -Lastly, we need to make a few modifications to the sudoers file for our users. Replace {USERNAME} below with your -username. You will be prompted to select either vi or nano for your editor if you've not used this command before, -choose whichever you prefer. - -``` -sudo visudo /etc/sudoers.d/{USERNAME} -``` - -@danger{NEVER modify a file in ``sudoers.d`` directly. Always use the ``visudo`` command. This command checks your changes -before saving the file, and if the resulting changes would break sudo on your system, it will prompt you to fix -them. Modifying the file with nano or vim directly does not give you this sanity check and introduces the -possibility of losing sudo access to your machine. Tread carefully, and make a backup.} - -As always, copy and paste the below into your user's `sudoers.d` configuration. Replace {USERNAME} with your username, -and {HOSTNAME} with the name of your computer. - -``` -{USERNAME} {HOSTNAME} = (root) NOPASSWD: /home/{USERNAME}/scripts/sunshine-setup.sh -{USERNAME} {HOSTNAME} = (root) NOPASSWD: /bin/sunshine -{USERNAME} {HOSTNAME} = (root) NOPASSWD: /usr/bin/systemctl start autossh-sunshine -{USERNAME} {HOSTNAME} = (root) NOPASSWD: /usr/bin/systemctl --user start sunshine-after-login -# The below is optional, but will allow us to send trigger a shutdown with a sunshine prep command, if desired. -{USERNAME} {HOSTNAME} = (root) NOPASSWD: /usr/sbin/shutdown -``` - -Once again, restart your computer and do a quick test. Make sure you can connect to the PC to login and enter your -credentials. You should be booted out of the system, and then can reconnect a few seconds later to the logged-in -desktop session. You should see a tray icon for Sunshine, and the sound should be working (or you may need to manually -select the sunshine-sink at least the first time). - -If you don't have multiple monitors, at this point you can consider yourself done! - -##### Configuring the login screen layout for multiple monitors - -This is not Sunshine specific, but is a frequent problem I had setting up Sunshine and thought it pertinent to add to -the guide. If you are using multiple monitors (even a single monitor with a dummy plug may have this problem) you -might notice the streamed login screen has one or more of the following problems: - -1. The text is way too small to see (caused by a too-high resolution) -2. The mouse cursor is off on some other screen (caused by not mirroring the displays) -3. There are multiple login screens overlapping each other (caused by differing resolutions, and trying to mirror -the display). - -###### Log in to an X11 Session - -This can be fixed, by modifying some scripts called by SDDM on boot. To start though, we need to make sure we're -logged into an x11 session, not Wayland or the terminal. As the Wayland session will give us incorrect information, -and the terminal will give us no information since no graphical environment exists. SDDM initially starts an x11 -session to display the login screen so we need to use xorg commands to change the display configuration. - -To do this, log out of your desktop session on the Sunshine host, and somewhere on the lower left of your screen -(depending on your SDDM theme) there will be some text that on Debian 12 KDE Plasma defaults to saying -`Session: Plasma (Wayland)`. Select this and choose `Plasma (X11)` from the drop down menu and sign in. - -###### Find your monitor identifiers. - -Open a terminal and run: - -```bash -xrandr | grep -w connected -``` - -This will require some more sleuthing on your part. Different PC hardware, and different monitors / connectors, -display the names differently. Some start at 0, some start 1. Some spell out "DisplayPort" some, say "DP". You will -need to modify all of the commands from here on out based on the output of the above command. I will use the output I -receive below as the example for the rest of this guide. - -```bash -DisplayPort-0 connected (normal left inverted right x axis y axis) -DisplayPort-1 connected (normal left inverted right x axis y axis) -DisplayPort-2 connected (normal left inverted right x axis y axis) -HDMI-A-0 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 800mm x 450mm -``` - -@note{If I instead run this command on Wayland, I get the following useless output. Hence the need to sign in to an -x11 session. - -```bash -XWAYLAND0 connected 2592x1458+6031+0 (normal left inverted right x axis y axis) 600mm x 340mm -XWAYLAND1 connected 2592x1458+0+0 (normal left inverted right x axis y axis) 480mm x 270mm -XWAYLAND2 connected primary 3440x1440+2592+0 (normal left inverted right x axis y axis) 800mm x 330mm -XWAYLAND3 connected 2592x1458+0+0 (normal left inverted right x axis y axis) 1440mm x 810mm - -``` -} - - -From this, you can see that my monitors are named the following under an x11 session. - -DisplayPort-0 -DisplayPort-1 -DisplayPort-2 -HDMI-A-0 - -@tip{If you have a label maker, now would be a good time to unplug some cables, determine where they are on your -system, and label the outputs on your graphics card to ease changing your setup in the future.} - -In my setup, after moving some inputs I changed my system so that these cables correspond to the below monitors - -| Display Name | Monitor | -| ------------- | --------------------------- | -| DisplayPort-0 | rightmost 1080p display | -| DisplayPort-1 | leftmost 1080p display | -| DisplayPort-2 | middle 3440x1440 display | -| HDMI-A-0 | 4k Roku TV (and dummy plug) | - -###### Modify the SDDM startup script - -For my purposes, I would prefer to have the Roku TV (which doubles as my always-on dummy plug) to always display a -1080p screen on login (this can be changed automatically after login). And I would like to retain the ability to use -my leftmost monitor to login to my physical desktop, but I'd like to disable my primary and rightmost displays. - -To do this, we need to modify the SDDM startup script to shut off DisplayPort-1 and DisplayPort-2, set HDMI-A-0 to -1080p and mirror it with DisplayPort-1. - -@tabs{ - @tab{nano | ```bash - sudo nano /usr/share/sddm/scripts/Xsetup - ```} - @tab{vim | ```bash - sudo vi /usr/share/sddm/scripts/Xsetup - ```} -} - -Which will open a script that looks like this. We will not be removing these lines. - -```bash -#!/bin/sh -# Xsetup - run as root before the login dialog appears - -if [ -e /sbin/prime-offload ]; then -   echo running NVIDIA Prime setup /sbin/prime-offload -   /sbin/prime-offload -fi -``` - -At the bottom of this Xsetup script though, we can add some xrandr commands - -To shut a display off, we can use: `xrandr --output {DISPLAYNAME} --off`. - -To set a display as the primary and accept -it's automatic (usually the maximum, but not always especially on TVs where the default refresh rate may be lower) -resolution and refresh rate we can use: `xrandr --output {DISPLAYNAME} --auto --primary`. - -To set a display to a specific resolution we can use: `xrandr --output {DISPLAYNAME} --mode {PIXELWIDTH}x{PIXELLENGTH}`. - -And lastly, to mirror a display we can use: `xrandr --output {DISPLAYNAME} --same-as {ANOTHER-DISPLAY}` - -So with my desire to mirror my TV and left displays, my Xsetup script now looks like this: - -```bash -#!/bin/sh -# Xsetup - run as root before the login dialog appears - -if [ -e /sbin/prime-offload ]; then -   echo running NVIDIA Prime setup /sbin/prime-offload -   /sbin/prime-offload -fi - -xrandr --output DisplayPort-0 --off -xrandr --output DisplayPort-2 --off -xrandr --output DisplayPort-1 --auto --primary -xrandr --output HDMI-A-0 --mode 1920x1080 -xrandr --output HDMI-A-0 --same-as DisplayPort-1 -``` - -Save this file, reboot, and you should see your login screen now respects these settings. Make sure when you log -back in, you select a Wayland session (if that is your preferred session manager). - -#### Next Steps - -Congratulations! You now have Sunshine starting on boot, you can login to your session remotely, you get all the -benefits of the graphical session permissions, and you can safely shut down your PC with the confidence you can -turn it back on when needed. - -@seealso{As Eric Dong recommended, I'll also send you to automate changing your displays. -You can add multiple commands, to turn off, or configure as many displays as you'd like with the sunshine prep -commands. See [Changing Resolution and Refresh Rate](md_docs_2app__examples#changing-resolution-and-refresh-rate) -for more information and remember that the display names for your prep commands, may be different than what you -found for SDDM.} - - -## macOS -@todo{It's looking lonely here.} - - -## Windows - -| Author | [BeeLeDev](https://github.com/BeeLeDev) | -|------------|-----------------------------------------| -| Difficulty | Intermediate | - -### Discord call cancellation -Cancel Discord call audio with Voicemeeter (Standard) - -#### Voicemeeter Configuration -1. Click "Hardware Out" -2. Set the physical device you receive audio to as your Hardware Out with MME -3. Turn on BUS A for the Virtual Input - -#### Windows Configuration -1. Open the sound settings -2. Set your default Playback as Voicemeeter Input - -@tip{Run audio in the background to find the device that your Virtual Input is using -(Voicemeeter In #), you will see the bar to the right of the device have green bars -going up and down. This device will be referred to as Voicemeeter Input.} - -#### Discord Configuration -1. Open the settings -2. Go to Voice & Video -3. Set your Output Device as the physical device you receive audio to - -@tip{It is usually the same device you set for Hardware Out in Voicemeeter.} - -#### Sunshine Configuration -1. Go to Configuration -2. Go to the Audio/Video tab -3. Set Virtual Sink as Voicemeeter Input - -@note{This should be the device you set as default previously in Playback.} +@admonition{Community | A collection of guides written by the community is available on our +[blog](https://lizardbyte.com/blog). +Feel free to contribute your own tips and trips by making a PR to +[LizardByte.github.io](https://github.com/LizardByte/LizardByte.github.io).}
diff --git a/docs/images/discord_calls_01.png b/docs/images/discord_calls_01.png deleted file mode 100644 index d26e62a8..00000000 Binary files a/docs/images/discord_calls_01.png and /dev/null differ diff --git a/docs/images/discord_calls_02.png b/docs/images/discord_calls_02.png deleted file mode 100644 index 6a739be7..00000000 Binary files a/docs/images/discord_calls_02.png and /dev/null differ diff --git a/docs/images/discord_calls_03.png b/docs/images/discord_calls_03.png deleted file mode 100644 index 0dd34500..00000000 Binary files a/docs/images/discord_calls_03.png and /dev/null differ diff --git a/docs/images/discord_calls_04.png b/docs/images/discord_calls_04.png deleted file mode 100644 index ec38513e..00000000 Binary files a/docs/images/discord_calls_04.png and /dev/null differ diff --git a/docs/images/discord_calls_05.png b/docs/images/discord_calls_05.png deleted file mode 100644 index efb4e2be..00000000 Binary files a/docs/images/discord_calls_05.png and /dev/null differ diff --git a/gh-pages-template/_config.yml b/gh-pages-template/_config.yml new file mode 100644 index 00000000..17e32774 --- /dev/null +++ b/gh-pages-template/_config.yml @@ -0,0 +1,4 @@ +--- +# See https://github.com/daattali/beautiful-jekyll/blob/master/_config.yml for documented options + +avatar: "/Sunshine/assets/img/navbar-avatar.png" diff --git a/gh-pages-template/assets/img/banners/AdobeStock_231616343.jpeg b/gh-pages-template/assets/img/banners/AdobeStock_231616343.jpeg new file mode 100644 index 00000000..818e82d2 Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_231616343.jpeg differ diff --git a/gh-pages-template/assets/img/banners/AdobeStock_231616343_1920x1280.jpg b/gh-pages-template/assets/img/banners/AdobeStock_231616343_1920x1280.jpg new file mode 100644 index 00000000..d235cd5f Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_231616343_1920x1280.jpg differ diff --git a/gh-pages-template/assets/img/banners/AdobeStock_303330124.jpeg b/gh-pages-template/assets/img/banners/AdobeStock_303330124.jpeg new file mode 100644 index 00000000..4a5ab376 Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_303330124.jpeg differ diff --git a/gh-pages-template/assets/img/banners/AdobeStock_303330124_1920x1280.jpg b/gh-pages-template/assets/img/banners/AdobeStock_303330124_1920x1280.jpg new file mode 100644 index 00000000..8e047dc3 Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_303330124_1920x1280.jpg differ diff --git a/gh-pages-template/assets/img/banners/AdobeStock_305732536.jpeg b/gh-pages-template/assets/img/banners/AdobeStock_305732536.jpeg new file mode 100644 index 00000000..d79c40a2 Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_305732536.jpeg differ diff --git a/gh-pages-template/assets/img/banners/AdobeStock_305732536_1920x1280.jpg b/gh-pages-template/assets/img/banners/AdobeStock_305732536_1920x1280.jpg new file mode 100644 index 00000000..bdde5c9f Binary files /dev/null and b/gh-pages-template/assets/img/banners/AdobeStock_305732536_1920x1280.jpg differ diff --git a/gh-pages-template/assets/img/navbar-avatar.png b/gh-pages-template/assets/img/navbar-avatar.png new file mode 100644 index 00000000..0a44d94a Binary files /dev/null and b/gh-pages-template/assets/img/navbar-avatar.png differ diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html new file mode 100644 index 00000000..5d6ebd32 --- /dev/null +++ b/gh-pages-template/index.html @@ -0,0 +1,614 @@ +--- +title: Sunshine +subtitle: A LizardByte project +layout: page +full-width: true +after-content: +- donate.html +- support.html +cover-img: +- /Sunshine/assets/img/banners/AdobeStock_305732536_1920x1280.jpg +- /Sunshine/assets/img/banners/AdobeStock_231616343_1920x1280.jpg +- /Sunshine/assets/img/banners/AdobeStock_303330124_1920x1280.jpg +ext-js: +- https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js +--- + + +
+
+

+ Sunshine is a self-hosted game stream host for Moonlight. Offering low latency, cloud gaming + server capabilities with support for AMD, Intel, and Nvidia GPUs for hardware encoding. Software + encoding is also available. You can connect to Sunshine from any Moonlight client on a variety + of devices. A web UI is provided to allow configuration, and client pairing, from your favorite + web browser. Pair from the local server or any mobile device. +

+
+
+ + +
+
+

Features

+ +
+
+
+
+
+
+ +
+
+
Self-hosted
+

+ Run Sunshine on your own hardware. No need to pay monthly fees to a + cloud gaming provider. +

+
+
+
+
+
+
+
+
+
+
+ Moonlight +
+
+
Moonlight Support
+

+ Connect to Sunshine from any Moonlight client. Moonlight is available + for Windows, macOS, Linux, Android, iOS, Xbox, and more. See + clients for more information. +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
Hardware Encoding
+

+ Sunshine supports AMD, Intel, and Nvidia GPUs for hardware encoding. + Software encoding is also available. +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
Low Latency
+

+ Sunshine is designed to provide the lowest latency possible to achieve optimal gaming performance. +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
Control
+

+ Sunshine emulates an Xbox, PlayStation, or Nintendo Switch controller. + Use nearly any controller on your Moonlight client!
+ +

    +
  • Nintendo Switch emulation is only available on Linux.
  • +
  • Gamepad emulation is not currently supported on macOS.
  • +
+ +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
Configurable
+

+ Sunshine offers many configuration options to customize your experience. +

+
+
+
+
+
+
+
+
+ + +
+
+

Clients

+ +
+ + +
+
+
+
+
+ +
+ +
+ Official +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Official +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+
+
+ + iOS + +
+
+
+ Official +
+
+
+ +
+
+ + +
+
+
+
+
+ + + + +
+
+
+ + QT + +
+
+
+ Official +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Official +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Community +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Community +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Community +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Community +
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ Community +
+
+
+ +
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+ +
+

Documentation

+

+ Read the documentation to learn how to install, use, and configure Sunshine. +

+
+
+
+ +
+
+ + +
+
+
+
+ +
+

Download

+

+ Download Sunshine for your platform. +

+
+
+
+ +
+
+
+
+ + + diff --git a/package.json b/package.json index 0775f146..666acfcd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dependencies": { "@lizardbyte/shared-web": "2024.921.191855", "vue": "3.5.13", - "vue-i18n": "9.14.0" + "vue-i18n": "11.0.1" }, "devDependencies": { "@vitejs/plugin-vue": "4.6.2", diff --git a/packaging/linux/flatpak/README.md b/packaging/linux/flatpak/README.md index 9b358b79..a503a346 100644 --- a/packaging/linux/flatpak/README.md +++ b/packaging/linux/flatpak/README.md @@ -3,7 +3,7 @@ [![Flathub installs](https://img.shields.io/flathub/downloads/dev.lizardbyte.app.Sunshine?style=for-the-badge&logo=flathub)](https://flathub.org/apps/dev.lizardbyte.app.Sunshine) [![Flathub Version](https://img.shields.io/flathub/v/dev.lizardbyte.app.Sunshine?style=for-the-badge&logo=flathub)](https://flathub.org/apps/dev.lizardbyte.app.Sunshine) -LizardByte has the full documentation hosted on [Read the Docs](https://sunshinestream.readthedocs.io). +LizardByte has the full documentation hosted on [Read the Docs](https://docs.lizardbyte.dev/projects/sunshine). ## About diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.metainfo.xml b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.metainfo.xml index 43c4cbdf..6ccadf74 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.metainfo.xml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.metainfo.xml @@ -42,7 +42,7 @@ LizardByte - https://app.lizardbyte.dev/Sunshine/assets/images/AdobeStock_305732536_1920x1280.jpg + https://app.lizardbyte.dev/Sunshine/assets/img/banners/AdobeStock_305732536_1920x1280.jpg
diff --git a/packaging/macos/Portfile b/packaging/macos/Portfile deleted file mode 100644 index 678f742a..00000000 --- a/packaging/macos/Portfile +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8; mode: tcl; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- vim:fenc=utf-8:ft=tcl:et:sw=4:ts=4:sts=4 - -# initial PR into macports: https://github.com/macports/macports-ports/pull/15143 - -PortSystem 1.0 -PortGroup cmake 1.1 -PortGroup github 1.0 - -name @PROJECT_NAME@ -version @PROJECT_VERSION@ -revision 0 -categories multimedia emulators games -platforms darwin -license GPL-3 -maintainers @LizardByte -description @PROJECT_DESCRIPTION@ - -# long_description will not be split into multiple lines as it's configured by CMakeLists -long_description @PROJECT_LONG_DESCRIPTION@ -homepage @PROJECT_HOMEPAGE_URL@ -master_sites https://github.com/lizardbyte/sunshine/releases - -compiler.cxx_standard 2017 -fetch.type git - -git.url @GITHUB_CLONE_URL@ -git.branch @GITHUB_COMMIT@ - -post-fetch { - system -W ${worksrcpath} "${git.cmd} submodule update --init --recursive" -} - -# https://guide.macports.org/chunked/reference.dependencies.html -depends_build-append port:doxygen \ - port:graphviz \ - port:npm9 \ - port:pkgconfig - -depends_lib port:curl \ - port:libopus \ - port:miniupnpc - -configure.args -DBOOST_USE_STATIC=ON \ - -DBUILD_WERROR=ON \ - -DCMAKE_INSTALL_PREFIX=${prefix} \ - -DSUNSHINE_ASSETS_DIR=etc/sunshine/assets \ - -DSUNSHINE_PUBLISHER_NAME='LizardByte' \ - -DSUNSHINE_PUBLISHER_WEBSITE='https://app.lizardbyte.dev' \ - -DSUNSHINE_PUBLISHER_ISSUE_URL='https://app.lizardbyte.dev/support' - -configure.env-append BRANCH=@GITHUB_BRANCH@ -configure.env-append BUILD_VERSION=@BUILD_VERSION@ -configure.env-append COMMIT=@GITHUB_COMMIT@ - -startupitem.create yes -startupitem.executable "${prefix}/bin/sunshine" -startupitem.location LaunchDaemons -startupitem.name ${name} -startupitem.netchange yes - -platform darwin { - if { ${os.major} < 20 } { - # See: https://github.com/LizardByte/Sunshine/discussions/117#discussioncomment-2513494 - notes-append "Port is limited to software encoding, when used with macOS releases prior to Big Sur." - } -} - -notes-append "Run @PROJECT_NAME@ by executing 'sunshine ', e.g. 'sunshine ~/sunshine.conf' " -notes-append "The config file will be created if it doesn't exist." -notes-append "It is recommended to set a location for the apps file in the config." -notes-append "See our documentation at 'https://docs.lizardbyte.dev/projects/sunshine/en/v@PROJECT_VERSION@/' for further info." - -test.run yes -test.dir ${build.dir}/tests -test.target "" -test.cmd ./test_sunshine -test.args --gtest_color=yes --gtest_filter=-*HIDTest.*:-*DeathTest.* diff --git a/packaging/sunshine.rb b/packaging/sunshine.rb index 72d0e985..3b5be19f 100644 --- a/packaging/sunshine.rb +++ b/packaging/sunshine.rb @@ -130,7 +130,7 @@ class @PROJECT_NAME@ < Formula Thanks for installing @PROJECT_NAME@! To get started, review the documentation at: - https://docs.lizardbyte.dev/projects/sunshine/en/latest/ + https://docs.lizardbyte.dev/projects/sunshine EOS if OS.linux? diff --git a/scripts/_locale.py b/scripts/_locale.py index d0354149..84ae3385 100644 --- a/scripts/_locale.py +++ b/scripts/_locale.py @@ -24,6 +24,7 @@ year = datetime.datetime.now().year # target locales target_locales = [ + 'bg', # Bulgarian 'de', # German 'en', # English 'en_GB', # English (United Kingdom) @@ -32,9 +33,14 @@ target_locales = [ 'fr', # French 'it', # Italian 'ja', # Japanese + 'ko', # Korean + 'pl', # Polish 'pt', # Portuguese + 'pt_BR', # Portuguese (Brazil) 'ru', # Russian 'sv', # Swedish + 'tr', # Turkish + 'uk', # Ukrainian 'zh', # Chinese ] diff --git a/src/audio.cpp b/src/audio.cpp index d2d55780..bd4c8efe 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -20,16 +20,6 @@ namespace audio { using opus_t = util::safe_ptr; using sample_queue_t = std::shared_ptr>>; - struct audio_ctx_t { - // We want to change the sink for the first stream only - std::unique_ptr sink_flag; - - std::unique_ptr control; - - bool restore_sink; - platf::sink_t sink; - }; - static int start_audio_control(audio_ctx_t &ctx); static void @@ -95,8 +85,6 @@ namespace audio { }, }; - auto control_shared = safe::make_shared(start_audio_control, stop_audio_control); - void encodeThread(sample_queue_t samples, config_t config, void *channel_data) { auto packets = mail::man->queue(mail::audio_packets); @@ -149,7 +137,7 @@ namespace audio { apply_surround_params(stream, config.customStreamParams); } - auto ref = control_shared.ref(); + auto ref = get_audio_ctx_ref(); if (!ref) { return; } @@ -260,6 +248,26 @@ namespace audio { } } + audio_ctx_ref_t + get_audio_ctx_ref() { + static auto control_shared { safe::make_shared(start_audio_control, stop_audio_control) }; + return control_shared.ref(); + } + + bool + is_audio_ctx_sink_available(const audio_ctx_t &ctx) { + if (!ctx.control) { + return false; + } + + const std::string &sink = ctx.sink.host.empty() ? config::audio.sink : ctx.sink.host; + if (sink.empty()) { + return false; + } + + return ctx.control->is_sink_available(sink); + } + int map_stream(int channels, bool quality) { int shift = quality ? 1 : 0; diff --git a/src/audio.h b/src/audio.h index 208a5775..927dfdef 100644 --- a/src/audio.h +++ b/src/audio.h @@ -4,6 +4,8 @@ */ #pragma once +// local includes +#include "platform/common.h" #include "thread_safe.h" #include "utility.h" @@ -55,8 +57,50 @@ namespace audio { std::bitset flags; }; + struct audio_ctx_t { + // We want to change the sink for the first stream only + std::unique_ptr sink_flag; + + std::unique_ptr control; + + bool restore_sink; + platf::sink_t sink; + }; + using buffer_t = util::buffer_t; using packet_t = std::pair; + using audio_ctx_ref_t = safe::shared_t::ptr_t; + void capture(safe::mail_t mail, config_t config, void *channel_data); + + /** + * @brief Get the reference to the audio context. + * @returns A shared pointer reference to audio context. + * @note Aside from the configuration purposes, it can be used to extend the + * audio sink lifetime to capture sink earlier and restore it later. + * + * @examples + * audio_ctx_ref_t audio = get_audio_ctx_ref() + * @examples_end + */ + audio_ctx_ref_t + get_audio_ctx_ref(); + + /** + * @brief Check if the audio sink held by audio context is available. + * @returns True if available (and can probably be restored), false otherwise. + * @note Useful for delaying the release of audio context shared pointer (which + * tries to restore original sink). + * + * @examples + * audio_ctx_ref_t audio = get_audio_ctx_ref() + * if (audio.get()) { + * return is_audio_ctx_sink_available(*audio.get()); + * } + * return false; + * @examples_end + */ + bool + is_audio_ctx_sink_available(const audio_ctx_t &ctx); } // namespace audio diff --git a/src/config.cpp b/src/config.cpp index ac23b474..aeb17d80 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -330,6 +330,91 @@ namespace config { } } // namespace sw + namespace dd { + video_t::dd_t::config_option_e + config_option_from_view(const std::string_view value) { +#define _CONVERT_(x) \ + if (value == #x##sv) return video_t::dd_t::config_option_e::x + _CONVERT_(disabled); + _CONVERT_(verify_only); + _CONVERT_(ensure_active); + _CONVERT_(ensure_primary); + _CONVERT_(ensure_only_display); +#undef _CONVERT_ + return video_t::dd_t::config_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::resolution_option_e + resolution_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::resolution_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); + _CONVERT_(manual); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::resolution_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::refresh_rate_option_e + refresh_rate_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::refresh_rate_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); + _CONVERT_(manual); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::refresh_rate_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::hdr_option_e + hdr_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::hdr_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::hdr_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::mode_remapping_t + mode_remapping_from_view(const std::string_view value) { + const auto parse_entry_list { [](const auto &entry_list, auto &output_field) { + for (auto &[_, entry] : entry_list) { + auto requested_resolution = entry.template get_optional("requested_resolution"s); + auto requested_fps = entry.template get_optional("requested_fps"s); + auto final_resolution = entry.template get_optional("final_resolution"s); + auto final_refresh_rate = entry.template get_optional("final_refresh_rate"s); + + output_field.push_back(video_t::dd_t::mode_remapping_entry_t { + requested_resolution.value_or(""), + requested_fps.value_or(""), + final_resolution.value_or(""), + final_refresh_rate.value_or("") }); + } + } }; + + // We need to add a wrapping object to make it valid JSON, otherwise ptree cannot parse it. + std::stringstream json_stream; + json_stream << "{\"dd_mode_remapping\":" << value << "}"; + + boost::property_tree::ptree json_tree; + boost::property_tree::read_json(json_stream, json_tree); + + video_t::dd_t::mode_remapping_t output; + parse_entry_list(json_tree.get_child("dd_mode_remapping.mixed"), output.mixed); + parse_entry_list(json_tree.get_child("dd_mode_remapping.resolution_only"), output.resolution_only); + parse_entry_list(json_tree.get_child("dd_mode_remapping.refresh_rate_only"), output.refresh_rate_only); + + return output; + } + } // namespace dd + video_t video { false, // headless_mode false, // follow_client_hdr @@ -339,7 +424,6 @@ namespace config { 0, // hevc_mode 0, // av1_mode - 1, // min_fps_factor 2, // min_threads { "superfast"s, // preset @@ -391,6 +475,19 @@ namespace config { {}, // adapter_name {}, // output_name + { + video_t::dd_t::config_option_e::verify_only, // configuration_option + video_t::dd_t::resolution_option_e::automatic, // resolution_option + {}, // manual_resolution + video_t::dd_t::refresh_rate_option_e::automatic, // refresh_rate_option + {}, // manual_refresh_rate + video_t::dd_t::hdr_option_e::automatic, // hdr_option + 3s, // config_revert_delay + {}, // mode_remapping + {} // wa + }, // display_device + + 1 // min_fps_factor "1920x1080x60", // fallback_mode }; @@ -998,9 +1095,9 @@ namespace config { bool_f(vars, "follow_client_hdr", video.follow_client_hdr); bool_f(vars, "set_vdisplay_primary", video.set_vdisplay_primary); int_f(vars, "qp", video.qp); - int_f(vars, "min_threads", video.min_threads); int_between_f(vars, "hevc_mode", video.hevc_mode, { 0, 3 }); int_between_f(vars, "av1_mode", video.av1_mode, { 0, 3 }); + int_f(vars, "min_threads", video.min_threads); string_f(vars, "sw_preset", video.sw.sw_preset); if (!video.sw.sw_preset.empty()) { video.sw.svtav1_preset = sw::svtav1_preset_from_view(video.sw.sw_preset); @@ -1071,8 +1168,25 @@ namespace config { string_f(vars, "encoder", video.encoder); string_f(vars, "adapter_name", video.adapter_name); string_f(vars, "output_name", video.output_name); - string_f(vars, "fallback_mode", video.fallback_mode); + + generic_f(vars, "dd_configuration_option", video.dd.configuration_option, dd::config_option_from_view); + generic_f(vars, "dd_resolution_option", video.dd.resolution_option, dd::resolution_option_from_view); + string_f(vars, "dd_manual_resolution", video.dd.manual_resolution); + generic_f(vars, "dd_refresh_rate_option", video.dd.refresh_rate_option, dd::refresh_rate_option_from_view); + string_f(vars, "dd_manual_refresh_rate", video.dd.manual_refresh_rate); + generic_f(vars, "dd_hdr_option", video.dd.hdr_option, dd::hdr_option_from_view); + { + int value = -1; + int_between_f(vars, "dd_config_revert_delay", value, { 0, std::numeric_limits::max() }); + if (value >= 0) { + video.dd.config_revert_delay = std::chrono::milliseconds { value }; + } + } + generic_f(vars, "dd_mode_remapping", video.dd.mode_remapping, dd::mode_remapping_from_view); + bool_f(vars, "dd_wa_hdr_toggle", video.dd.wa.hdr_toggle); + int_between_f(vars, "min_fps_factor", video.min_fps_factor, { 1, 3 }); + string_f(vars, "fallback_mode", video.fallback_mode); path_f(vars, "pkey", nvhttp.pkey); path_f(vars, "cert", nvhttp.cert); @@ -1171,6 +1285,7 @@ namespace config { } string_restricted_f(vars, "locale", config::sunshine.locale, { + "bg"sv, // Bulgarian "de"sv, // German "en"sv, // English "en_GB"sv, // English (UK) @@ -1179,10 +1294,14 @@ namespace config { "fr"sv, // French "it"sv, // Italian "ja"sv, // Japanese + "ko"sv, // Korean + "pl"sv, // Polish "pt"sv, // Portuguese + "pt_BR"sv, // Portuguese (Brazilian) "ru"sv, // Russian "sv"sv, // Swedish "tr"sv, // Turkish + "uk"sv, // Ukrainian "zh"sv, // Chinese }); diff --git a/src/config.h b/src/config.h index ea04a2a3..7137955e 100644 --- a/src/config.h +++ b/src/config.h @@ -24,7 +24,6 @@ namespace config { int hevc_mode; int av1_mode; - int min_fps_factor; // Minimum fps target, determines minimum frame time int min_threads; // Minimum number of threads/slices for CPU encoding struct { std::string sw_preset; @@ -83,6 +82,61 @@ namespace config { std::string adapter_name; std::string output_name; + struct dd_t { + struct workarounds_t { + bool hdr_toggle; ///< Specify whether to apply HDR high-contrast color workaround. + }; + + enum class config_option_e { + disabled, ///< Disable the configuration for the device. + verify_only, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_active, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_primary, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_only_display ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + }; + + enum class resolution_option_e { + disabled, ///< Do not change resolution. + automatic, ///< Change resolution and use the one received from Moonlight. + manual ///< Change resolution and use the manually provided one. + }; + + enum class refresh_rate_option_e { + disabled, ///< Do not change refresh rate. + automatic, ///< Change refresh rate and use the one received from Moonlight. + manual ///< Change refresh rate and use the manually provided one. + }; + + enum class hdr_option_e { + disabled, ///< Do not change HDR settings. + automatic ///< Change HDR settings and use the state requested by Moonlight. + }; + + struct mode_remapping_entry_t { + std::string requested_resolution; + std::string requested_fps; + std::string final_resolution; + std::string final_refresh_rate; + }; + + struct mode_remapping_t { + std::vector mixed; ///< To be used when `resolution_option` and `refresh_rate_option` is set to `automatic`. + std::vector resolution_only; ///< To be use when only `resolution_option` is set to `automatic`. + std::vector refresh_rate_only; ///< To be use when only `refresh_rate_option` is set to `automatic`. + }; + + config_option_e configuration_option; + resolution_option_e resolution_option; + std::string manual_resolution; ///< Manual resolution in case `resolution_option == resolution_option_e::manual`. + refresh_rate_option_e refresh_rate_option; + std::string manual_refresh_rate; ///< Manual refresh rate in case `refresh_rate_option == refresh_rate_option_e::manual`. + hdr_option_e hdr_option; + std::chrono::milliseconds config_revert_delay; ///< Time to wait until settings are reverted (after stream ends/app exists). + mode_remapping_t mode_remapping; + workarounds_t wa; + } dd; + + int min_fps_factor; // Minimum fps target, determines minimum frame time std::string fallback_mode; }; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index d182c27a..d9819288 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -28,6 +28,7 @@ #include "config.h" #include "confighttp.h" #include "crypto.h" +#include "display_device.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" @@ -61,6 +62,10 @@ namespace confighttp { REMOVE ///< Remove client }; + /** + * @brief Log the request details. + * @param request The HTTP request object. + */ void print_req(const req_https_t &request) { BOOST_LOG(debug) << "METHOD :: "sv << request->method; @@ -79,6 +84,23 @@ namespace confighttp { BOOST_LOG(debug) << " [--] "sv; } + /** + * @brief Send a response. + * @param response The HTTP response object. + * @param output_tree The JSON tree to send. + */ + void + send_response(resp_https_t response, const pt::ptree &output_tree) { + std::ostringstream data; + pt::write_json(data, output_tree); + response->write(data.str()); + } + + /** + * @brief Send a 401 Unauthorized response. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void send_unauthorized(resp_https_t response, req_https_t request) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); @@ -86,6 +108,12 @@ namespace confighttp { response->write(SimpleWeb::StatusCode::client_error_unauthorized); } + /** + * @brief Send a redirect response. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param path The path to redirect to. + */ void send_redirect(resp_https_t response, req_https_t request, const char *path) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); @@ -174,17 +202,58 @@ namespace confighttp { return true; } + /** + * @brief Send a 404 Not Found response. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void - not_found(resp_https_t response, req_https_t request) { + not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { + constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found; + pt::ptree tree; - tree.put("root..status_code", 404); + tree.put("status_code", static_cast(code)); + tree.put("error", "Not Found"); std::ostringstream data; + pt::write_json(data, tree); - pt::write_xml(data, tree); - response->write(SimpleWeb::StatusCode::client_error_not_found, data.str()); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + + response->write(code, data.str(), headers); } + /** + * @brief Send a 400 Bad Request response. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param error_message The error message to include in the response. + */ + void + bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") { + constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_bad_request; + + pt::ptree tree; + tree.put("status_code", static_cast(code)); + tree.put("status", false); + tree.put("error", error_message); + + std::ostringstream data; + pt::write_json(data, tree); + + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + + response->write(code, data.str(), headers); + } + + /** + * @brief Get the index page. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @todo combine these functions into a single function that accepts the page, i.e "index", "pin", "apps" + */ void fetchStaticPage(resp_https_t response, req_https_t request, const std::string& page, bool needsAuthenticate) { if (needsAuthenticate) { @@ -201,31 +270,61 @@ namespace confighttp { response->write(content, headers); }; + /** + * @brief Get the PIN page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getIndexPage(resp_https_t response, req_https_t request) { fetchStaticPage(response, request, "index.html", true); } + /** + * @brief Get the apps page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getPinPage(resp_https_t response, req_https_t request) { fetchStaticPage(response, request, "pin.html", true); } + /** + * @brief Get the clients page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getAppsPage(resp_https_t response, req_https_t request) { fetchStaticPage(response, request, "apps.html", true); } + /** + * @brief Get the configuration page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getConfigPage(resp_https_t response, req_https_t request) { fetchStaticPage(response, request, "config.html", true); } + /** + * @brief Get the password page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getPasswordPage(resp_https_t response, req_https_t request) { fetchStaticPage(response, request, "password.html", true); } + /** + * @brief Get the welcome page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getWelcomePage(resp_https_t response, req_https_t request) { if (!checkIPOrigin(response, request)) { @@ -240,6 +339,11 @@ namespace confighttp { fetchStaticPage(response, request, "welcome.html", false); } + /** + * @brief Get the troubleshooting page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getLoginPage(resp_https_t response, req_https_t request) { if (!checkIPOrigin(response, request)) { @@ -260,6 +364,9 @@ namespace confighttp { } /** + * @brief Get the favicon image. + * @param response The HTTP response object. + * @param request The HTTP request object. * @todo combine function with getSunshineLogoImage and possibly getNodeModules * @todo use mime_types map */ @@ -279,6 +386,9 @@ namespace confighttp { } /** + * @brief Get the Sunshine logo image. + * @param response The HTTP response object. + * @param request The HTTP request object. * @todo combine function with getFaviconImage and possibly getNodeModules * @todo use mime_types map */ @@ -297,12 +407,23 @@ namespace confighttp { response->write(SimpleWeb::StatusCode::success_ok, in, headers); } + /** + * @brief Check if a path is a child of another path. + * @param base The base path. + * @param query The path to check. + * @return True if the path is a child of the base path, false otherwise. + */ bool isChildPath(fs::path const &base, fs::path const &query) { auto relPath = fs::relative(base, query); return *(relPath.begin()) != fs::path(".."); } + /** + * @brief Get an asset from the node_modules directory. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ void getNodeModules(resp_https_t response, req_https_t request) { if (!checkIPOrigin(response, request)) { @@ -319,32 +440,37 @@ namespace confighttp { // Don't do anything if file does not exist or is outside the assets directory if (!isChildPath(filePath, nodeModulesPath)) { BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder"; - response->write(SimpleWeb::StatusCode::client_error_bad_request, "Bad Request"); + bad_request(response, request); + return; } - else if (!fs::exists(filePath)) { - response->write(SimpleWeb::StatusCode::client_error_not_found); + if (!fs::exists(filePath)) { + not_found(response, request); + return; } - else { - auto relPath = fs::relative(filePath, webDirPath); - // get the mime type from the file extension mime_types map - // remove the leading period from the extension - auto mimeType = mime_types.find(relPath.extension().string().substr(1)); - // check if the extension is in the map at the x position - if (mimeType != mime_types.end()) { - // if it is, set the content type to the mime type - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", mimeType->second); - std::ifstream in(filePath.string(), std::ios::binary); - response->write(SimpleWeb::StatusCode::success_ok, in, headers); - } - // do not return any file if the type is not in the map + + auto relPath = fs::relative(filePath, webDirPath); + // get the mime type from the file extension mime_types map + // remove the leading period from the extension + auto mimeType = mime_types.find(relPath.extension().string().substr(1)); + // check if the extension is in the map at the x position + if (mimeType == mime_types.end()) { + bad_request(response, request); + return; } + + // if it is, set the content type to the mime type + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", mimeType->second); + std::ifstream in(filePath.string(), std::ios::binary); + response->write(SimpleWeb::StatusCode::success_ok, in, headers); } /** * @brief Get the list of available applications. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps| GET| null} */ void getApps(resp_https_t response, req_https_t request) { @@ -363,6 +489,8 @@ namespace confighttp { * @brief Get the logs from the log file. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/logs| GET| null} */ void getLogs(resp_https_t response, req_https_t request) { @@ -378,7 +506,7 @@ namespace confighttp { } /** - * @brief Save an application. If the application already exists, it will be updated, otherwise it will be added. + * @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application. * @param response The HTTP response object. * @param request The HTTP request object. * The body for the post request should be JSON serialized in the following format: @@ -407,6 +535,8 @@ namespace confighttp { * "uuid": "C3445C24-871A-FD23-0708-615C121B5B78" * } * @endcode + * + * @api_examples{/api/apps| POST| {"name":"Hello, World!","index":-1}} */ void saveApp(resp_https_t response, req_https_t request) { @@ -417,42 +547,35 @@ namespace confighttp { std::stringstream ss; ss << request->content.rdbuf(); - pt::ptree outputTree; - auto g = util::fail_guard([&]() { - std::ostringstream data; - - pt::write_json(data, outputTree); - response->write(data.str()); - }); - - pt::ptree inputTree, fileTree; - BOOST_LOG(info) << config::stream.file_apps; try { // TODO: Input Validation + pt::ptree fileTree; + pt::ptree inputTree; + pt::ptree outputTree; pt::read_json(ss, inputTree); pt::read_json(config::stream.file_apps, fileTree); proc::migrate_apps(&fileTree, &inputTree); pt::write_json(config::stream.file_apps, fileTree); + proc::refresh(config::stream.file_apps); + + outputTree.put("status", true); + send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "SaveApp: "sv << e.what(); - - outputTree.put("status", "false"); - outputTree.put("error", "Invalid Input JSON"); - return; + bad_request(response, request, e.what()); } - - outputTree.put("status", "true"); - proc::refresh(config::stream.file_apps); } /** * @brief Delete an application. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps/9999| DELETE| null} */ void deleteApp(resp_https_t response, req_https_t request) { @@ -499,13 +622,8 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", "Invalid File JSON"); - return; + bad_request(response, request, e.what()); } - - outputTree.put("status", "true"); - proc::refresh(config::stream.file_apps); } /** @@ -516,9 +634,11 @@ namespace confighttp { * @code{.json} * { * "key": "igdb_", - * "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/.png", + * "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/.png" * } * @endcode + * + * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} */ void uploadCover(resp_https_t response, req_https_t request) { @@ -528,31 +648,19 @@ namespace confighttp { std::stringstream configStream; ss << request->content.rdbuf(); pt::ptree outputTree; - auto g = util::fail_guard([&]() { - std::ostringstream data; - - SimpleWeb::StatusCode code = SimpleWeb::StatusCode::success_ok; - if (outputTree.get_child_optional("error").has_value()) { - code = SimpleWeb::StatusCode::client_error_bad_request; - } - - pt::write_json(data, outputTree); - response->write(code, data.str()); - }); pt::ptree inputTree; try { pt::read_json(ss, inputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "UploadCover: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", e.what()); + bad_request(response, request, e.what()); return; } auto key = inputTree.get("key", ""); if (key.empty()) { - outputTree.put("error", "Cover key is required"); + bad_request(response, request, "Cover key is required"); return; } auto url = inputTree.get("url", ""); @@ -563,11 +671,11 @@ namespace confighttp { std::basic_string path = coverdir + http::url_escape(key) + ".png"; if (!url.empty()) { if (http::url_get_host(url) != "images.igdb.com") { - outputTree.put("error", "Only images.igdb.com is allowed"); + bad_request(response, request, "Only images.igdb.com is allowed"); return; } if (!http::download_file(url, path)) { - outputTree.put("error", "Failed to download cover"); + bad_request(response, request, "Failed to download cover"); return; } } @@ -577,13 +685,17 @@ namespace confighttp { std::ofstream imgfile(path); imgfile.write(data.data(), (int) data.size()); } + outputTree.put("status", true); outputTree.put("path", path); + send_response(response, outputTree); } /** * @brief Get the configuration settings. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/config| GET| null} */ void getConfig(resp_https_t response, req_https_t request) { @@ -592,14 +704,7 @@ namespace confighttp { print_req(request); pt::ptree outputTree; - auto g = util::fail_guard([&]() { - std::ostringstream data; - - pt::write_json(data, outputTree); - response->write(data.str()); - }); - - outputTree.put("status", "true"); + outputTree.put("status", true); outputTree.put("platform", SUNSHINE_PLATFORM); outputTree.put("version", PROJECT_VER); #ifdef _WIN32 @@ -611,12 +716,16 @@ namespace confighttp { for (auto &[name, value] : vars) { outputTree.put(std::move(name), std::move(value)); } + + send_response(response, outputTree); } /** * @brief Get the locale setting. This endpoint does not require authentication. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/configLocale| GET| null} */ void getLocale(resp_https_t response, req_https_t request) { @@ -625,15 +734,9 @@ namespace confighttp { print_req(request); pt::ptree outputTree; - auto g = util::fail_guard([&]() { - std::ostringstream data; - - pt::write_json(data, outputTree); - response->write(data.str()); - }); - - outputTree.put("status", "true"); + outputTree.put("status", true); outputTree.put("locale", config::sunshine.locale); + send_response(response, outputTree); } /** @@ -648,6 +751,8 @@ namespace confighttp { * @endcode * * @attention{It is recommended to ONLY save the config settings that differ from the default behavior.} + * + * @api_examples{/api/config| POST| {"key":"value"}} */ void saveConfig(resp_https_t response, req_https_t request) { @@ -658,16 +763,10 @@ namespace confighttp { std::stringstream ss; std::stringstream configStream; ss << request->content.rdbuf(); - pt::ptree outputTree; - auto g = util::fail_guard([&]() { - std::ostringstream data; - - pt::write_json(data, outputTree); - response->write(data.str()); - }); - pt::ptree inputTree; try { // TODO: Input Validation + pt::ptree inputTree; + pt::ptree outputTree; pt::read_json(ss, inputTree); for (const auto &kv : inputTree) { std::string value = inputTree.get(kv.first); @@ -676,12 +775,12 @@ namespace confighttp { configStream << kv.first << " = " << value << std::endl; } file_handler::write_file(config::sunshine.config_file.c_str(), configStream.str()); + outputTree.put("status", true); + send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -689,6 +788,8 @@ namespace confighttp { * @brief Restart Sunshine. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/restart| POST| null} */ void restart(resp_https_t response, req_https_t request) { @@ -729,6 +830,24 @@ namespace confighttp { write_resp.detach(); } + /** + * @brief Reset the display device persistence. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/reset-display-device-persistence| POST| null} + */ + void + resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) return; + + print_req(request); + + pt::ptree outputTree; + outputTree.put("status", display_device::reset_persistence()); + send_response(response, outputTree); + } + /** * @brief Update existing credentials. * @param response The HTTP response object. @@ -743,6 +862,8 @@ namespace confighttp { * "confirmNewPassword": "Confirm New Password" * } * @endcode + * + * @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}} */ void savePassword(resp_https_t response, req_https_t request) { @@ -750,20 +871,15 @@ namespace confighttp { print_req(request); + std::vector errors = {}; std::stringstream ss; std::stringstream configStream; ss << request->content.rdbuf(); - pt::ptree inputTree, outputTree; - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); - try { // TODO: Input Validation + pt::ptree inputTree; + pt::ptree outputTree; pt::read_json(ss, inputTree); auto username = inputTree.count("currentUsername") > 0 ? inputTree.get("currentUsername") : ""; auto newUsername = inputTree.get("newUsername"); @@ -772,15 +888,13 @@ namespace confighttp { auto confirmPassword = inputTree.count("confirmNewPassword") > 0 ? inputTree.get("confirmNewPassword") : ""; if (newUsername.length() == 0) newUsername = username; if (newUsername.length() == 0) { - outputTree.put("status", false); - outputTree.put("error", "Invalid Username"); + errors.emplace_back("Invalid Username"); } else { auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); if (config::sunshine.username.empty() || (boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password)) { if (newPassword.empty() || newPassword != confirmPassword) { - outputTree.put("status", false); - outputTree.put("error", "Password Mismatch"); + errors.emplace_back("Password Mismatch"); } else { http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword); @@ -793,16 +907,25 @@ namespace confighttp { } } else { - outputTree.put("status", false); - outputTree.put("error", "Invalid Current Credentials"); + errors.emplace_back("Invalid Current Credentials"); } } + + if (!errors.empty()) { + // join the errors array + std::string error = std::accumulate(errors.begin(), errors.end(), std::string(), + [](const std::string &a, const std::string &b) { + return a.empty() ? b : a + ", " + b; + }); + bad_request(response, request, error); + return; + } + + send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "SavePassword: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -860,6 +983,8 @@ namespace confighttp { * "name": "Friendly Client Name" * } * @endcode + * + * @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}} */ void savePin(resp_https_t response, req_https_t request) { @@ -870,26 +995,19 @@ namespace confighttp { std::stringstream ss; ss << request->content.rdbuf(); - pt::ptree inputTree, outputTree; - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); - try { // TODO: Input Validation + pt::ptree inputTree; + pt::ptree outputTree; pt::read_json(ss, inputTree); std::string pin = inputTree.get("pin"); std::string name = inputTree.get("name"); outputTree.put("status", nvhttp::pin(pin, name)); + send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "SavePin: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -976,6 +1094,8 @@ namespace confighttp { * @brief Unpair all clients. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/clients/unpair-all| POST| null} */ void unpairAll(resp_https_t response, req_https_t request) { @@ -983,16 +1103,12 @@ namespace confighttp { print_req(request); - pt::ptree outputTree; - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); nvhttp::erase_all_clients(); proc::proc.terminate(); + + pt::ptree outputTree; outputTree.put("status", true); + send_response(response, outputTree); } /** @@ -1005,6 +1121,8 @@ namespace confighttp { * "uuid": "" * } * @endcode + * + * @api_examples{/api/unpair| POST| {"uuid":"1234"}} */ void unpair(resp_https_t response, req_https_t request) { @@ -1015,25 +1133,18 @@ namespace confighttp { std::stringstream ss; ss << request->content.rdbuf(); - pt::ptree inputTree, outputTree; - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); - try { // TODO: Input Validation + pt::ptree inputTree; + pt::ptree outputTree; pt::read_json(ss, inputTree); std::string uuid = inputTree.get("uuid"); outputTree.put("status", nvhttp::unpair_client(uuid)); + send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "Unpair: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -1131,6 +1242,8 @@ namespace confighttp { * @brief Get the list of paired clients. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/clients/list| GET| null} */ void listClients(resp_https_t response, req_https_t request) { @@ -1138,26 +1251,21 @@ namespace confighttp { print_req(request); - pt::ptree named_certs = nvhttp::get_all_clients(); + const pt::ptree named_certs = nvhttp::get_all_clients(); pt::ptree outputTree; - outputTree.put("status", false); - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); - outputTree.add_child("named_certs", named_certs); outputTree.put("status", true); + send_response(response, outputTree); } /** * @brief Close the currently running application. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps/close| POST| null} */ void closeApp(resp_https_t response, req_https_t request) { @@ -1165,16 +1273,11 @@ namespace confighttp { print_req(request); - pt::ptree outputTree; - - auto g = util::fail_guard([&]() { - std::ostringstream data; - pt::write_json(data, outputTree); - response->write(data.str()); - }); - proc::proc.terminate(); + + pt::ptree outputTree; outputTree.put("status", true); + send_response(response, outputTree); } void @@ -1185,6 +1288,18 @@ namespace confighttp { auto address_family = net::af_from_enum_string(config::sunshine.address_family); https_server_t server { config::nvhttp.cert, config::nvhttp.pkey }; + server.default_resource["DELETE"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["PATCH"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["POST"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; server.default_resource["GET"] = not_found; server.resource["^/$"]["GET"] = getIndexPage; server.resource["^/pin/?$"]["GET"] = getPinPage; @@ -1208,6 +1323,7 @@ namespace confighttp { server.resource["^/api/configLocale$"]["GET"] = getLocale; server.resource["^/api/restart$"]["POST"] = restart; server.resource["^/api/quit$"]["POST"] = quit; + server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence; server.resource["^/api/password$"]["POST"] = savePassword; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/list$"]["GET"] = listClients; diff --git a/src/display_device.cpp b/src/display_device.cpp index f273104b..e337e9a8 100644 --- a/src/display_device.cpp +++ b/src/display_device.cpp @@ -6,12 +6,19 @@ #include "display_device.h" // lib includes +#include +#include +#include #include #include #include +#include +#include // local includes +#include "audio.h" #include "platform/common.h" +#include "rtsp.h" // platform-specific includes #ifdef _WIN32 @@ -22,52 +29,699 @@ namespace display_device { namespace { + constexpr std::chrono::milliseconds DEFAULT_RETRY_INTERVAL { 5000 }; + /** - * @brief A global for the settings manager interface whose lifetime is managed by `display_device::init()`. + * @brief A global for the settings manager interface and other settings whose lifetime is managed by `display_device::init(...)`. */ - std::unique_ptr> SM_INSTANCE; + struct { + std::mutex mutex {}; + std::chrono::milliseconds config_revert_delay { 0 }; + std::unique_ptr> sm_instance { nullptr }; + } DD_DATA; + + /** + * @brief Helper class for capturing audio context when the API demands it. + * + * The capture is needed to be done in case some of the displays are going + * to be deactivated before the stream starts. In this case the audio context + * will be captured for this display and can be restored once it is turned back. + */ + class sunshine_audio_context_t: public AudioContextInterface { + public: + [[nodiscard]] bool + capture() override { + return context_scheduler.execute([](auto &audio_context) { + // Explicitly releasing the context first in case it was not release yet so that it can be potentially cleaned up. + audio_context = boost::none; + audio_context = audio_context_t {}; + + // Always say that we have captured it successfully as otherwise the settings change procedure will be aborted. + return true; + }); + } + + [[nodiscard]] bool + isCaptured() const override { + return context_scheduler.execute([](const auto &audio_context) { + if (audio_context) { + // In case we still have context we need to check whether it was released or not. + // If it was released we can pretend that we no longer have it as it will be immediately cleaned up in `capture` method before we acquire new context. + return !audio_context->released; + } + + return false; + }); + } + + void + release() override { + context_scheduler.schedule([](auto &audio_context, auto &stop_token) { + if (audio_context) { + audio_context->released = true; + + const auto *audio_ctx_ptr = audio_context->audio_ctx_ref.get(); + if (audio_ctx_ptr && !audio::is_audio_ctx_sink_available(*audio_ctx_ptr) && audio_context->retry_counter > 0) { + // It is possible that the audio sink is not immediately available after the display is turned on. + // Therefore, we will hold on to the audio context a little longer, until it is either available + // or we time out. + --audio_context->retry_counter; + return; + } + } + + audio_context = boost::none; + stop_token.requestStop(); + }, + SchedulerOptions { .m_sleep_durations = { 2s } }); + } + + private: + struct audio_context_t { + /** + * @brief A reference to the audio context that will automatically extend the audio session. + * @note It is auto-initialized here for convenience. + */ + decltype(audio::get_audio_ctx_ref()) audio_ctx_ref { audio::get_audio_ctx_ref() }; + + /** + * @brief Will be set to true if the capture was released, but we still have to keep the context around, because the device is not available. + */ + bool released { false }; + + /** + * @brief How many times to check if the audio sink is available before giving up. + */ + int retry_counter { 15 }; + }; + + RetryScheduler> context_scheduler { std::make_unique>(boost::none) }; + }; + + /** + * @brief Convert string to unsigned int. + * @note For random reason there is std::stoi, but not std::stou... + * @param value String to be converted + * @return Parsed unsigned integer. + */ + unsigned int + stou(const std::string &value) { + unsigned long result { std::stoul(value) }; + if (result > std::numeric_limits::max()) { + throw std::out_of_range("stou"); + } + return result; + } + + /** + * @brief Parse resolution value from the string. + * @param input String to be parsed. + * @param output Reference to output variable to fill in. + * @returns True on successful parsing (empty string allowed), false otherwise. + * + * @examples + * std::optional resolution; + * if (parse_resolution_string("1920x1080", resolution)) { + * if (resolution) { + * BOOST_LOG(info) << "Value was specified"; + * } + * else { + * BOOST_LOG(info) << "Value was empty"; + * } + * } + * @examples_end + */ + bool + parse_resolution_string(const std::string &input, std::optional &output) { + const std::string trimmed_input { boost::algorithm::trim_copy(input) }; + const std::regex resolution_regex { R"(^(\d+)x(\d+)$)" }; + + if (std::smatch match; std::regex_match(trimmed_input, match, resolution_regex)) { + try { + output = Resolution { + stou(match[1].str()), + stou(match[2].str()) + }; + return true; + } + catch (const std::out_of_range &) { + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << " (number out of range)."; + } + catch (const std::exception &err) { + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << ":\n" + << err.what(); + } + } + else { + if (trimmed_input.empty()) { + output = std::nullopt; + return true; + } + + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << R"(. It must match a "1920x1080" pattern!)"; + } + + return false; + } + + /** + * @brief Parse refresh rate value from the string. + * @param input String to be parsed. + * @param output Reference to output variable to fill in. + * @param allow_decimal_point Specify whether the decimal point is allowed or not. + * @returns True on successful parsing (empty string allowed), false otherwise. + * + * @examples + * std::optional refresh_rate; + * if (parse_refresh_rate_string("59.95", refresh_rate)) { + * if (refresh_rate) { + * BOOST_LOG(info) << "Value was specified"; + * } + * else { + * BOOST_LOG(info) << "Value was empty"; + * } + * } + * @examples_end + */ + bool + parse_refresh_rate_string(const std::string &input, std::optional &output, const bool allow_decimal_point = true) { + static const auto is_zero { [](const auto &character) { return character == '0'; } }; + const std::string trimmed_input { boost::algorithm::trim_copy(input) }; + const std::regex refresh_rate_regex { allow_decimal_point ? R"(^(\d+)(?:\.(\d+))?$)" : R"(^(\d+)$)" }; + + if (std::smatch match; std::regex_match(trimmed_input, match, refresh_rate_regex)) { + try { + // Here we are trimming zeros from the string to possibly reduce out of bounds case + std::string trimmed_match_1 { boost::algorithm::trim_left_copy_if(match[1].str(), is_zero) }; + if (trimmed_match_1.empty()) { + trimmed_match_1 = "0"s; // Just in case ALL the string is full of zeros, we want to leave one + } + + std::string trimmed_match_2; + if (allow_decimal_point && match[2].matched) { + trimmed_match_2 = boost::algorithm::trim_right_copy_if(match[2].str(), is_zero); + } + + if (!trimmed_match_2.empty()) { + // We have a decimal point and will have to split it into numerator and denominator. + // For example: + // 59.995: + // numerator = 59995 + // denominator = 1000 + + // We are essentially removing the decimal point here: 59.995 -> 59995 + const std::string numerator_str { trimmed_match_1 + trimmed_match_2 }; + const auto numerator { stou(numerator_str) }; + + // Here we are counting decimal places and calculating denominator: 10^decimal_places + const auto denominator { static_cast(std::pow(10, trimmed_match_2.size())) }; + + output = Rational { numerator, denominator }; + } + else { + // We do not have a decimal point, just a valid number. + // For example: + // 60: + // numerator = 60 + // denominator = 1 + output = Rational { stou(trimmed_match_1), 1 }; + } + return true; + } + catch (const std::out_of_range &) { + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << " (number out of range)."; + } + catch (const std::exception &err) { + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << ":\n" + << err.what(); + } + } + else { + if (trimmed_input.empty()) { + output = std::nullopt; + return true; + } + + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << ". Must have a pattern of " << (allow_decimal_point ? R"("123" or "123.456")" : R"("123")") << "!"; + } + + return false; + } + + /** + * @brief Parse device preparation option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @returns Parsed device preparation value we need to use. + * Empty optional if no preparation nor configuration shall take place. + * + * @examples + * const config::video_t &video_config { config::video }; + * const auto device_prep_option = parse_device_prep_option(video_config); + * @examples_end + */ + std::optional + parse_device_prep_option(const config::video_t &video_config) { + using enum config::video_t::dd_t::config_option_e; + using enum SingleDisplayConfiguration::DevicePreparation; + + switch (video_config.dd.configuration_option) { + case verify_only: + return VerifyOnly; + case ensure_active: + return EnsureActive; + case ensure_primary: + return EnsurePrimary; + case ensure_only_display: + return EnsureOnlyDisplay; + case disabled: + break; + } + + return std::nullopt; + } + + /** + * @brief Parse resolution option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a display config object that will be modified on success. + * @returns True on successful parsing, false otherwise. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = parse_resolution_option(video_config, *launch_session, config); + * @examples_end + */ + bool + parse_resolution_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + using resolution_option_e = config::video_t::dd_t::resolution_option_e; + + switch (video_config.dd.resolution_option) { + case resolution_option_e::automatic: { + if (!session.enable_sops) { + BOOST_LOG(warning) << R"(Sunshine is configured to change resolution automatically, but the "Optimize game settings" is not set in the client! Resolution will not be changed.)"; + } + else if (session.width >= 0 && session.height >= 0) { + config.m_resolution = Resolution { + static_cast(session.width), + static_cast(session.height) + }; + } + else { + BOOST_LOG(error) << "Resolution provided by client session config is invalid: " << session.width << "x" << session.height; + return false; + } + break; + } + case resolution_option_e::manual: { + if (!session.enable_sops) { + BOOST_LOG(warning) << R"(Sunshine is configured to change resolution manually, but the "Optimize game settings" is not set in the client! Resolution will not be changed.)"; + } + else { + if (!parse_resolution_string(video_config.dd.manual_resolution, config.m_resolution)) { + BOOST_LOG(error) << "Failed to parse manual resolution string!"; + return false; + } + + if (!config.m_resolution) { + BOOST_LOG(error) << "Manual resolution must be specified!"; + return false; + } + } + break; + } + case resolution_option_e::disabled: + break; + } + + return true; + } + + /** + * @brief Parse refresh rate option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a config object that will be modified on success. + * @returns True on successful parsing, false otherwise. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = parse_refresh_rate_option(video_config, *launch_session, config); + * @examples_end + */ + bool + parse_refresh_rate_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + using refresh_rate_option_e = config::video_t::dd_t::refresh_rate_option_e; + + switch (video_config.dd.refresh_rate_option) { + case refresh_rate_option_e::automatic: { + if (session.fps >= 0) { + config.m_refresh_rate = Rational { static_cast(session.fps), 1 }; + } + else { + BOOST_LOG(error) << "FPS value provided by client session config is invalid: " << session.fps; + return false; + } + break; + } + case refresh_rate_option_e::manual: { + if (!parse_refresh_rate_string(video_config.dd.manual_refresh_rate, config.m_refresh_rate)) { + BOOST_LOG(error) << "Failed to parse manual refresh rate string!"; + return false; + } + + if (!config.m_refresh_rate) { + BOOST_LOG(error) << "Manual refresh rate must be specified!"; + return false; + } + break; + } + case refresh_rate_option_e::disabled: + break; + } + + return true; + } + + /** + * @brief Parse HDR option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @returns Parsed HDR state value we need to switch to. + * Empty optional if no action is required. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * const auto hdr_option = parse_hdr_option(video_config, *launch_session); + * @examples_end + */ + std::optional + parse_hdr_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + using hdr_option_e = config::video_t::dd_t::hdr_option_e; + + switch (video_config.dd.hdr_option) { + case hdr_option_e::automatic: + return session.enable_hdr ? HdrState::Enabled : HdrState::Disabled; + case hdr_option_e::disabled: + break; + } + + return std::nullopt; + } + + /** + * @brief Indicates which remapping fields and config structure shall be used. + */ + enum class remapping_type_e { + mixed, ///! Both reseolution and refresh rate may be remapped + resolution_only, ///! Only resolution will be remapped + refresh_rate_only ///! Only refresh rate will be remapped + }; + + /** + * @brief Determine the ramapping type from the user config. + * @param video_config User's video related configuration. + * @returns Enum value if remapping can be performed, null optional if remapping shall be skipped. + */ + std::optional + determine_remapping_type(const config::video_t &video_config) { + using dd_t = config::video_t::dd_t; + const bool auto_resolution { video_config.dd.resolution_option == dd_t::resolution_option_e::automatic }; + const bool auto_refresh_rate { video_config.dd.refresh_rate_option == dd_t::refresh_rate_option_e::automatic }; + + if (auto_resolution && auto_refresh_rate) { + return remapping_type_e::mixed; + } + + if (auto_resolution) { + return remapping_type_e::resolution_only; + } + + if (auto_refresh_rate) { + return remapping_type_e::refresh_rate_only; + } + + return std::nullopt; + } + + /** + * @brief Contains remapping data parsed from the string values. + */ + struct parsed_remapping_entry_t { + std::optional requested_resolution; + std::optional requested_fps; + std::optional final_resolution; + std::optional final_refresh_rate; + }; + + /** + * @brief Check if resolution is to be mapped based on remmaping type. + * @param type Remapping type to check. + * @returns True if resolution is to be mapped, false otherwise. + */ + bool + is_resolution_mapped(const remapping_type_e type) { + return type == remapping_type_e::resolution_only || type == remapping_type_e::mixed; + } + + /** + * @brief Check if FPS is to be mapped based on remmaping type. + * @param type Remapping type to check. + * @returns True if FPS is to be mapped, false otherwise. + */ + bool + is_fps_mapped(const remapping_type_e type) { + return type == remapping_type_e::refresh_rate_only || type == remapping_type_e::mixed; + } + + /** + * @brief Parse the remapping entry from the config into an internal structure. + * @param entry Entry to parse. + * @param type Specify which entry fields should be parsed. + * @returns Parsed structure or null optional if a necessary field could not be parsed. + */ + std::optional + parse_remapping_entry(const config::video_t::dd_t::mode_remapping_entry_t &entry, const remapping_type_e type) { + parsed_remapping_entry_t result {}; + + if (is_resolution_mapped(type) && (!parse_resolution_string(entry.requested_resolution, result.requested_resolution) || + !parse_resolution_string(entry.final_resolution, result.final_resolution))) { + return std::nullopt; + } + + if (is_fps_mapped(type) && (!parse_refresh_rate_string(entry.requested_fps, result.requested_fps, false) || + !parse_refresh_rate_string(entry.final_refresh_rate, result.final_refresh_rate))) { + return std::nullopt; + } + + return result; + } + + /** + * @brief Remap the the requested display mode based on the config. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a config object that will be modified on success. + * @returns True if the remapping was performed or skipped, false if remapping has failed due to invalid config. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = remap_display_mode_if_needed(video_config, *launch_session, config); + * @examples_end + */ + bool + remap_display_mode_if_needed(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + const auto remapping_type { determine_remapping_type(video_config) }; + if (!remapping_type) { + return true; + } + + const auto &remapping_list { [&]() { + using enum remapping_type_e; + + switch (*remapping_type) { + case resolution_only: + return video_config.dd.mode_remapping.resolution_only; + case refresh_rate_only: + return video_config.dd.mode_remapping.refresh_rate_only; + case mixed: + default: + return video_config.dd.mode_remapping.mixed; + } + }() }; + + if (remapping_list.empty()) { + BOOST_LOG(debug) << "No values are available for display mode remapping."; + return true; + } + BOOST_LOG(debug) << "Trying to remap display modes..."; + + const auto entry_to_string { [type = *remapping_type](const config::video_t::dd_t::mode_remapping_entry_t &entry) { + const bool mapping_resolution { is_resolution_mapped(type) }; + const bool mapping_fps { is_fps_mapped(type) }; + + // clang-format off + return (mapping_resolution ? " - requested resolution: "s + entry.requested_resolution + "\n" : "") + + (mapping_fps ? " - requested FPS: "s + entry.requested_fps + "\n" : "") + + (mapping_resolution ? " - final resolution: "s + entry.final_resolution + "\n" : "") + + (mapping_fps ? " - final refresh rate: "s + entry.final_refresh_rate : ""); + // clang-format on + } }; + + for (const auto &entry : remapping_list) { + const auto parsed_entry { parse_remapping_entry(entry, *remapping_type) }; + if (!parsed_entry) { + BOOST_LOG(error) << "Failed to parse remapping entry from:\n" + << entry_to_string(entry); + return false; + } + + if (!parsed_entry->final_resolution && !parsed_entry->final_refresh_rate) { + BOOST_LOG(error) << "At least one final value must be set for remapping display modes! Entry:\n" + << entry_to_string(entry); + return false; + } + + if (!session.enable_sops && (parsed_entry->requested_resolution || parsed_entry->final_resolution)) { + BOOST_LOG(warning) << R"(Skipping remapping entry, because the "Optimize game settings" is not set in the client! Entry:\n)" + << entry_to_string(entry); + continue; + } + + // Note: at this point config should already have parsed resolution set. + if (parsed_entry->requested_resolution && parsed_entry->requested_resolution != config.m_resolution) { + BOOST_LOG(verbose) << "Skipping remapping because requested resolutions do not match! Entry:\n" + << entry_to_string(entry); + continue; + } + + // Note: at this point config should already have parsed refresh rate set. + if (parsed_entry->requested_fps && parsed_entry->requested_fps != config.m_refresh_rate) { + BOOST_LOG(verbose) << "Skipping remapping because requested FPS do not match! Entry:\n" + << entry_to_string(entry); + continue; + } + + BOOST_LOG(info) << "Remapping requested display mode. Entry:\n" + << entry_to_string(entry); + if (parsed_entry->final_resolution) { + config.m_resolution = parsed_entry->final_resolution; + } + if (parsed_entry->final_refresh_rate) { + config.m_refresh_rate = parsed_entry->final_refresh_rate; + } + break; + } + + return true; + } /** * @brief Construct a settings manager interface to manage display device settings. + * @param persistence_filepath File location for saving persistent state. + * @param video_config User's video related configuration. * @return An interface or nullptr if the OS does not support the interface. */ std::unique_ptr - make_settings_manager() { + make_settings_manager([[maybe_unused]] const std::filesystem::path &persistence_filepath, [[maybe_unused]] const config::video_t &video_config) { #ifdef _WIN32 - // TODO: In the upcoming PR, add audio context capture and settings persistence return std::make_unique( std::make_shared(std::make_shared()), - nullptr, - std::make_unique(nullptr), - WinWorkarounds {}); + std::make_shared(), + std::make_unique( + std::make_shared(persistence_filepath)), + WinWorkarounds { + .m_hdr_blank_delay = video_config.dd.wa.hdr_toggle ? std::make_optional(500ms) : std::nullopt }); #else return nullptr; #endif } + + /** + * @brief Defines the "revert config" algorithms. + */ + enum class revert_option_e { + try_once, ///< Try reverting once and then abort. + try_indefinitely, ///< Keep trying to revert indefinitely. + try_indefinitely_with_delay ///< Keep trying to revert indefinitely, but delay the first try by some amount of time. + }; + + /** + * @brief Reverts the configuration based on the provided option. + * @note This is function does not lock mutex. + */ + void + revert_configuration_unlocked(const revert_option_e option) { + if (!DD_DATA.sm_instance) { + // Platform is not supported, nothing to do. + return; + } + + // Note: by default the executor function is immediately executed in the calling thread. With delay, we want to avoid that. + SchedulerOptions scheduler_option { .m_sleep_durations = { DEFAULT_RETRY_INTERVAL } }; + if (option == revert_option_e::try_indefinitely_with_delay && DD_DATA.config_revert_delay > std::chrono::milliseconds::zero()) { + scheduler_option.m_sleep_durations = { DD_DATA.config_revert_delay, DEFAULT_RETRY_INTERVAL }; + scheduler_option.m_execution = SchedulerOptions::Execution::ScheduledOnly; + } + + DD_DATA.sm_instance->schedule([try_once = (option == revert_option_e::try_once)](auto &settings_iface, auto &stop_token) { + // Here we want to keep retrying indefinitely until we succeed. + if (settings_iface.revertSettings() || try_once) { + stop_token.requestStop(); + } + }, + scheduler_option); + } } // namespace std::unique_ptr - init() { - // We can support re-init without any issues, however we should make sure to cleanup first! - SM_INSTANCE = nullptr; + init(const std::filesystem::path &persistence_filepath, const config::video_t &video_config) { + std::lock_guard lock { DD_DATA.mutex }; + // We can support re-init without any issues, however we should make sure to clean up first! + revert_configuration_unlocked(revert_option_e::try_once); + DD_DATA.config_revert_delay = video_config.dd.config_revert_delay; + DD_DATA.sm_instance = nullptr; - // If we fail to create settings manager, this means platform is not supported and - // we will need to provided error-free passtrough in other methods - if (auto settings_manager { make_settings_manager() }) { - SM_INSTANCE = std::make_unique>(std::move(settings_manager)); + // If we fail to create settings manager, this means platform is not supported, and + // we will need to provided error-free pass-trough in other methods + if (auto settings_manager { make_settings_manager(persistence_filepath, video_config) }) { + DD_DATA.sm_instance = std::make_unique>(std::move(settings_manager)); - const auto available_devices { SM_INSTANCE->execute([](auto &settings_iface) { return settings_iface.enumAvailableDevices(); }) }; + const auto available_devices { DD_DATA.sm_instance->execute([](auto &settings_iface) { return settings_iface.enumAvailableDevices(); }) }; BOOST_LOG(info) << "Currently available display devices:\n" << toJson(available_devices); - // TODO: In the upcoming PR, schedule recovery here + // In case we have failed to revert configuration before shutting down, we should + // do it now. + revert_configuration_unlocked(revert_option_e::try_indefinitely); } class deinit_t: public platf::deinit_t { public: ~deinit_t() override { - // TODO: In the upcoming PR, execute recovery once here - SM_INSTANCE = nullptr; + std::lock_guard lock { DD_DATA.mutex }; + try { + // This may throw if used incorrectly. At the moment this will not happen, however + // in case some unforeseen changes are made that could raise an exception, + // we definitely don't want this to happen in destructor. Especially in the + // deinit_t where the outcome does not really matter. + revert_configuration_unlocked(revert_option_e::try_once); + } + catch (std::exception &err) { + BOOST_LOG(fatal) << err.what(); + } + + DD_DATA.sm_instance = nullptr; } }; return std::make_unique(); @@ -75,11 +729,99 @@ namespace display_device { std::string map_output_name(const std::string &output_name) { - if (!SM_INSTANCE) { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { // Fallback to giving back the output name if the platform is not supported. return output_name; } - return SM_INSTANCE->execute([&output_name](auto &settings_iface) { return settings_iface.getDisplayName(output_name); }); + return DD_DATA.sm_instance->execute([&output_name](auto &settings_iface) { return settings_iface.getDisplayName(output_name); }); + } + + void + configure_display(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + const auto result { parse_configuration(video_config, session) }; + if (const auto *parsed_config { std::get_if(&result) }; parsed_config) { + configure_display(*parsed_config); + return; + } + + if (const auto *disabled { std::get_if(&result) }; disabled) { + revert_configuration(); + return; + } + + // Error already logged for failed_to_parse_tag_t case, and we also don't + // want to revert active configuration in case we have any + } + + void + configure_display(const SingleDisplayConfiguration &config) { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { + // Platform is not supported, nothing to do. + return; + } + + DD_DATA.sm_instance->schedule([config](auto &settings_iface, auto &stop_token) { + // We only want to keep retrying in case of a transient errors. + // In other cases, when we either fail or succeed we just want to stop... + if (settings_iface.applySettings(config) != SettingsManagerInterface::ApplyResult::ApiTemporarilyUnavailable) { + stop_token.requestStop(); + } + }, + { .m_sleep_durations = { DEFAULT_RETRY_INTERVAL } }); + } + + void + revert_configuration() { + std::lock_guard lock { DD_DATA.mutex }; + revert_configuration_unlocked(revert_option_e::try_indefinitely_with_delay); + } + + bool + reset_persistence() { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { + // Platform is not supported, assume success. + return true; + } + + return DD_DATA.sm_instance->execute([](auto &settings_iface, auto &stop_token) { + // Whatever the outcome is we want to stop interfering with the user, + // so any schedulers need to be stopped. + stop_token.requestStop(); + return settings_iface.resetPersistence(); + }); + } + + std::variant + parse_configuration(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + const auto device_prep { parse_device_prep_option(video_config) }; + if (!device_prep) { + return configuration_disabled_tag_t {}; + } + + SingleDisplayConfiguration config; + config.m_device_id = video_config.output_name; + config.m_device_prep = *device_prep; + config.m_hdr_state = parse_hdr_option(video_config, session); + + if (!parse_resolution_option(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + + if (!parse_refresh_rate_option(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + + if (!remap_display_mode_if_needed(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + + return config; } } // namespace display_device diff --git a/src/display_device.h b/src/display_device.h index 6562f5a3..e17c408f 100644 --- a/src/display_device.h +++ b/src/display_device.h @@ -5,24 +5,35 @@ #pragma once // lib includes +#include +#include #include // forward declarations namespace platf { class deinit_t; -} // namespace platf +} +namespace config { + struct video_t; +} +namespace rtsp_stream { + struct launch_session_t; +} namespace display_device { /** * @brief Initialize the implementation and perform the initial state recovery (if needed). + * @param persistence_filepath File location for reading/saving persistent state. + * @param video_config User's video related configuration. * @returns A deinit_t instance that performs cleanup when destroyed. * * @examples - * const auto init_guard { display_device::init() }; + * const config::video_t &video_config { config::video }; + * const auto init_guard { init("/my/persitence/file.state", video_config) }; * @examples_end */ - std::unique_ptr - init(); + [[nodiscard]] std::unique_ptr + init(const std::filesystem::path &persistence_filepath, const config::video_t &video_config); /** * @brief Map the output name to a specific display. @@ -34,6 +45,111 @@ namespace display_device { * const auto mapped_name_custom { map_output_name("{some-device-id}") }; * @examples_end */ - std::string + [[nodiscard]] std::string map_output_name(const std::string &output_name); + + /** + * @brief Configure the display device based on the user configuration and the session information. + * @note This is a convenience method for calling similar method of a different signature. + * + * @param video_config User's video related configuration. + * @param session Session information. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * configure_display(video_config, *launch_session); + * @examples_end + */ + void + configure_display(const config::video_t &video_config, const rtsp_stream::launch_session_t &session); + + /** + * @brief Configure the display device using the provided configuration. + * + * In some cases configuring display can fail due to transient issues and + * we will keep trying every 5 seconds, even if the stream has already started as there was + * no possibility to apply settings before the stream start. + * + * Therefore, there is no return value as we still want to continue with the stream, so that + * the users can do something about it once they are connected. Otherwise, we might + * prevent users from logging in at all if we keep failing to apply configuration. + * + * @param config Configuration for the display. + * + * @examples + * const SingleDisplayConfiguration valid_config { }; + * configure_display(valid_config); + * @examples_end + */ + void + configure_display(const SingleDisplayConfiguration &config); + + /** + * @brief Revert the display configuration and restore the previous state. + * + * In case the state could not be restored, by default it will be retried again in 5 seconds + * (repeating indefinitely until success or until persistence is reset). + * + * @examples + * revert_configuration(); + * @examples_end + */ + void + revert_configuration(); + + /** + * @brief Reset the persistence and currently held initial display state. + * + * This is normally used to get out of the "broken" state where the algorithm wants + * to restore the initial display state, but it is no longer possible. + * + * This could happen if the display is no longer available or the hardware was changed + * and the device ids no longer match. + * + * The user then accepts that Sunshine is not able to restore the state and "agrees" to + * do it manually. + * + * @return + * @note Whether the function succeeds or fails, the any of the scheduled "retries" from + * other methods will be stopped to not interfere with the user actions. + * + * @examples + * const auto result = reset_persistence(); + * @examples_end + */ + [[nodiscard]] bool + reset_persistence(); + + /** + * @brief A tag structure indicating that configuration parsing has failed. + */ + struct failed_to_parse_tag_t {}; + + /** + * @brief A tag structure indicating that configuration is disabled. + */ + struct configuration_disabled_tag_t {}; + + /** + * @brief Parse the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @return Parsed single display configuration or + * a tag indicating that the parsing has failed or + * a tag indicating that the user does not want to perform any configuration. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * const auto config { parse_configuration(video_config, *launch_session) }; + * if (const auto *parsed_config { std::get_if(&result) }; parsed_config) { + * configure_display(*config); + * } + * @examples_end + */ + [[nodiscard]] std::variant + parse_configuration(const config::video_t &video_config, const rtsp_stream::launch_session_t &session); } // namespace display_device diff --git a/src/main.cpp b/src/main.cpp index 1ca5b589..3fbf056c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -139,7 +139,7 @@ main(int argc, char *argv[]) { // Adding guard here first as it also performs recovery after crash, // otherwise people could theoretically end up without display output. // It also should be destroyed before forced shutdown to expedite the cleanup. - auto display_device_deinit_guard = display_device::init(); + auto display_device_deinit_guard = display_device::init(platf::appdata() / "display_device.state", config::video); if (!display_device_deinit_guard) { BOOST_LOG(error) << "Display device session failed to initialize"sv; } diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index d7507a1c..05b7fc2e 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -22,6 +22,7 @@ // local includes #include "config.h" #include "crypto.h" +#include "display_device.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" @@ -985,12 +986,17 @@ namespace nvhttp { print_req(request); pt::ptree tree; + bool revert_display_configuration { false }; auto g = util::fail_guard([&]() { std::ostringstream data; pt::write_xml(data, tree); response->write(data.str()); response->close_connection_after_response = true; + + if (revert_display_configuration) { + display_device::revert_configuration(); + } }); auto named_cert_p = get_verified_cert(request); @@ -1078,6 +1084,9 @@ namespace nvhttp { tree.put("root.gamesession", 1); rtsp_stream::launch_session_raise(launch_session); + + // Stream was started successfully, we will revert the config when the app or session terminates + revert_display_configuration = false; } void @@ -1124,7 +1133,21 @@ namespace nvhttp { return; } - if (rtsp_stream::session_count() == 0) { + // Newer Moonlight clients send localAudioPlayMode on /resume too, + // so we should use it if it's present in the args and there are + // no active sessions we could be interfering with. + const bool no_active_sessions { rtsp_stream::session_count() == 0 }; + if (no_active_sessions && args.find("localAudioPlayMode"s) != std::end(args)) { + host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); + } + auto launch_session = make_launch_session(host_audio, 0, args, named_cert_p); + + if (no_active_sessions) { + // We want to prepare display only if there are no active sessions at + // the moment. This should be done before probing encoders as it could + // change the active displays. + display_device::configure_display(config::video, *launch_session); + // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed // due to hotplugging, driver crash, primary monitor change, @@ -1136,17 +1159,8 @@ namespace nvhttp { return; } - - // Newer Moonlight clients send localAudioPlayMode on /resume too, - // so we should use it if it's present in the args and there are - // no active sessions we could be interfering with. - if (args.find("localAudioPlayMode"s) != std::end(args)) { - host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); - } } - auto launch_session = make_launch_session(host_audio, 0, args, named_cert_p); - auto encryption_mode = net::encryption_mode_for_address(request->remote_endpoint().address()); if (!launch_session->rtsp_cipher && encryption_mode == config::ENCRYPTION_MODE_MANDATORY) { BOOST_LOG(error) << "Rejecting client that cannot comply with mandatory encryption requirement"sv; @@ -1203,6 +1217,9 @@ namespace nvhttp { if (proc::proc.running() > 0) { proc::proc.terminate(); } + + // The config needs to be reverted regardless of whether "proc::proc.terminate()" was called or not. + display_device::revert_configuration(); } void diff --git a/src/platform/common.h b/src/platform/common.h index 1f5d4d07..195f92aa 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -550,6 +550,14 @@ namespace platf { virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) = 0; + /** + * @brief Check if the audio sink is available in the system. + * @param sink Sink to be checked. + * @returns True if available, false otherwise. + */ + virtual bool + is_sink_available(const std::string &sink) = 0; + virtual std::optional sink_info() = 0; diff --git a/src/platform/linux/audio.cpp b/src/platform/linux/audio.cpp index ff231707..a48ee2f0 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -473,6 +473,12 @@ namespace platf { return ::platf::microphone(mapping, channels, sample_rate, frame_size, get_monitor_name(sink_name)); } + bool + is_sink_available(const std::string &sink) override { + BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; + return true; + } + int set_sink(const std::string &sink) override { auto alarm = safe::make_alarm(); diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 5b77d606..be5f8a0c 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1685,7 +1685,7 @@ namespace platf { BOOST_LOG((window_system != window_system_e::X11 || config::video.capture == "kms") ? fatal : error) << "You must run [sudo setcap cap_sys_admin+p $(readlink -f $(which sunshine))] for KMS display capture to work!\n"sv << "If you installed from AppImage or Flatpak, please refer to the official documentation:\n"sv - << "https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/setup.html#install"sv; + << "https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2getting__started.html#linux"sv; break; } diff --git a/src/platform/macos/av_video.m b/src/platform/macos/av_video.m index 3b58bff5..bb7723bb 100644 --- a/src/platform/macos/av_video.m +++ b/src/platform/macos/av_video.m @@ -32,8 +32,7 @@ } + (NSString *)getDisplayName:(CGDirectDisplayID)displayID { - NSScreen *screens = [NSScreen screens]; - for (NSScreen *screen in screens) { + for (NSScreen *screen in [NSScreen screens]) { if (screen.deviceDescription[@"NSScreenNumber"] == [NSNumber numberWithUnsignedInt:displayID]) { return screen.localizedName; } diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 1e3a4cd6..8d2129f2 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -81,6 +81,12 @@ namespace platf { return mic; } + bool + is_sink_available(const std::string &sink) override { + BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; + return true; + } + std::optional sink_info() override { sink_t sink; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 09481fe4..85eb6908 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -722,6 +722,13 @@ namespace platf::audio { return sink; } + bool + is_sink_available(const std::string &sink) override { + const auto match_list = match_all_fields(from_utf8(sink)); + const auto matched = find_device_id(match_list); + return static_cast(matched); + } + /** * @brief Extract virtual audio sink information possibly encoded in the sink name. * @param sink The sink name diff --git a/src/platform/windows/display.h b/src/platform/windows/display.h index 3e035490..f37dd3d7 100644 --- a/src/platform/windows/display.h +++ b/src/platform/windows/display.h @@ -23,7 +23,7 @@ namespace platf::dxgi { // Add D3D11_CREATE_DEVICE_DEBUG here to enable the D3D11 debug runtime. // You should have a debugger like WinDbg attached to receive debug messages. - auto constexpr D3D11_CREATE_DEVICE_FLAGS = D3D11_CREATE_DEVICE_VIDEO_SUPPORT; + auto constexpr D3D11_CREATE_DEVICE_FLAGS = 0; template void diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp index 3239c68b..cdce7962 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -9,10 +9,22 @@ #include #include +#include + // We have to include boost/process/v1.hpp before display.h due to WinSock.h, // but that prevents the definition of NTSTATUS so we must define it ourself. typedef long NTSTATUS; +// Definition from the WDK's d3dkmthk.h +typedef enum _D3DKMT_GPU_PREFERENCE_QUERY_STATE: DWORD { + D3DKMT_GPU_PREFERENCE_STATE_UNINITIALIZED, ///< The GPU preference isn't initialized. + D3DKMT_GPU_PREFERENCE_STATE_HIGH_PERFORMANCE, ///< The highest performing GPU is preferred. + D3DKMT_GPU_PREFERENCE_STATE_MINIMUM_POWER, ///< The minimum-powered GPU is preferred. + D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED, ///< A GPU preference isn't specified. + D3DKMT_GPU_PREFERENCE_STATE_NOT_FOUND, ///< A GPU preference isn't found. + D3DKMT_GPU_PREFERENCE_STATE_USER_SPECIFIED_GPU ///< A specific GPU is preferred. +} D3DKMT_GPU_PREFERENCE_QUERY_STATE; + #include "display.h" #include "misc.h" #include "src/config.h" @@ -329,111 +341,6 @@ namespace platf::dxgi { return capture_e::ok; } - bool - set_gpu_preference_on_self(int preference) { - // The GPU preferences key uses app path as the value name. - WCHAR sunshine_path[MAX_PATH]; - GetModuleFileNameW(NULL, sunshine_path, ARRAYSIZE(sunshine_path)); - - WCHAR value_data[128]; - swprintf_s(value_data, L"GpuPreference=%d;", preference); - - auto status = RegSetKeyValueW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\DirectX\\UserGpuPreferences", - sunshine_path, - REG_SZ, - value_data, - (wcslen(value_data) + 1) * sizeof(WCHAR)); - if (status != ERROR_SUCCESS) { - BOOST_LOG(error) << "Failed to set GPU preference: "sv << status; - return false; - } - - BOOST_LOG(info) << "Set GPU preference: "sv << preference; - return true; - } - - bool - validate_and_test_gpu_preference(const std::string &display_name, bool verify_frame_capture) { - std::string cmd = "tools\\ddprobe.exe"; - - // We start at 1 because 0 is automatic selection which can be overridden by - // the GPU driver control panel options. Since ddprobe.exe can have different - // GPU driver overrides than Sunshine.exe, we want to avoid a scenario where - // autoselection might work for ddprobe.exe but not for us. - for (int i = 1; i < 5; i++) { - // Run the probe tool. It returns the status of DuplicateOutput(). - // - // Arg format: [GPU preference] [Display name] [--verify-frame-capture] - HRESULT result; - std::vector args = { std::to_string(i), display_name }; - try { - if (verify_frame_capture) { - args.emplace_back("--verify-frame-capture"); - } - result = bp::system(cmd, bp::args(args), bp::std_out > bp::null, bp::std_err > bp::null); - } - catch (bp::process_error &e) { - BOOST_LOG(error) << "Failed to start ddprobe.exe: "sv << e.what(); - return false; - } - - BOOST_LOG(info) << "ddprobe.exe " << boost::algorithm::join(args, " ") << " returned 0x" - << util::hex(result).to_string_view(); - - // E_ACCESSDENIED can happen at the login screen. If we get this error, - // we know capture would have been supported, because DXGI_ERROR_UNSUPPORTED - // would have been raised first if it wasn't. - if (result == S_OK || result == E_ACCESSDENIED) { - // We found a working GPU preference, so set ourselves to use that. - if (set_gpu_preference_on_self(i)) { - return true; - } - else { - return false; - } - } - } - - // If no valid configuration was found, return false - return false; - } - - // On hybrid graphics systems, Windows will change the order of GPUs reported by - // DXGI in accordance with the user's GPU preference. If the selected GPU is a - // render-only device with no displays, DXGI will add virtual outputs to the - // that device to avoid confusing applications. While this works properly for most - // applications, it breaks the Desktop Duplication API because DXGI doesn't proxy - // the virtual DXGIOutput to the real GPU it is attached to. When trying to call - // DuplicateOutput() on one of these virtual outputs, it fails with DXGI_ERROR_UNSUPPORTED - // (even if you try sneaky stuff like passing the ID3D11Device for the iGPU and the - // virtual DXGIOutput from the dGPU). Because the GPU preference is once-per-process, - // we spawn a helper tool to probe for us before we set our own GPU preference. - bool - probe_for_gpu_preference(const std::string &display_name) { - static bool set_gpu_preference = false; - - // If we've already been through here, there's nothing to do this time. - if (set_gpu_preference) { - return true; - } - - // Try probing with different GPU preferences and verify_frame_capture flag - if (validate_and_test_gpu_preference(display_name, true)) { - set_gpu_preference = true; - return true; - } - - // If no valid configuration was found, try again with verify_frame_capture == false - if (validate_and_test_gpu_preference(display_name, false)) { - set_gpu_preference = true; - return true; - } - - // If neither worked, return false - return false; - } - /** * @brief Tests to determine if the Desktop Duplication API can capture the given output. * @details When testing for enumeration only, we avoid resyncing the thread desktop. @@ -506,6 +413,27 @@ namespace platf::dxgi { return false; } + /** + * @brief Hook for NtGdiDdDDIGetCachedHybridQueryValue() from win32u.dll. + * @param gpuPreference A pointer to the location where the preference will be written. + * @return Always STATUS_SUCCESS if valid arguments are provided. + */ + NTSTATUS + __stdcall NtGdiDdDDIGetCachedHybridQueryValueHook(D3DKMT_GPU_PREFERENCE_QUERY_STATE *gpuPreference) { + // By faking a cached GPU preference state of D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED, this will + // prevent DXGI from performing the normal GPU preference resolution that looks at the registry, + // power settings, and the hybrid adapter DDI interface to pick a GPU. Instead, we will not be + // bound to any specific GPU. This will prevent DXGI from performing output reparenting (moving + // outputs from their true location to the render GPU), which breaks DDA. + if (gpuPreference) { + *gpuPreference = D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED; + return 0; // STATUS_SUCCESS + } + else { + return STATUS_INVALID_PARAMETER; + } + } + int display_base_t::init(const ::video::config_t &config, const std::string &display_name) { std::once_flag windows_cpp_once_flag; @@ -515,13 +443,22 @@ namespace platf::dxgi { typedef BOOL (*User32_SetProcessDpiAwarenessContext)(DPI_AWARENESS_CONTEXT value); - auto user32 = LoadLibraryA("user32.dll"); - auto f = (User32_SetProcessDpiAwarenessContext) GetProcAddress(user32, "SetProcessDpiAwarenessContext"); - if (f) { - f(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + { + auto user32 = LoadLibraryA("user32.dll"); + auto f = (User32_SetProcessDpiAwarenessContext) GetProcAddress(user32, "SetProcessDpiAwarenessContext"); + if (f) { + f(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + + FreeLibrary(user32); } - FreeLibrary(user32); + { + // We aren't calling MH_Uninitialize(), but that's okay because this hook lasts for the life of the process + MH_Initialize(); + MH_CreateHookApi(L"win32u.dll", "NtGdiDdDDIGetCachedHybridQueryValue", (void *) NtGdiDdDDIGetCachedHybridQueryValueHook, nullptr); + MH_EnableHook(MH_ALL_HOOKS); + } }); // Get rectangle of full desktop for absolute mouse coordinates @@ -530,11 +467,6 @@ namespace platf::dxgi { HRESULT status; - // We must set the GPU preference before calling any DXGI APIs! - if (!probe_for_gpu_preference(display_name)) { - BOOST_LOG(warning) << "Failed to set GPU preference. Capture may not work!"sv; - } - status = CreateDXGIFactory1(IID_IDXGIFactory1, (void **) &factory); if (FAILED(status)) { BOOST_LOG(error) << "Failed to create DXGIFactory1 [0x"sv << util::hex(status).to_string_view() << ']'; @@ -1101,12 +1033,6 @@ namespace platf { BOOST_LOG(debug) << "Detecting monitors..."sv; - // We must set the GPU preference before calling any DXGI APIs! - const auto output_name { display_device::map_output_name(config::video.output_name) }; - if (!dxgi::probe_for_gpu_preference(output_name)) { - BOOST_LOG(warning) << "Failed to set GPU preference. Capture may not work!"sv; - } - // We sync the thread desktop once before we start the enumeration process // to ensure test_dxgi_duplication() returns consistent results for all GPUs // even if the current desktop changes during our enumeration process. diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index ba2b0685..bb7ad2bd 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -760,7 +760,7 @@ namespace platf::dxgi { adapter_p, D3D_DRIVER_TYPE_UNKNOWN, nullptr, - D3D11_CREATE_DEVICE_FLAGS, + D3D11_CREATE_DEVICE_FLAGS | D3D11_CREATE_DEVICE_VIDEO_SUPPORT, featureLevels, sizeof(featureLevels) / sizeof(D3D_FEATURE_LEVEL), D3D11_SDK_VERSION, &device, diff --git a/src/process.cpp b/src/process.cpp index 6225491e..f189c1f3 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -23,6 +23,7 @@ #include "config.h" #include "crypto.h" +#include "display_device.h" #include "logging.h" #include "platform/common.h" #include "httpcommon.h" @@ -559,16 +560,18 @@ namespace proc { } #endif -#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 bool has_run = _app_id > 0; // Only show the Stopped notification if we actually have an app to stop // Since terminate() is always run when a new app has started if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { +#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_stopped(proc::proc.get_last_run_app_name()); - } #endif + display_device::revert_configuration(); + } + _app_id = -1; display_name.clear(); initial_hdr = false; diff --git a/src/stream.cpp b/src/stream.cpp index 06466bac..9ec5fc31 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -21,6 +21,7 @@ extern "C" { #include "config.h" #include "crypto.h" +#include "display_device.h" #include "globals.h" #include "input.h" #include "logging.h" @@ -2077,11 +2078,15 @@ namespace stream { // If this is the last session, invoke the platform callbacks if (--running_sessions == 0) { -#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 if (proc::proc.running()) { +#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_pausing(proc::proc.get_last_run_app_name()); - } #endif + } + else { + display_device::revert_configuration(); + } + platf::streaming_will_stop(); } diff --git a/src_assets/common/assets/web/Checkbox.vue b/src_assets/common/assets/web/Checkbox.vue new file mode 100644 index 00000000..b94446d3 --- /dev/null +++ b/src_assets/common/assets/web/Checkbox.vue @@ -0,0 +1,120 @@ + + + diff --git a/src_assets/common/assets/web/apps.html b/src_assets/common/assets/web/apps.html index e14593b0..589e8451 100644 --- a/src_assets/common/assets/web/apps.html +++ b/src_assets/common/assets/web/apps.html @@ -151,15 +151,13 @@
{{ $t('apps.image_desc') }}
-
- - -
{{ $t('apps.global_prep_desc') }}
-
+
{{ $t('apps.cmd_prep_desc') }}
@@ -187,12 +185,12 @@
-
Sunshine
-
- - -
+
+