diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 2862f55729..05b88c36cf 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -17,6 +17,25 @@ body: validations: required: true + - type: dropdown + id: issue-area + attributes: + label: Issue Area + description: Select the closest area. Pick the most visible symptom if more than one applies. + options: + - Pairing / client connection + - Display / virtual display / HDR + - Frame pacing / stutter / bitrate + - Audio / channel layout / delay + - Controller / gamepad / input + - Cursor / mouse visibility + - Playnite / app launch / app exit + - Installer / upgrade / uninstall + - Web UI / API + - Other + validations: + required: true + - type: dropdown id: host-os attributes: @@ -37,7 +56,7 @@ body: id: host-gpu attributes: label: Host GPU and Driver - description: Example: RTX 4080, NVIDIA 576.xx. + description: "Example: RTX 4080, NVIDIA 576.xx." validations: required: true @@ -45,10 +64,42 @@ body: id: client attributes: label: Client Device and App - description: Example: NVIDIA Shield TV Pro, Artemis v20.2.6, 4K 120 Hz HEVC HDR. + description: "Example: NVIDIA Shield TV Pro, Artemis v20.2.6, 4K 120 Hz HEVC HDR." validations: required: true + - type: input + id: stream-settings + attributes: + label: Stream Settings + description: "Example: 3840x2160, 60 FPS, HEVC, HDR off, 80 Mbps." + validations: + required: false + + - type: input + id: display-setup + attributes: + label: Host Display Setup + description: "Example: physical LG OLED, SudoVDA virtual display, dummy plug, headless VM." + validations: + required: false + + - type: input + id: network-path + attributes: + label: Network Path + description: "Example: wired LAN, Wi-Fi 6, Tailscale direct, Tailscale relay, WireGuard, WAN." + validations: + required: false + + - type: textarea + id: input-audio + attributes: + label: Input And Audio Notes + description: Mention gamepads, mouse/cursor behavior, audio sink, stereo/5.1/7.1, Bluetooth, eARC, or soundbar details if relevant. + validations: + required: false + - type: textarea id: reproduction attributes: diff --git a/.github/workflows/environment-specific-closure.yml b/.github/workflows/environment-specific-closure.yml index 6de309f7b1..e7e69df41b 100644 --- a/.github/workflows/environment-specific-closure.yml +++ b/.github/workflows/environment-specific-closure.yml @@ -10,7 +10,10 @@ on: jobs: close: name: Close Environment-Specific Issue + # Disabled during Nimbus bootstrap. Re-enable only after the public support + # policy clearly defines when maintainers close environment-specific issues. if: >- + false && github.event.action == 'labeled' && github.event.label.name == 'environment-specific' && github.event.issue.state == 'open' && diff --git a/.github/workflows/logs-needed-closure.yml b/.github/workflows/logs-needed-closure.yml index c6cb46a4c2..cf4224a846 100644 --- a/.github/workflows/logs-needed-closure.yml +++ b/.github/workflows/logs-needed-closure.yml @@ -13,6 +13,9 @@ concurrency: jobs: close: + # Disabled during Nimbus bootstrap. Re-enable only after the issue-support + # policy, labels, and maintainer response expectations are Nimbus-owned. + if: false runs-on: ubuntu-latest permissions: issues: write diff --git a/.gitignore b/.gitignore index f2e2183890..ec8c593a84 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ # VSCode IDE .vscode/ +# Local switch/export bundles may contain credentials, certificates, and keys. +nimbus-switch-bundles/ + # build directories build/ cmake-*/ diff --git a/README.md b/README.md index fb34e2d994..a7b5d5fc0e 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,15 @@ Early Nimbus work is focused on: - Preparing the future Lucent client track. See [ROADMAP.md](ROADMAP.md) for the current maintainer roadmap. +See [docs/community-research.md](docs/community-research.md) for the community +setup research behind Setup Doctor, Client Profiles, and future fixture work. +See [docs/streaming-recipes.md](docs/streaming-recipes.md) and +[docs/fixture-matrix.md](docs/fixture-matrix.md) for the first repeatable +testing surfaces. +See [docs/apollo-to-nimbus-switch.md](docs/apollo-to-nimbus-switch.md) before +testing Nimbus on a host that already runs Apollo. +See [docs/ui-identity-plan.md](docs/ui-identity-plan.md) for the staged Nimbus +Web UI identity direction. ## Building diff --git a/ROADMAP.md b/ROADMAP.md index 47efc6d504..f3a2dd237f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,6 +42,8 @@ Goal: build maintainer credibility through user-visible stability work. - Prioritize Windows 11, NVIDIA Shield TV Pro, Android TV, and Moonlight/Artemis compatibility reports. +- Turn community setup pain into guided diagnostics, starting with Setup Doctor, + Client Profiles, Cursor Doctor, and a fixture matrix. - Improve logs and diagnostics before changing capture behavior. - Create reproducible issue templates for frame pacing, WGC, virtual display, HDR, audio, controller, and Playnite issues. @@ -72,7 +74,8 @@ The next engineering pass should validate the package-branding slice from `docs/packaging-identity-plan.md` before any branded Nimbus release: 1. Use `docs/release-build-audit.md` as the release automation baseline. -2. Use `docs/upstream-issue-radar.md` to choose the first reliability issue. +2. Use `docs/community-research.md` and `docs/upstream-issue-radar.md` to choose + the first reliability issue. 3. Confirm runtime identifiers remained unchanged unless the packaging identity plan says the migration has a test path. 4. Produce a local Nimbus-named installer and inspect install, upgrade, and diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index da28b6249a..dd3ac50079 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -200,7 +200,7 @@ if(${SUNSHINE_ENABLE_TRAY}) if(${SUNSHINE_BUILD_FLATPAK}) set(SUNSHINE_TRAY_PREFIX "${PROJECT_FQDN}") else() - set(SUNSHINE_TRAY_PREFIX "apollo") + set(SUNSHINE_TRAY_PREFIX "nimbus") endif() list(APPEND SUNSHINE_DEFINITIONS SUNSHINE_TRAY_PREFIX="${SUNSHINE_TRAY_PREFIX}") else() diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index cf02c63c9f..043f424dd1 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -34,9 +34,9 @@ file(GLOB NVPREFS_FILES CONFIGURE_DEPENDS include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include") include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/sudovda") -# apollo icon +# Nimbus icon if(NOT DEFINED PROJECT_ICON_PATH) - set(PROJECT_ICON_PATH "${CMAKE_SOURCE_DIR}/apollo.ico") + set(PROJECT_ICON_PATH "${CMAKE_SOURCE_DIR}/nimbus.ico") endif() list(APPEND SUNSHINE_DEFINITIONS PROJECT_APP_USER_MODEL_ID="${WINDOWS_APP_USER_MODEL_ID}") diff --git a/cmake/packaging/common.cmake b/cmake/packaging/common.cmake index b9690ea074..7b7e3b520d 100644 --- a/cmake/packaging/common.cmake +++ b/cmake/packaging/common.cmake @@ -13,7 +13,7 @@ set(CPACK_PACKAGE_CONTACT "https://github.com/kunolabs/Nimbus/issues") set(CPACK_PACKAGE_DESCRIPTION ${CMAKE_PROJECT_DESCRIPTION}) set(CPACK_PACKAGE_HOMEPAGE_URL ${CMAKE_PROJECT_HOMEPAGE_URL}) set(CPACK_RESOURCE_FILE_LICENSE ${PROJECT_SOURCE_DIR}/LICENSE) -set(CPACK_PACKAGE_ICON ${PROJECT_SOURCE_DIR}/sunshine.png) +set(CPACK_PACKAGE_ICON ${PROJECT_SOURCE_DIR}/nimbus.png) # Ensure the generated installer filename uses the branded name set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}") set(CPACK_STRIP_FILES YES) diff --git a/cmake/packaging/linux.cmake b/cmake/packaging/linux.cmake index e59663bf97..d72e6667dc 100644 --- a/cmake/packaging/linux.cmake +++ b/cmake/packaging/linux.cmake @@ -98,10 +98,10 @@ set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) # application icon if(NOT ${SUNSHINE_BUILD_FLATPAK}) - install(FILES "${CMAKE_SOURCE_DIR}/apollo.svg" + install(FILES "${CMAKE_SOURCE_DIR}/nimbus.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps") else() - install(FILES "${CMAKE_SOURCE_DIR}/apollo.svg" + install(FILES "${CMAKE_SOURCE_DIR}/nimbus.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps" RENAME "${PROJECT_FQDN}.svg") endif() @@ -109,27 +109,27 @@ endif() # tray icon if(${SUNSHINE_TRAY} STREQUAL 1) if(NOT ${SUNSHINE_BUILD_FLATPAK}) - install(FILES "${CMAKE_SOURCE_DIR}/apollo.svg" + install(FILES "${CMAKE_SOURCE_DIR}/nimbus.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status" - RENAME "apollo-tray.svg") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-playing.svg" + RENAME "nimbus-tray.svg") + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-playing.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-pausing.svg" + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-pausing.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-locked.svg" + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-locked.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status") else() # flatpak icons must be prefixed with the app id or they will not be included in the flatpak - install(FILES "${CMAKE_SOURCE_DIR}/apollo.svg" + install(FILES "${CMAKE_SOURCE_DIR}/nimbus.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status" RENAME "${PROJECT_FQDN}-tray.svg") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-playing.svg" + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-playing.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status" RENAME "${PROJECT_FQDN}-playing.svg") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-pausing.svg" + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-pausing.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status" RENAME "${PROJECT_FQDN}-pausing.svg") - install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/apollo-locked.svg" + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web/public/images/nimbus-locked.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/status" RENAME "${PROJECT_FQDN}-locked.svg") endif() diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index c5f2da38a4..7ae94af248 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -116,11 +116,10 @@ if(NOT EXISTS "${CMAKE_BINARY_DIR}/assets/shaders") execute_process(COMMAND cmd.exe /c mklink /J "${shaders_in_build_dest_native}" "${shaders_in_build_src_native}") endif() -set(CPACK_PACKAGE_ICON "${CMAKE_SOURCE_DIR}\\\\apollo.ico") +set(CPACK_PACKAGE_ICON "${CMAKE_SOURCE_DIR}\\\\nimbus.ico") # The name of the directory that will be created in C:/Program Files/ -# Match the legacy NSIS layout by installing under Apollo -set(CPACK_PACKAGE_INSTALL_DIRECTORY "Apollo") +set(CPACK_PACKAGE_INSTALL_DIRECTORY "Nimbus") # Setting components groups and dependencies set(CPACK_COMPONENT_GROUP_CORE_EXPANDED true) diff --git a/cmake/packaging/windows_wix.cmake b/cmake/packaging/windows_wix.cmake index 3571555ab9..b6ecdd477d 100644 --- a/cmake/packaging/windows_wix.cmake +++ b/cmake/packaging/windows_wix.cmake @@ -5,7 +5,7 @@ set(CPACK_GENERATOR "WIX") # Product identity and visuals -set(CPACK_WIX_PRODUCT_ICON "${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/images/apollo.ico") +set(CPACK_WIX_PRODUCT_ICON "${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/images/nimbus.ico") set(CPACK_WIX_PROGRAM_MENU_FOLDER "Nimbus") # Stable Upgrade GUID to enable in-place upgrades diff --git a/cmake/targets/common.cmake b/cmake/targets/common.cmake index 87013b8bd4..0e99257028 100644 --- a/cmake/targets/common.cmake +++ b/cmake/targets/common.cmake @@ -57,8 +57,18 @@ endif() # Default layout: ${CMAKE_SOURCE_DIR}/src_assets/common/assets/web set(WEB_UI_DIR "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web") -#WebUI build -find_program(NPM npm REQUIRED) +# WebUI build. On Windows prefer the Node.js launcher from the standard +# installer; MSYS npm can fail when invoked through cmd.exe during packaging. +if(WIN32) + find_program(NPM npm.cmd + HINTS "$ENV{ProgramFiles}/nodejs" + NO_DEFAULT_PATH) + if(NOT NPM) + find_program(NPM NAMES npm.cmd npm REQUIRED) + endif() +else() + find_program(NPM npm REQUIRED) +endif() set(NPM_INSTALL_FLAGS --ignore-scripts @@ -69,6 +79,7 @@ set(NPM_INSTALL_FLAGS if (NPM_OFFLINE) list(APPEND NPM_INSTALL_FLAGS --offline) endif() +set(NPM_CACHE_DIR "${CMAKE_BINARY_DIR}/npm-cache") # Choose web UI build mode based on active CMake configuration. # In Debug config, build Vite in "debug" mode to enable Vue devtools. @@ -83,8 +94,11 @@ set(NPM_BUILD_NODE_OPTIONS "") add_custom_target(web-ui ALL WORKING_DIRECTORY "${WEB_UI_DIR}" COMMENT "Installing NPM dependencies and building the Web UI" - COMMAND "$<$:cmd;/C>" "${NPM}" ci ${NPM_INSTALL_FLAGS} COMMAND "${CMAKE_COMMAND}" -E env + "NPM_CONFIG_CACHE=${NPM_CACHE_DIR}" + "$<$:cmd;/C>" "${NPM}" ci ${NPM_INSTALL_FLAGS} + COMMAND "${CMAKE_COMMAND}" -E env + "NPM_CONFIG_CACHE=${NPM_CACHE_DIR}" "SUNSHINE_BUILD_HOMEBREW=${NPM_BUILD_HOMEBREW}" "SUNSHINE_SOURCE_ASSETS_DIR=${NPM_SOURCE_ASSETS_DIR}" "SUNSHINE_ASSETS_DIR=${NPM_ASSETS_DIR}" diff --git a/cmake/targets/windows.cmake b/cmake/targets/windows.cmake index f2bd569d32..245e5a08b9 100644 --- a/cmake/targets/windows.cmake +++ b/cmake/targets/windows.cmake @@ -62,7 +62,7 @@ add_custom_command( "${CMAKE_SOURCE_DIR}/packaging/windows/bootstrapper/VibeshineInstaller.cs" "${CMAKE_SOURCE_DIR}/packaging/windows/bootstrapper/app.manifest" "${CMAKE_SOURCE_DIR}/LICENSE" - "${CMAKE_SOURCE_DIR}/apollo.ico" + "${CMAKE_SOURCE_DIR}/nimbus.ico" generate_windows_versioninfo COMMENT "Building lightweight Nimbus uninstaller UI" ) diff --git a/docs/apollo-to-nimbus-switch.md b/docs/apollo-to-nimbus-switch.md new file mode 100644 index 0000000000..288a0e93ee --- /dev/null +++ b/docs/apollo-to-nimbus-switch.md @@ -0,0 +1,150 @@ +# Apollo To Nimbus Switch Guide + +Snapshot date: 2026-05-24 + +This guide is for maintainers and early testers who already use Apollo and want +to try Nimbus without losing apps, Web UI credentials, paired clients, covers, +or configuration history. + +Nimbus is still in alpha-prep. Do not run the first switch attempt on a +daily-use host unless you have a rollback path. + +## What Can Be Carried Over + +Apollo, Vibepollo, and Nimbus share the same inherited configuration shape for +the first Nimbus alpha: + +| Item | Usually stored in | Why it matters | +| --- | --- | --- | +| Apps | `apps.json` | Custom apps, Playnite-synced entries, images, launch commands. | +| Host settings | `sunshine.conf` | Display, encoder, audio, network, Playnite, RTSS, and app behavior. | +| Web UI users and state | `sunshine_state.json` | Web UI credentials, paired-client state, and host state. | +| Compatibility state | `vibeshine_state.json` | Vibepollo-era state if present. | +| Client certificates and keys | `credentials/` | Pairing identity and HTTPS/GameStream certificates. | +| Covers | `covers/` | App artwork referenced by `apps.json`. | + +The switch bundle may contain password hashes, private keys, client +certificates, and device identity data. Treat it as sensitive. + +## Current Installer Reality + +The Nimbus installer can detect Apollo and uninstall it before installing +Nimbus. That is required because the projects share inherited service and +upgrade identity. + +However, MSI-style Apollo to Nimbus config carry-over is not yet a proven +automatic migration. A safe alpha switch should create a separate Apollo config +bundle first, then import it into Nimbus only after the new install is present +and the Nimbus service is stopped. + +## Recommended Switch Flow + +```mermaid +flowchart TD + A["Apollo daily host"] --> B["Export Apollo switch bundle"] + B --> C["Record Apollo version and config path"] + C --> D["Install Nimbus candidate"] + D --> E["Stop Nimbus service"] + E --> F["Import Apollo bundle into Nimbus config"] + F --> G["Start Nimbus service"] + G --> H["Open Web UI and confirm apps"] + H --> I["Pair/stream Shield TV smoke"] + I --> J{"Good for one week?"} + J -- "yes" --> K["Promote Nimbus to daily host"] + J -- "no" --> L["Rollback to Apollo backup"] +``` + +## Export Apollo Config + +Run this from an elevated PowerShell window before uninstalling Apollo: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\collect_apollo_switch_bundle.ps1 +``` + +The script looks for Apollo in the registry, common install/config paths, and +drive-root portable installs such as `S:\Apollo\config`. It writes a +timestamped, gitignored bundle under `nimbus-switch-bundles/` with: + +- `config/`: copied Apollo config files and folders. +- `switch-bundle-summary.md`: human-readable summary. +- `switch-bundle-manifest.json`: file inventory and SHA256 hashes. + +If Apollo uses a non-standard config path, pass it explicitly: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\collect_apollo_switch_bundle.ps1 ` + -SourceConfigDir "S:\Apollo\config" +``` + +Keep old Sunshine backups, such as `S:\Sunshine`, separate from this Apollo +switch bundle unless you are deliberately exporting Sunshine instead of Apollo. + +## Import Into Nimbus + +After installing Nimbus, stop the service before importing: + +```powershell +Stop-Service ApolloService -ErrorAction SilentlyContinue +``` + +Then import from the Apollo source or exported bundle config into Nimbus: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\collect_apollo_switch_bundle.ps1 ` + -SourceConfigDir ".\nimbus-switch-bundles\-apollo-to-nimbus\config" ` + -NimbusConfigDir "C:\Program Files\Nimbus\config" ` + -ImportToNimbus ` + -Force +``` + +Restart Nimbus: + +```powershell +Start-Service ApolloService +``` + +`-Force` allows known config files and folders in the Nimbus config directory to +be replaced. The script backs up existing Nimbus copies into the same switch +bundle before replacement. + +During import, absolute Apollo cover paths in `apps.json` are rewritten to the +target Nimbus `config\covers` directory. + +## What To Check After Import + +- Web UI opens at `https://localhost:47990`. +- Apps page contains your expected Apollo apps. +- Covers load or fail gracefully. +- Clients page shows expected paired devices, or pairing still works if you + intentionally skipped credentials. +- Shield TV / Artemis can pair, start a short stream, and disconnect cleanly. +- General display, virtual display, HDR, audio, and Playnite settings still look + sane. + +## Rollback + +If Nimbus is not ready: + +1. Export the current Nimbus config with the same switch script. +2. Uninstall Nimbus without factory reset unless you intentionally want to wipe + its config. +3. Reinstall Apollo. +4. Restore the saved Apollo bundle into Apollo's config directory. +5. Start Apollo and verify apps, clients, and streaming. + +Do not delete the original Apollo switch bundle until Nimbus has been your +daily host for at least a week. + +## Release Gate + +The first Nimbus alpha should not claim seamless Apollo migration until this +flow has been tested on a physical host: + +- Apollo installed and working. +- Apollo switch bundle exported. +- Nimbus installed to its own directory, such as `C:\Program Files\Nimbus` or + `S:\Nimbus`, not reused from Apollo or Sunshine. +- Apollo bundle imported into Nimbus. +- Apps, settings, credentials, pairing, stream start, and disconnect verified. +- Rollback path recorded. diff --git a/docs/building.md b/docs/building.md index 5ead524a69..1c8ade0744 100644 --- a/docs/building.md +++ b/docs/building.md @@ -120,6 +120,21 @@ The `package_installer` target uses CPack's WiX generator and requires WiX Tools When building from MSYS2, configure CMake with Windows Git to keep generated version metadata clean: `-DGIT_EXECUTABLE=C:/Progra~1/Git/cmd/git.exe`. +CPack WiX performs ICE validation through Windows Installer services. If the +package build reaches WiX but fails in a sandboxed shell because the Windows +Installer service cannot be accessed, rerun the same package command from a +normal PowerShell or approved unsandboxed Codex command. Do not disable ICE +validation for release-candidate evidence. + +On Windows, Nimbus prefers the standard Node.js `npm.cmd` launcher for the CMake web UI package step and stores npm +cache data under the CMake build directory. If a build tree was configured before that change, clear the cached npm +path before packaging: + +```powershell +$env:PATH = 'C:\msys64\ucrt64\bin;' + $env:PATH +cmake -S . -B build -UNPM +``` + ##### WebRTC (optional, Windows only) Nimbus can link against the libwebrtc C++ wrapper when `SUNSHINE_ENABLE_WEBRTC=ON`. The option name is inherited from upstream and has not been renamed yet. The wrapper source is vendored as diff --git a/docs/client-controls.md b/docs/client-controls.md new file mode 100644 index 0000000000..6185f20fa1 --- /dev/null +++ b/docs/client-controls.md @@ -0,0 +1,49 @@ +# Client Controls + +Nimbus keeps compatibility with Moonlight-compatible clients while exposing a +few host-side controls for pairing, permissions, display overrides, and client +commands. + +## Permission System + +Each paired client can be allowed or restricted from sensitive host actions. Use +the PIN page to review paired devices, remove a single device, or unpair all +devices when access should be reset. + +Recommended maintainer posture: + +- Treat unknown paired devices as untrusted. +- Unpair devices before sharing logs, fixtures, or VM images. +- Re-pair clients after major host, certificate, or credential changes. + +## Display Mode Override + +Display mode override lets a client request a fixed resolution and refresh rate +for streams where the automatic client-requested mode is not the desired host +mode. + +Use the format: + +```text +1920x1080x60 +``` + +Leave the field blank to use the client's requested mode. Record any override +used in bug reports, because display selection, HDR, virtual displays, and +physical monitor recovery can all affect the final stream mode. + +## Client Commands + +Client commands allow a paired client to trigger configured host commands when a +session connects or disconnects. + +Use this carefully: + +- Keep commands local and explicit. +- Avoid commands that depend on private paths when sharing configurations. +- Prefer scripts stored beside the app or inside a documented tools directory. +- Disable client commands for devices that should only launch apps. + +Nimbus inherits much of this behavior from the Apollo and Sunshine lineage. This +page is the Nimbus-owned reference surface for these controls while deeper docs +are rebuilt. diff --git a/docs/community-research.md b/docs/community-research.md new file mode 100644 index 0000000000..28d24e0c54 --- /dev/null +++ b/docs/community-research.md @@ -0,0 +1,214 @@ +# Nimbus Community Research + +Snapshot date: 2026-05-23 + +This document turns community setup patterns into Nimbus product direction. It +is not a promise that Nimbus will replace every upstream feature. The goal is to +identify where Nimbus can be meaningfully different from Vibepollo, Apollo, and +Sunshine while staying useful to Moonlight-compatible clients. + +## Positioning + +Nimbus should become the guided, validated, profile-driven host fork: + +> Tell Nimbus what you stream to, and Nimbus tells you the correct display, +> bitrate, audio, controller, and network setup. If something is wrong, Nimbus +> explains why. + +That is different from simply adding more power-user toggles. Vibepollo already +leans into fast feature expansion around Playnite, RTSS/NVIDIA Control Panel, +WGC/frame-generation workarounds, Lossless Scaling, WebRTC, API tokens, and +session auth. Nimbus can stand out by making the messy community setup ritual +inspectable, repeatable, and easier to support. + +```mermaid +flowchart LR + A["Community setup pain"] --> B["Nimbus Setup Doctor"] + A --> C["Client Profiles"] + A --> D["Streaming Recipes"] + A --> E["Fixture Matrix"] + + B --> F["Trustworthy host fork"] + C --> F + D --> F + E --> F +``` + +## Observed Setup Clusters + +| Cluster | Typical Shape | Nimbus Opportunity | +| --- | --- | --- | +| Living-room console | Windows host to Shield TV, Android TV, Xbox, or mini PC at 4K60/4K120 | Presets for TV clients, controller-first apps, and safe cursor handling. | +| Handheld | Steam Deck, phones, tablets, Logitech G Cloud, 720p/800p/1200p/90Hz | Device profiles and bitrate/refresh recommendations. | +| OLED/HDR enthusiast | 4K120 HDR, dummy plugs, virtual displays, AV1/HEVC, EDID tuning | HDR/display doctor and validated 10-bit/HDR checks. | +| Headless host | No monitor, SudoVDA/VDD, dummy HDMI/DP, VM/GPU passthrough | Virtual-display lifecycle diagnostics and recovery warnings. | +| Remote home/cloud | Tailscale, WireGuard, ZeroTier, WAN pairing, mobile networks | Network path diagnostics and support boundaries. | +| Library launcher | Playnite, Steam Big Picture, LaunchBox, emulators, Xbox/Game Pass | Library integration status, import safety, and clean launch/exit guidance. | +| Performance tuning | RTSS frame caps, fractional refresh, WGC/DXGI, Lossless Scaling | Frame pacing assistant with observable reasons. | +| Family/local multiplayer | Multiple controllers on Android TV/Shield/Google TV | Controller doctor and Player 1 conflict checks. | + +## Repeated Pain Points + +### Display And HDR + +Users often bounce between SudoVDA, MikeTheTech VDD, dummy plugs, physical +monitors, EDID exports, HDR calibration, and app-specific display overrides. +The real pain is not only whether a setting exists, but whether the user knows +which display Nimbus is capturing, which display was changed, and whether the +restore path is trustworthy. + +### Frame Pacing + +Frame pacing advice is scattered across wikis and forum comments: 59.94 vs 60, +119.88 vs 120, RTSS caps, WGC vs DXGI behavior, VRR/G-Sync, Wi-Fi power-saving, +and client-native refresh. Nimbus should surface this as a guided check instead +of leaving users to infer it from symptoms. + +### Audio + +Stereo, 5.1, 7.1, Steam Streaming Speakers, virtual sinks, eARC/soundbars, +Bluetooth delay, and app-specific routing are recurring support traps. Nimbus +should make audio mode, selected sink, and likely downmix paths easier to see. + +### Controllers + +ViGEmBus, host-attached physical controllers, player-order conflicts, multiple +controllers collapsing into one, Android TV mappings, Steam Input, and DualSense +features all create confusing failures. Nimbus needs a controller health view +before it needs more hidden input knobs. + +### Cursor Persistence + +The Windows cursor can remain visible, disappear, duplicate, offset, or be +confused with the client-side cursor. This appears across host/client streaming +stacks, especially with desktop mode, touchscreen/touchpad mode, Android +clients, HDR/virtual displays, and games that draw their own cursor. + +Nimbus should start with diagnostics and safe workarounds: + +- Explain the difference between host cursor, client cursor, and game cursor. +- Link to client-side cursor toggles where applicable. +- Offer per-app guidance for controller-first games. +- Consider a low-risk "move cursor to safe corner on launch" policy before any + invasive cursor-hiding behavior. + +### Network + +Users can often say "Tailscale works" or "stream failed," but not whether they +are direct, relayed, firewalled, blocked by RTSP, or using the wrong address. +Nimbus should eventually report network path hints, open ports, and support +boundaries for overlay networks. + +### Install And Update Trust + +Unsigned installers, UAC, driver installation, archived ViGEmBus status, +SudoVDA, service identity, and inherited project names affect trust. Nimbus +should keep release notes honest and make rollback/update behavior predictable. + +## What Existing Projects Already Cover + +| Project | Useful Coverage | Nimbus Lesson | +| --- | --- | --- | +| Sunshine | Cross-platform host, mature configuration docs, apps, network setup, audio/input knobs. | Do not duplicate docs blindly; add guided diagnostics around the knobs. | +| Apollo | SudoVDA-oriented virtual display, Artemis workflow, display override docs, stutter guidance. | Virtual display and frame pacing are primary host responsibilities. | +| Vibepollo | Playnite sync, RTSS/NVCP integration, WGC/frame-generation fixes, Lossless Scaling, WebRTC, scoped API tokens, session auth, update checks. | Avoid chasing every power-user toggle; focus on setup intelligence and validation. | +| Moonlight/Artemis | Client settings, controller behavior, native FPS, HDR, cursor toggles, platform-specific quirks. | Nimbus should stay compatible and explain client-side fixes without pretending Lucent exists yet. | +| Playnite | Library plugins, emulator auto-scan, metadata, covers, categories, exclusions. | Prefer Playnite for library truth; add a safer Discovery Inbox for non-Playnite users later. | + +## Feature Ranking + +| Rank | Feature | Impact | Effort | Risk | First Deliverable | +| ---: | --- | --- | --- | --- | --- | +| 1 | Nimbus Setup Doctor | Very high | Medium | Low | Read-only health report with display, encoder, driver, ports, logs, and warnings. | +| 2 | Client Profiles | Very high | Medium | Medium | Preset docs and UI labels for Shield TV, Steam Deck, Android TV, phone/tablet, mini PC. | +| 3 | Pairing deep-link UX | High | Low | Low | Toast opens Clients page at Pair Client section and focuses the PIN flow. | +| 4 | Streaming Recipe Library | High | Low | Low | Markdown recipes for common setups with known-good defaults. | +| 5 | Frame Pacing Assistant | High | Medium | Medium | Detect requested FPS vs display refresh and recommend safe caps. | +| 6 | Audio Routing Wizard | High | Medium | Low | Show selected sink, channel mode, and common downmix checks. | +| 7 | Controller Doctor | High | Medium | Low | Show ViGEm status, host controller warning, and multi-controller notes. | +| 8 | Safe Update/Rollback Channel | Medium-high | Medium | Low | Release channel docs, rollback notes, and service/driver preservation checks. | +| 9 | Launch Command Presets | Medium-high | Low-medium | Low | Preset picker for common detached-command and launcher flows. | +| 10 | Game Discovery Inbox | Medium-high | Medium | Medium | Read-only candidate list before any import writes to `apps.json`. | +| 11 | Community Fixture Matrix | Medium | Low-medium | Low | Public table of tested host/client/display/network combos. | + +## Launch Command Presets + +Community app entries often rely on detached commands instead of a primary +process, especially for Steam library entries, launcher-owned games, Playnite +flows, and stream keepalive setups. Nimbus currently exposes raw command fields, +but that makes users copy/paste fragile snippets from forum posts. + +Nimbus should add a QoL preset layer before the raw fields: + +- `Steam game`: accept an AppID, `steam://rungameid/...`, or Steam store URL and + generate the detached command. +- `Steam Big Picture`: launch Steam directly into the TV-oriented shell. +- `Playnite`: launch Playnite fullscreen or a known Playnite game entry. +- `Launcher URL`: keep URI-based Epic, Ubisoft Connect, EA App, and Battle.net + flows discoverable without hiding the generated command. +- `Stream keepalive`: explain detached-only behavior and sensible exit-timeout + defaults. +- `Power-user wrapper`: optional pre/post commands for RTSS, display/HDR setup, + and cleanup once those workflows have fixtures. + +The UI should always show the generated command before saving so experienced +users can inspect or edit it. Presets are helpers, not a replacement for the raw +command editor. + +## Game Discovery Inbox + +Vibepollo already covers a large Playnite-based import path. Nimbus should not +blindly scan every `.exe` and add false positives. The safer Nimbus version is a +review inbox: + +```mermaid +flowchart TD + A["Known sources"] --> B["Steam/Epic/GOG manifests"] + A --> C["Playnite library"] + A --> D["User-selected folders"] + D --> E["Heuristic executable candidates"] + B --> F["Discovery Inbox"] + C --> F + E --> F + F --> G["Select, deselect, all, none"] + G --> H["Import selected apps"] + H --> I["Undo last import batch"] +``` + +Candidate confidence should be explicit: + +- `High`: launcher manifest or Playnite entry with install metadata. +- `Medium`: known game folder pattern with one likely executable. +- `Low`: directory heuristic with multiple executables or launchers. + +The first implementation should be read-only and write no apps until the UI and +candidate scoring are reviewed. + +## Sources + +- [Vibepollo README](https://github.com/Nonary/Vibepollo) +- [Sunshine configuration docs](https://docs.lizardbyte.dev/projects/sunshine/master/md_docs_2configuration.html?lng=en-US) +- [Moonlight Setup Guide](https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide) +- [Moonlight Android releases](https://github.com/moonlight-stream/moonlight-android/releases) +- [Apollo README](https://github.com/ClassicOldSong/Apollo) +- [Apollo Display Mode Override](https://github.com/ClassicOldSong/Apollo/wiki/Display-Mode-Override) +- [Apollo Stuttering Clinic](https://github.com/ClassicOldSong/Apollo/wiki/Stuttering-Clinic) +- [Apollo Profile Manager](https://github.com/ClassicOldSong/ApolloProfileManager) +- [Vibepollo over Apollo discussion](https://www.reddit.com/r/MoonlightStreaming/comments/1r0p1g1/any_benefits_for_using_vibepollo_over_apollo/) +- [Switching to Vibepollo discussion](https://www.reddit.com/r/MoonlightStreaming/comments/1pu61b2/has_anyone_made_the_switch_from_using/) +- [Vibeshine vs Vibepollo discussion](https://www.reddit.com/r/MoonlightStreaming/comments/1qyhvgd/differences_between_vibeshine_vs_vibepollo_and/) +- [Sunshine cursor issue 1940](https://github.com/LizardByte/Sunshine/issues/1940) +- [Playnite PC auto-scan discussion](https://www.reddit.com/r/playnite/comments/18mokqc/is_auto_scan_of_pc_windows_games_possible/) +- [Playnite emulated game auto-scan docs](https://api.playnite.link/docs/manual/features/emulationSupport/addingEmulatedGames.html) + +## Next Actions + +1. Keep the public maintainer tracker for Setup Doctor and Client Profiles + honest: it is a maintainer planning issue, not a user-reported issue. +2. Keep the first implementation read-only wherever possible. +3. Add a Troubleshooting page cursor card as the first visible Setup Doctor seed. +4. Add Launch Command Presets to the QoL backlog before broad app-discovery + work; it is lower risk and immediately helps migrated Apollo libraries. +5. Create a fixture matrix after the next VM build and Shield TV stream test. +6. Treat upstream Vibepollo display/HDR/session fixes as sync candidates, not + automatic merges. diff --git a/docs/contributing.md b/docs/contributing.md index 1ea24d3116..21f937def5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -51,6 +51,9 @@ The Web UI lives under `src_assets/common/assets/web`. UI work toward first-class Tailwind utilities. - If you add files outside the existing Web UI tree, update `tailwind.config.js` so classes are not purged. +- For visible redesign work, start with + [ui-identity-plan.md](ui-identity-plan.md) and keep changes in small, + screenshot-backed slices. ### Documentation diff --git a/docs/fixture-matrix.md b/docs/fixture-matrix.md new file mode 100644 index 0000000000..a67de05b0d --- /dev/null +++ b/docs/fixture-matrix.md @@ -0,0 +1,45 @@ +# Nimbus Fixture Matrix + +Snapshot date: 2026-05-23 + +This matrix records real tested host/client setups. Keep it factual: only add a +row after a local or community tester has actually run the setup. + +## Status Legend + +| Status | Meaning | +| --- | --- | +| Pass | Tested successfully with no observed issue in the recorded scenario. | +| Partial | Stream works, but there is a caveat or unverified surface. | +| Fail | Reproduced failure with enough evidence to investigate. | +| Planned | Intended fixture, not yet tested. | + +## Current Fixtures + +| Date | Status | Host | Client | Display | Network | Scenario | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 2026-05-21 | Partial | Windows 11 VM on VMware, AMD Ryzen 7 5700X3D host, VMware SVGA 3D | Artemis on Shield TV | 1920x1080@60, WebRTC observed in UI | LAN, VM IP 192.168.51.128 | Pair, stream, disconnect | Manual VM smoke passed; service name remains inherited `ApolloService`; next build must retest naming and upstream display fixes. | + +## Planned Fixtures + +| Priority | Fixture | Why | +| --- | --- | --- | +| High | Windows 11 physical host to Shield TV Pro 4K60 SDR | Main living-room baseline. | +| High | Windows 11 physical host to Shield TV Pro 4K60 HDR | Validates HDR/10-bit sync and client behavior. | +| High | SudoVDA virtual display restore after disconnect | Core inherited reliability issue and upstream sync validation. | +| Medium | Steam Deck/handheld native resolution profile | Client Profiles direction. | +| Medium | Tailscale remote stream | Network diagnostics direction. | +| Medium | Playnite Fullscreen launch and clean exit | Library integration and app lifecycle confidence. | + +## Evidence Checklist + +- Nimbus commit hash. +- Installer artifact hash if testing a packaged build. +- Host OS build, GPU, driver, and display type. +- Client device, client app/version, resolution, FPS, codec, HDR. +- Network path: LAN, Wi-Fi, Ethernet, Tailscale, WireGuard, WAN. +- Pairing result. +- Launch result. +- Disconnect/restore result. +- Exported log bundle path or attached issue link. + diff --git a/docs/guides.md b/docs/guides.md index 8f3d056dc2..85cc97a36d 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -1,9 +1,16 @@ # Guides -@admonition{Community | A collection of guides written by the community is available on our -[blog](https://app.lizardbyte.dev/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).} +Nimbus is building a practical guide set around repeatable home-streaming +setups, not one-size-fits-all tuning claims. + +- [Streaming Recipes](streaming-recipes.md): starting points for Shield TV, + Android TV, Steam Deck, OLED HDR, headless hosts, and remote overlay networks. +- [Fixture Matrix](fixture-matrix.md): real tested host/client combinations and + evidence expectations. +- [Apollo To Nimbus Switch Guide](apollo-to-nimbus-switch.md): backup, import, + and rollback guidance for Apollo users testing Nimbus. +- [Community Research](community-research.md): source research behind Setup + Doctor, Client Profiles, Cursor Doctor, and Game Discovery Inbox.
diff --git a/docs/naming-identity-audit.md b/docs/naming-identity-audit.md new file mode 100644 index 0000000000..1eaafd3071 --- /dev/null +++ b/docs/naming-identity-audit.md @@ -0,0 +1,108 @@ +# Nimbus Naming Identity Audit + +Snapshot date: 2026-05-21 + +This audit tracks inherited names from Vibepollo, Vibeshine, Apollo, Sunshine, +Moonlight, and Artemis. It separates visible Nimbus polish from compatibility +anchors that need a migration plan. + +## Naming Policy + +```mermaid +flowchart TD + A["Inherited name found"] --> B{"Visible to users?"} + B -- "yes" --> C{"Protocol or factual compatibility?"} + C -- "yes" --> D["Use neutral wording or Moonlight-compatible"] + C -- "no" --> E["Rename to Nimbus"] + B -- "no" --> F{"Runtime, config, upgrade, or external API anchor?"} + F -- "yes" --> G["Defer until migration fixture exists"] + F -- "no" --> H["Rename when nearby code is touched"] + + I["Lucent"] --> J["Use for our future client fork"] + J --> K["Do not present Lucent as available until the fork exists"] +``` + +Guidance: + +- `Nimbus` is the host product name. +- `Lucent` is reserved for the planned client project. +- Use `compatible client` in settings and support text when the exact client + does not matter. +- Use `Moonlight-compatible client` where protocol compatibility or discoverable + ecosystem wording matters. +- Use `Artemis` only for factual references, fixture evidence, or links to the + current Artemis client. +- Do not rename runtime keys, service names, executable names, config filenames, + upgrade codes, app ids, or protocol classes without a dedicated migration + fixture. + +## Current Scan Summary + +Targeted scan commands: + +```powershell +rg -n "Vibeshine|Vibepollo|Apollo|Sunshine|Moonlight|Artemis|Lucent" src_assets\common\assets\web +rg -n "Vibeshine|Vibepollo|Apollo|Sunshine|Moonlight|Artemis|Lucent" src tools packaging cmake scripts docs release_notes .github tests +rg -l "sunshine|Sunshine|Vibeshine|Vibepollo|Apollo|Moonlight|Artemis|Lucent" . +``` + +Repo-wide file-match counts on this snapshot: + +| Term | Files | +| --- | ---: | +| `sunshine` | 181 | +| `Sunshine` | 150 | +| `Vibepollo` | 53 | +| `Moonlight` | 49 | +| `Apollo` | 37 | +| `Vibeshine` | 21 | +| `Artemis` | 9 | +| `Lucent` | 13 | + +Top matched folders: `src` (82 files), `src_assets` (57), `packaging` (26), +`docs` (25), `cmake` (18), and `tests` (17). + +High-signal findings: + +| Area | Current stance | Notes | +| --- | --- | --- | +| English Web UI labels and descriptions | Rename or neutralize now | Current pass removed screenshot-level `Vibeshine` placeholders and most direct `Moonlight` instructions. | +| Web UI package manifest | Rename now | `src_assets/common/assets/web` now identifies as `nimbus-web` in package metadata and build output. | +| Non-English locale files | Defer as localization work | Many translations still say Vibepollo or Moonlight. Mechanical replacement would create bad translations. | +| `sunshine_name`, `vibeshine_file_state` config keys | Preserve | User-facing labels can say Nimbus, but changing keys affects config compatibility. | +| `sunshine_state.json`, `vibeshine_state.json`, `sunshine.log` defaults | Preserve for alpha | UI placeholders should not advertise inherited filenames, but runtime filename migration needs backup and upgrade tests. | +| `ApolloService` | Preserve for alpha | Visible service description is Nimbus Service; internal service name affects upgrade and uninstall behavior. | +| `SunshineVersion`, `sunshine_version.ts` | Defer | Internal comparator name, not visible UI. Rename later with tests. | +| `Moonlight-compatible clients` | Keep selectively | Accurate compatibility wording while Lucent does not exist. | +| `Artemis` in fixture notes | Keep | Factual test evidence from Shield TV pairing and stream smoke. | +| `Artemis` in client-link UI | Defer | It points to the current Artemis/Moonlight Android ecosystem. Replace with Lucent only when Lucent exists. | +| Historical release notes | Preserve | Upstream release-note files intentionally describe Vibepollo history. | +| Linux packaging ids and appstream | Defer | These are platform package identity surfaces and need their own Linux fixture. | + +## Current Visible Cleanup + +Completed in this pass: + +- General settings host-name placeholder now says `Nimbus`. +- File settings placeholders now use neutral `Automatic` text instead of + inherited filenames. +- The Web UI package manifest now uses `nimbus-web` so maintainer build output + no longer prints `sunshine`. +- Lossless Scaling status and browse messages now say Nimbus. +- English locale copy now uses `compatible client` for certificate, private key, + pairing, bitrate, input, and troubleshooting guidance. +- English video setting descriptions now say Nimbus instead of inherited + Sunshine wording. +- The Web UI still says `Moonlight-compatible clients` in the product + description because that is accurate for the first alpha. + +## Deferred Migration Lanes + +| Lane | Scope | Minimum validation | +| --- | --- | --- | +| Runtime config filenames | `sunshine_state.json`, `vibeshine_state.json`, `sunshine.log` | Backup, migration, rollback, upgrade from Apollo/Vibepollo, uninstall/reinstall. | +| Service identity | `ApolloService`, `sunshinesvc`, related scripts | Upgrade/uninstall fixture plus Windows service recovery tests. | +| Executable/helper names | `sunshine.exe`, `sunshine_display_helper.exe`, `sunshine_wgc_capture.exe` | Installer payload, service launch, crash/log collection, firewall, shortcuts. | +| Client identity | Artemis/Moonlight links and copy | Create Lucent fork or public repo first, then update links and client docs. | +| Translations | Non-English locale files | Human or reviewed machine translation pass; avoid blind replacement. | +| Linux platform identity | FQDN, desktop files, Flatpak/AppImage metadata | Linux package build and install smoke. | diff --git a/docs/packaging-identity-plan.md b/docs/packaging-identity-plan.md index 35342399e2..6be575a399 100644 --- a/docs/packaging-identity-plan.md +++ b/docs/packaging-identity-plan.md @@ -6,6 +6,8 @@ This plan separates public Nimbus branding from runtime identifiers that affect installs, upgrades, shortcuts, services, config paths, and platform packages. The fork inherited names from Vibepollo, Apollo, Sunshine, and Vibeshine. Some of those names are user-facing strings. Others are compatibility anchors. +See `docs/naming-identity-audit.md` for the current inherited-name scan and +Lucent client naming policy. Do not rename every inherited identifier in one broad sweep. Each package or runtime identity change should have a migration note, a rollback expectation, @@ -108,12 +110,13 @@ Do not change these until there is a tested migration plan. | CPack package name | `Nimbus` | Changed in Phase C. Local package build generated `Nimbus.msi` and `NimbusSetup.exe`. | | CPack vendor | `Kuno Labs` | Changed in Phase C. | | CPack contact | `https://github.com/kunolabs/Nimbus/issues` | Changed in Phase C. | -| Windows install directory | `Apollo` | Defer or migrate with explicit install-path behavior. | +| Windows install directory | `Nimbus` for fresh installs; inherited Apollo paths are still detected for upgrade/migration compatibility | Changed for fresh install defaults. Validate upgrade and uninstall cleanup before release. | | WiX upgrade GUID | `{E3FA501A-85F8-4187-85A7-D6E6BDC7EDA1}` | Preserve unless we intentionally break upgrade lineage. | | WiX product-line seed | `Vibepollo-.` | Treat as high-risk. Change only with upgrade testing. | | Bootstrapper namespace | `VibepolloInstaller` | Split cosmetic UI naming from installer detection logic. | | Bootstrapper output | `NimbusSetup.exe` | Changed in Phase C. Local Windows package build passed. | | Start menu folder | `Nimbus` | Changed in Phase C. Validate shortcut cleanup tests. | +| Display restore scheduled task | `NimbusDisplayRestore`; legacy `VibeshineDisplayRestore` is still deleted during cleanup | Active runtime task name changed after VM log feedback. Keep legacy deletion until upgrade/uninstall fixtures prove it is safe to remove. | | Service names | `ApolloService`, `SunshineService`, `VibeshineService`, `sunshinesvc` | Preserve detection and cleanup paths until migration logic is explicit. | | Config/state files | `sunshine.conf`, `sunshine_state.json` | Preserve for first branded release unless migration is built and tested. | | WebRTC release scripts | Nimbus wording and fallback repo | Publishing remains manual and confirmation-gated. | @@ -185,6 +188,8 @@ flowchart LR - Change user-facing package metadata to Nimbus. - Rename generated release artifacts to Nimbus. - Update bootstrapper UI strings and support links. +- Update fresh-install defaults to Nimbus while preserving inherited Apollo and + Vibepollo detection paths for compatibility. - Keep service names, config paths, and upgrade GUIDs unchanged unless the test plan proves a migration is safe. - Build on Windows and inspect the installer, generated filenames, Start menu @@ -196,6 +201,8 @@ Acceptance criteria: - The installer does not publish or sign using upstream destinations. Status: passed for local unsigned validation artifacts. - Upgrade behavior from an existing Vibepollo/Apollo install is recorded. +- Apollo-to-Nimbus config carry-over is validated with an explicit backup/import + bundle before any release note claims seamless migration. - Fresh install and uninstall behavior is recorded. - Release notes disclose inherited runtime ids that remain in place. @@ -239,3 +246,8 @@ Run the installer in a Windows VM or snapshot fixture. Record fresh install, upgrade from Apollo/Vibepollo where practical, uninstall, reinstall, generated shortcuts, Add/Remove Programs text, services, and config behavior before creating any `nimbus-v*` tag. + +For Apollo upgrade testing, use `docs/apollo-to-nimbus-switch.md` first. The +current alpha-prep installer detects and removes Apollo, but Apollo config +carry-over into the Nimbus install directory must be backed up and verified +explicitly. diff --git a/docs/release-build-audit.md b/docs/release-build-audit.md index 6d99036cfc..4e92045b8a 100644 --- a/docs/release-build-audit.md +++ b/docs/release-build-audit.md @@ -26,10 +26,10 @@ to understand inherited automation before changing release behavior. | `.github/workflows/ci.yml` | push, pull request, manual | High | Builds Windows and can create GitHub releases from `nimbus-v*` tags. Release creation remains blocked on package-output validation. | | `.github/workflows/ci-windows.yml` | reusable workflow | High | Builds Windows artifacts, downloads pinned WebRTC artifacts, optionally signs artifacts, and optionally publishes symbols. | | `.github/workflows/webrtc-release.yml` | manual | High | Can publish pinned WebRTC release assets with `contents: write`. Publishing defaults to off and requires an explicit Nimbus confirmation string. | -| `.github/workflows/fixed-issue-follow-up.yml` | issue label, release published | Medium | Comments on and closes issues labeled `fixed`. Useful later, but should be reworded and checked before relying on it publicly. | +| `.github/workflows/fixed-issue-follow-up.yml` | issue label, release published | Medium | Can comment when issues are labeled `fixed`; release-time closure is disabled during Nimbus bootstrap. | | `.github/workflows/logs-needed-reminder.yml` | issue/comment events | Medium | Uses inherited Vibeshine/Sunshine log filename detection for compatibility. Instructions now use Nimbus wording. | -| `.github/workflows/logs-needed-closure.yml` | scheduled or issue state flow | Medium | Can close issues for missing logs. Needs maintainer policy review before use. | -| `.github/workflows/environment-specific-closure.yml` | issue label | Medium | Closes issues as not planned when labeled `environment-specific`. Wording now says Nimbus. | +| `.github/workflows/logs-needed-closure.yml` | scheduled or issue state flow | Medium | Disabled during Nimbus bootstrap. Re-enable only after maintainer support policy is defined. | +| `.github/workflows/environment-specific-closure.yml` | issue label | Medium | Disabled during Nimbus bootstrap. Re-enable only after environment-specific closure policy is defined. | ## Release Automation Findings @@ -68,11 +68,13 @@ Applied hardening: string. - Main release creation no longer closes `fixed` issues directly. - The separate fixed-issue release closer is disabled. +- Stale logs-needed closure and environment-specific closure jobs are disabled + until Nimbus has a public support policy. -Remaining release blocker: package branding now emits Nimbus-named artifacts and -the local Windows package build has verified the final installer output. A -release tag is still blocked until installer execution is tested in a VM or -snapshot fixture and release notes are prepared. +Remaining release blocker: the current release-prep commit now builds +Nimbus-named Windows artifacts locally, but a release tag is still blocked until +installer execution is tested in a VM or snapshot fixture and release notes are +prepared. Decision: CI is safer for normal branch work and manual investigation, but do not intentionally create a Nimbus release tag until package install, upgrade, @@ -87,7 +89,7 @@ identity: - `PROJECT_FQDN` is still `dev.lizardbyte.app.Sunshine`. - `WINDOWS_APP_USER_MODEL_ID` is still `Nonary.Vibepollo`. - CPack package name is now `Nimbus`. -- Windows install directory is still `Apollo`. +- Fresh Windows installs default to `Nimbus`. - Windows WiX product family is seeded with `Vibepollo`. - Bootstrapper text, support links, and output names now use Nimbus. - Several installer paths intentionally detect or migrate Apollo and Sunshine. @@ -173,6 +175,137 @@ Caveats: - Signing and symbol publishing remain disabled until Nimbus-owned destinations and secrets exist. +## Local Windows Package Revalidation + +Validation date: 2026-05-21 + +Validated commits: `ce9ec7aa`, `fe7b7422`, `f14d443b` + +Scope: revalidate the Nimbus release-safety, visible web branding, visible +Windows branding, and CMake web packaging fixes after VM fixture feedback. + +Result: + +| Check | Result | Notes | +| --- | --- | --- | +| Web UI production build | Pass | `npm run build` passes after restoring dependencies. Vite still reports inherited large vendor chunk warnings. | +| English locale JSON parse | Pass | `en.json`, `en_GB.json`, and repaired `en_US.json` parse successfully. | +| Native Windows host build | Pass | `cmake --build build/nimbus-package-validation --target sunshine --config Release -j 6` passes when `C:\msys64\ucrt64\bin` is on `PATH`. | +| Bootstrapper C# compile | Pass | `build_bootstrapper.ps1 -UninstallOnly` compiles the shared installer source. | +| CMake package web step | Pass | Windows CMake now prefers `C:/Program Files/nodejs/npm.cmd` and uses a build-local npm cache. | +| CPack WiX MSI | Blocked | Current host does not expose WiX v3 `candle.exe` / `light.exe`, so `package_installer` stops at CPack WiX. | + +Useful command shape: + +```powershell +$env:PATH = 'C:\msys64\ucrt64\bin;' + $env:PATH +cmake -S . -B build\nimbus-package-validation -UNPM +cmake --build build\nimbus-package-validation --target package_installer --config Release -j 6 +``` + +Historical package blocker: + +```text +Could not find the WiX candle executable. +``` + +This blocker was resolved by making WiX Toolset v3.14.1 portable binaries +discoverable through `WIX`. The old `82fa5cdd` artifacts above are historical +validation evidence only; do not promote them as the current release candidate. + +## Local UI And Status Revalidation + +Validation date: 2026-05-21 + +Validated commits: `2ab4bf99`, `5bdc0784`, `3d8197ae`, `79de18b6` + +Scope: revalidate the additional Nimbus visible web copy sweep, first app-shell +identity token pass, Windows status/log message rebrand, and static +onboarding/header branding pass. + +Result: + +| Check | Result | Notes | +| --- | --- | --- | +| Web UI production build | Pass | `npm run build` passes. Vite still reports inherited large vendor chunk warnings. | +| Targeted visible-name scan | Pass | Targeted Vue shell, app-edit, troubleshooting, Playnite, and English locale surfaces no longer contain `Vibepollo`. | +| Static onboarding scan | Pass | `welcome.html`, `login.html`, and `template_header.html` no longer reference the Apollo logo, `sunshine.ico`, or the `apollo` default first username. | +| English locale JSON parse | Pass | `en.json`, `en_GB.json`, and `en_US.json` parse successfully after the broader Nimbus copy sweep. | +| Native Windows host build | Pass | `cmake --build build\nimbus-package-validation --target sunshine --config Release -j 6` passes when `C:\msys64\ucrt64\bin` is on `PATH`. | +| Figma UI kit automation | Blocked | Existing Figma Starter-plan MCP call limit still blocks automated UI kit population. `docs/ui-identity-plan.md` remains the implementation source of truth. | + +Current visual-design state: + +- The Vue app shell and login/logout surfaces no longer depend on the inherited + Apollo logo image. +- Legacy first-run and login HTML use the Nimbus mark and `nimbus` as the + default first username. +- Tailwind semantic tokens now use the Nimbus blue, Lucent mint, neutral host + console, amber, green, and red token direction from `docs/ui-identity-plan.md`. +- RTSS and Windows troubleshooting status text now uses Nimbus wording for + user-visible guidance. +- Tray menu text, launcher export headers, default discovery fallback, service + display text, and in-app client-control help links now use Nimbus-owned + wording and docs. +- The uninstaller quick-tip panel now uses uninstall-specific copy instead of + reusing install/upgrade guidance. + +## Current Full Package Revalidation + +Validation date: 2026-05-21 + +Validated source commit: `4f3411b2` + +Scope: rebuild the full Windows package after the visible Nimbus web shell, +static onboarding, Windows status copy, remaining visible host-branding cleanup, +uninstaller quick-tip copy, manual update-check feedback, English session +tooltip polish, release-note, and release-safety passes. This validates +generated package artifacts, not installer execution on a target machine. + +Command shape: + +```powershell +$env:PATH = 'C:\msys64\ucrt64\bin;' + $env:PATH +$env:WIX = 'K:\CODEX\game-dev\game-streaming\Nimbus\build\nimbus-package-validation\tools\wix314' +cmake --build build\nimbus-package-validation --target package_installer --config Release -j 6 +``` + +Result: + +| Check | Result | Notes | +| --- | --- | --- | +| Web UI production build | Pass | Vite still reports inherited large vendor chunk warnings only. | +| CPack WiX MSI | Pass | Initial sandbox run reached WiX but failed ICE validation because Windows Installer service access was unavailable; rerunning outside the sandbox with the same `WIX` path passed. | +| Bootstrapper | Pass | Final file metadata resolves to `Nimbus Installer`, `0.0.0.44`, `4f3411b2`, `Kuno Labs`. | +| Signing | Skipped | `SIGNPATH_API_TOKEN` was unset; output is unsigned. | +| Installer execution | Partial | Manual VM smoke observed Nimbus default install path, visible Nimbus wording, service running with Nimbus description, Web UI launch path, manual update-check feedback, Artemis-on-Shield pairing, Artemis streaming/disconnect, and no Defender warning. Uninstall/reinstall and upgrade behavior still need fixture records. | + +Follow-up on 2026-05-27: the bootstrapper gained a Nimbus-line +upgrade-preservation guard for user-owned config, cover, credential, log, +session, and script files. Compile-only validation passed for both +`-UninstallOnly` and MSI-embedded setup builds. A full unsigned package rebuild +also passed after rerunning WiX outside the sandbox so ICE validation could +access Windows Installer services. VM upgrade execution is still required before +release claims. + +Latest local fixture artifacts from `63daba19`: + +| Artifact | Size | SHA256 | +| --- | ---: | --- | +| `build/nimbus-package-validation/cpack_artifacts/NimbusSetup.exe` | 25,916,928 | `DB6FDAE221FA33E60C1E1569464B8D552A3C1215B2E69444E1C74B9A1BC063F0` | +| `build/nimbus-package-validation/cpack_artifacts/Nimbus.msi` | 25,678,060 | `8B54FE46B7B424A315336B6F5978AAB2CF8DF2525B9EC0DE17DEB7607FEF54BD` | + +Artifacts: + +| Artifact | Size | SHA256 | +| --- | ---: | --- | +| `build/nimbus-package-validation/cpack_artifacts/NimbusSetup.exe` | 25,871,872 | `EEAD1749BA88032AFD09E7FBB1917B50DDA88F425059CEEDC18775ADE3028328` | +| `build/nimbus-package-validation/cpack_artifacts/Nimbus.msi` | 25,606,727 | `6E739CE9F56B11A0FD5B824E460AB389A0C61216FEE2CCE8DAA30E1E27F48D4E` | + +Current caveat: these artifacts are newer and better branded than the earlier +`82fa5cdd` validation artifacts, but they are still local validation artifacts +until the remaining VM uninstall, reinstall, and upgrade evidence is recorded. + ## Required Secrets Before Release CI | Secret | Purpose | Current Risk | @@ -183,7 +316,9 @@ Caveats: ## Recommended Next Actions -1. Run installer dry-runs in a Windows VM or snapshot fixture. +1. Finish the Windows VM fixture: uninstaller quick-tip retest, uninstall, + reinstall, compatible-client pairing, short stream smoke, and upgrade checks + where practical. 2. Prepare the first alpha release notes for `nimbus-v0.1.0-alpha.1`. 3. Review issue automation policy before enabling automatic issue closures. 4. Create a Nimbus symbol publishing plan before enabling `publish_symbols`. @@ -192,5 +327,6 @@ Caveats: ## Current Verdict Nimbus is ready for documentation, issue triage work, and first-alpha release -planning. It is not yet ready for public release-tag publication because -installer execution and upgrade behavior still need fixture validation. +planning. The current Windows package build now completes locally, but Nimbus is +not yet ready for public release-tag publication because installer execution and +upgrade behavior still need fixture validation. diff --git a/docs/release-process.md b/docs/release-process.md index f28e0eabdf..c7f6bbe4d5 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -111,6 +111,21 @@ Preconditions before creating the tag: | WebRTC assets | WebRTC publishing remains manual and confirmation-gated. | | Issue automation | Automatic issue closure remains disabled. | +Version display policy: + +- Pre-tag VM candidates may display `0.0.0` in the installer, Web UI, or + Programs & Features because the repository default version remains `0.0.0` + until a Nimbus tag or explicit build version is provided. +- Do not publish public release artifacts from an unversioned configure/build. +- The final `nimbus-v0.1.0-alpha.1` package must be configured from the + annotated tag or with an explicit `TAG=nimbus-v0.1.0-alpha.1` build + environment before packaging. +- Windows Installer `ProductVersion` is numeric-only, so Programs & Features may + show the MSI-safe base version such as `0.1.0` or `0.1.0.0` even when the + user-facing release tag is `0.1.0-alpha.1`. +- File metadata may include a fourth numeric build/revision component, such as + `0.1.0.12`, for Windows upgrade ordering and local candidate inspection. + When all preconditions pass, create and push only the intended tag: ```bash diff --git a/docs/roadmap-issues/audio-sink-restore-after-paused-stream.md b/docs/roadmap-issues/audio-sink-restore-after-paused-stream.md new file mode 100644 index 0000000000..62f5c10709 --- /dev/null +++ b/docs/roadmap-issues/audio-sink-restore-after-paused-stream.md @@ -0,0 +1,53 @@ +# Audio sink restore after paused or stuck stream + +## Problem + +Windows can remain locked to `Steam Streaming Speakers` after a stream ends if +the audio capture path does not fully tear down. In the field report that +triggered this issue, normal output switching recovered only after restarting +the Nimbus service. + +## Evidence + +- A native Artemis stream ended cleanly, but Windows still resisted switching + away from the virtual stream sink. +- Logs repeatedly showed `Resetting sink to [virtual-Stereo...] after default + changed` and `Reinitializing audio capture` after the visible stream was gone. +- A service restart stopped the lingering capture/helper activity and restored + normal Windows output switching. + +## Likely cause + +The normal restore path exists in `src/audio.cpp` and calls +`reset_default_device()` after audio capture teardown. The field report suggests +there is still a paused or stuck-stream path where capture remains alive long +enough for the Windows endpoint-change callback in +`src/platform/windows/audio.cpp` to keep reasserting the virtual sink. + +Related areas to inspect: + +- RTSP session pause/resume behavior in `src/stream.cpp`. +- Local WebRTC failure or startup paths that can touch capture state. +- The Windows audio endpoint callback registered by `audio_control_t::microphone`. +- Display helper pause semantics when `config_revert_on_disconnect` is disabled. + +## Candidate fixes + +1. Add a cheap active-session guard before the Windows endpoint callback + reasserts the virtual sink. +2. Ensure failed or local WebRTC stream attempts release audio capture and do not + leave the callback active. +3. Add a user-facing "Restore Audio Output" action in the tray or web UI that + calls the existing default-device reset logic without a full service restart. +4. Add a focused regression log fixture around "last session ended, default + device changed, callback still reasserts virtual sink". + +## Current workaround + +Use the tray menu's `Restore Audio Output` action in alpha builds that include +it. On older builds, restart the Nimbus service from an elevated PowerShell +window: + +```powershell +Restart-Service -Name ApolloService -Force +``` diff --git a/docs/roadmap-issues/launch-command-presets.md b/docs/roadmap-issues/launch-command-presets.md new file mode 100644 index 0000000000..cf27256d1a --- /dev/null +++ b/docs/roadmap-issues/launch-command-presets.md @@ -0,0 +1,66 @@ +# Tracking: Launch Command Presets + +Public tracker: pending + +Maintainer note: this is a QoL backlog item discovered during the Apollo to +Nimbus switch test. It should stay smaller than Game Discovery Inbox and focus +on making existing app entries easier to create and understand. + +## Problem + +Many community Sunshine, Apollo, and Vibepollo setups use detached commands to +launch games through Steam or another launcher while keeping the stream alive. +The raw command editor works, but it expects users to already know the correct +URI or wrapper command. + +Examples include: + +- Steam AppID launches such as `steam://rungameid/3357650`. +- Steam Big Picture launches for TV-first clients. +- Playnite fullscreen or per-game launch flows. +- Launcher URL schemes for Epic, Ubisoft Connect, EA App, and Battle.net. +- Pre/post wrappers for RTSS, display mode changes, HDR setup, or cleanup. + +## Goal + +Add a small preset picker beside detached commands so Nimbus can generate +common launch commands while still showing the final command before save. + +## Proposed Scope + +### Phase 1: Steam Presets + +- Add `Add Preset` beside detached commands in the application editor. +- Support `Steam game` input as: + - raw AppID, + - `steam://rungameid/`, + - Steam store URL. +- Generate a detached command and leave the primary command empty unless the + user already entered one. +- Keep the generated command editable. + +### Phase 2: TV And Launcher Presets + +- Add `Steam Big Picture`. +- Add Playnite fullscreen launcher preset. +- Document launcher URL patterns that are safe and tested. + +### Phase 3: Advanced Wrappers + +- Add optional before/after command templates for frame caps, display/HDR setup, + and cleanup. +- Require fixtures before shipping wrappers that change system state. + +## Acceptance Criteria + +- Presets never overwrite an existing command without confirmation. +- Generated commands are visible before saving. +- Raw command editing remains available. +- Steam AppID, Steam URL, and `steam://rungameid/...` input normalize to the same + command. +- Documentation includes at least one Shield TV / Steam game test note. + +## References + +- `docs/community-research.md` +- `docs/roadmap-issues/setup-doctor-client-profiles.md` diff --git a/docs/roadmap-issues/setup-doctor-client-profiles.md b/docs/roadmap-issues/setup-doctor-client-profiles.md new file mode 100644 index 0000000000..243ed74a5d --- /dev/null +++ b/docs/roadmap-issues/setup-doctor-client-profiles.md @@ -0,0 +1,78 @@ +# Tracking: Nimbus Setup Doctor And Client Profiles + +Public tracker: https://github.com/kunolabs/Nimbus/issues/4 + +Maintainer note: this is a Nimbus bootstrap planning tracker, not a +user-reported issue or a claim of existing community demand. + +## Problem + +Nimbus should not compete with Vibepollo by adding another pile of hidden +toggles. Community setup pain is mostly about knowing which settings matter for +a specific host/client setup and how to diagnose failures. + +Users repeatedly struggle with: + +- virtual display and HDR state, +- frame pacing and refresh-rate mismatches, +- audio sink/channel routing, +- ViGEm/controller conflicts, +- persistent or missing cursors, +- LAN/WAN/Tailscale/WireGuard path confusion, +- Playnite/library import expectations, +- unsigned installer and driver trust. + +## Goal + +Make Nimbus the guided, validated, profile-driven host fork: + +> Tell Nimbus what you stream to, and Nimbus tells you the correct display, +> bitrate, audio, controller, and network setup. If something is wrong, Nimbus +> explains why. + +## Proposed Scope + +### Phase 1: Read-Only Setup Doctor + +- Show GPU/encoder summary. +- Show active capture/display target and virtual display status. +- Show HDR/10-bit capability and current stream setup when available. +- Show ViGEm/SudoVDA presence and warnings. +- Show selected audio sink and channel mode. +- Show basic firewall/port checks. +- Show current log bundle/export entry point. +- Add a Cursor Doctor troubleshooting card. + +### Phase 2: Client Profiles + +- Add known setup profiles for: + - Shield TV / Android TV 4K60, + - Shield TV / Android TV 4K120 where supported, + - Steam Deck / handheld, + - phone/tablet, + - mini PC client, + - headless Windows host, + - remote Tailscale/WireGuard. +- Start as recommendations and docs before automatic config writes. + +### Phase 3: Game Discovery Inbox + +- Present read-only game/app candidates from known sources. +- Prefer Playnite and launcher manifests over blind `.exe` scanning. +- Let users select/deselect/all/none before importing. +- Track imported batch metadata so users can undo. + +## Acceptance Criteria + +- First pass is safe to run on an existing Apollo/Vibepollo/Nimbus config. +- No automatic config writes happen without an explicit confirm step. +- Each warning includes a concrete next action. +- Fixture matrix records at least one real Shield TV / Artemis stream result. +- Documentation links back to `docs/community-research.md`. + +## References + +- `docs/community-research.md` +- `docs/upstream-issue-radar.md` +- `docs/windows-installer-fixture.md` +- `docs/naming-identity-audit.md` diff --git a/docs/streaming-recipes.md b/docs/streaming-recipes.md new file mode 100644 index 0000000000..9f0cb9d488 --- /dev/null +++ b/docs/streaming-recipes.md @@ -0,0 +1,132 @@ +# Nimbus Streaming Recipes + +Snapshot date: 2026-05-23 + +These recipes are starting points for repeatable community testing. They are not +guaranteed optimal settings. Record what actually works in +`docs/fixture-matrix.md` after testing. + +## Shield TV / Android TV 4K60 + +Best for living-room controller-first play. + +| Setting | Starting Point | +| --- | --- | +| Client | Artemis or Moonlight Android TV | +| Resolution | 3840x2160 | +| Frame rate | 60 FPS | +| Codec | HEVC when supported | +| HDR | Enable only after SDR is stable | +| Network | Wired Ethernet preferred | +| Host display | SudoVDA virtual display or physical 4K display | +| App launch | Playnite Fullscreen or direct game app | + +Checks: + +- Pair client and confirm stream starts from the TV device. +- Confirm the Windows cursor is not visible in controller-first games. +- Confirm disconnect restores the expected host display state. +- Export logs after one successful stream and one disconnect. + +## Shield TV / Android TV 4K120 + +Best for high-refresh displays where the full chain supports 120 Hz. + +| Setting | Starting Point | +| --- | --- | +| Client | Artemis or Moonlight Android TV | +| Resolution | 3840x2160 | +| Frame rate | 120 FPS | +| Codec | HEVC or AV1 if both sides support it | +| HDR | Enable after 4K120 SDR is stable | +| Network | Wired Ethernet or proven low-latency Wi-Fi 6/6E | +| Frame pacing | Validate display refresh, RTSS cap, and client-reported FPS | + +Checks: + +- Confirm the host display is actually running near 120 Hz. +- Watch for 119.88/120 mismatch and frame pacing jitter. +- Record whether WGC, DXGI, or frame-generation capture is used. + +## Steam Deck / Handheld + +Best for handheld streaming with client-native aspect ratio. + +| Setting | Starting Point | +| --- | --- | +| Resolution | 1280x800 or client-native | +| Frame rate | 60, 90, or native FPS depending on client | +| Bitrate | Start conservative, then increase | +| Input | Gamepad-first; test Steam Input behavior | +| Launcher | Playnite, Steam Big Picture, or direct app | + +Checks: + +- Confirm the game renders without aspect-ratio stretching. +- Confirm controller order and rumble behavior. +- Confirm bitrate does not outrun Wi-Fi conditions. + +## OLED 4K HDR + +Best for enthusiasts validating HDR and 10-bit behavior. + +| Setting | Starting Point | +| --- | --- | +| Resolution | 3840x2160 | +| Frame rate | 60 or 120 FPS | +| Codec | HEVC Main10 or AV1 10-bit where supported | +| HDR | Validate SDR first, then HDR | +| Display | Physical HDR display or virtual display with known-good HDR metadata | + +Checks: + +- Confirm Windows HDR state before and during stream. +- Confirm client HDR mode actually engages. +- Record encoder, bit depth, and client codec. +- Export logs if HDR falls back to SDR or the image looks washed out. + +## Headless Windows Host + +Best for PCs without an attached gaming display. + +| Setting | Starting Point | +| --- | --- | +| Display | SudoVDA first; dummy plug as fallback | +| Restore | Create a golden display snapshot before testing | +| App launch | Direct app first, then Playnite | +| Access | Keep local/remote recovery path available | + +Checks: + +- Confirm the virtual display appears before pairing. +- Confirm stream-end restore does not strand the host in the wrong layout. +- Export display-helper logs after restore. + +## Remote Tailscale / WireGuard + +Best for controlled remote-home testing. + +| Setting | Starting Point | +| --- | --- | +| Pairing | Pair locally first if possible | +| Address | Use the overlay IP only after LAN success | +| Bitrate | Start lower than LAN | +| Network | Verify direct path vs relay where your overlay exposes it | + +Checks: + +- Confirm LAN stream works before remote testing. +- Record whether manual IP add was needed. +- Export logs for RTSP, connection timeout, or disconnect failures. + +## Cursor Troubleshooting Mini-Recipe + +Use this when the Windows cursor remains visible, disappears, duplicates, or +appears offset. + +1. Identify which cursor is visible: host cursor, client cursor, or game cursor. +2. Check whether the client has a local cursor toggle or touch/mouse mode. +3. Test one controller-first game and one mouse-first desktop/app session. +4. Note whether the host is using a physical display, SudoVDA, dummy plug, or + HDR/virtual-display setup. +5. Search Nimbus logs for `cursor`, `mouse`, `input`, and capture method. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b5b211bf94..6703b1dd57 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -32,6 +32,18 @@ Can't access the web UI? 1. Check firewall rules. +### WebRTC Stream Tab Blanks The Host Display + +The Stream tab is an experimental browser client. If you open it from the host +PC itself while virtual display is configured to use an exclusive Nimbus virtual +screen, Windows may disable the physical display that contains the browser. + +For TV/console-style setups, prefer a Moonlight-compatible native client such as +Artemis on Shield TV. If you want to use WebRTC locally, switch the virtual +display layout to an extended mode first, or make sure you have a recovery path +such as a paired native client, Remote Desktop, RustDesk, or a display restore +hotkey. + ### Controller works on Steam but not in games One trick might be to change Steam settings and check or uncheck the configuration to support Xbox/PlayStation controllers and leave only support for Generic controllers. @@ -235,6 +247,25 @@ permissions on the disk. ### Stuttering If you experience stuttering using NVIDIA, try disabling `vsync:fast` in the NVIDIA Control Panel. +### Windows audio stays on Steam Streaming Speakers after a stream + +Nimbus normally restores the previous Windows default audio output when audio +capture tears down. If a stream is disconnected while a desktop session, local +WebRTC attempt, or virtual display session is left paused, Windows may keep +`Steam Streaming Speakers` selected and refuse manual output changes until the +service fully restarts. + +The quickest recovery is to restart the Nimbus service from an elevated +PowerShell window, or use the tray menu's `Restore Audio Output` action in +alpha builds that include it. + +```powershell +Restart-Service -Name ApolloService -Force +``` + +The service name is still `ApolloService` for legacy compatibility in current +alpha builds, even though the displayed service description is `Nimbus Service`. +
| Previous | Next | diff --git a/docs/ui-identity-plan.md b/docs/ui-identity-plan.md new file mode 100644 index 0000000000..88886ab62d --- /dev/null +++ b/docs/ui-identity-plan.md @@ -0,0 +1,93 @@ +# Nimbus UI Identity Plan + +Nimbus should feel like a calm host console for game streaming: operational, +local-first, and trustworthy under maintenance pressure. This document records +the first visual direction before a full Web UI overhaul. + +## Goals + +- Make Nimbus visibly distinct from Apollo, Vibepollo, Sunshine, and Vibeshine + without breaking inherited compatibility. +- Keep the first screen useful: host status, release safety, driver readiness, + clients, and common actions. +- Prefer compact, repeatable controls over marketing-style layout. +- Keep design work progressive so each PR is reviewable and reversible. + +## Identity Principles + +| Principle | UI Consequence | +| --- | --- | +| Local-first trust | Show service, ports, drivers, release state, and fixture state plainly. | +| Host-console calm | Use restrained surfaces, crisp borders, small radii, and dense scan paths. | +| Streaming confidence | Surface stream health, display state, pairing, and recovery actions early. | +| Compatibility honesty | Label inherited runtime names as compatibility choices until migration exists. | +| Contributor clarity | Keep release gates, issue links, and support routes visibly Nimbus-owned. | + +## Draft Token Direction + +Nimbus should avoid becoming a single-hue blue or purple UI. The initial palette +balances a neutral operational base with distinct signal colors. + +| Token | Hex | Use | +| --- | --- | --- | +| Cloud Ink | `#0D1117` | Sidebar, top-level contrast, brand anchor | +| Nimbus Blue | `#2563EB` | Primary actions, active navigation, links | +| Lucent Mint | `#2DD4BF` | Healthy streaming, client/pairing accent | +| Signal Amber | `#F59E0B` | Unsigned builds, driver warnings, pending gates | +| Health Green | `#22C55E` | Passing fixture, running services | +| Fault Red | `#F43F5E` | Crash, blocked release, destructive actions | +| Mist Surface | `#F6F8FB` | App background | +| Line Frost | `#D8E3F2` | Borders and dividers | + +## Component Priorities + +| Priority | Component | Reason | +| --- | --- | --- | +| 1 | App shell and navigation | Replaces the strongest inherited identity signals. | +| 1 | Release/status alert | Prevents unsafe upstream update or downgrade prompts. | +| 1 | Host status cards | Makes install, service, port, and driver state inspectable. | +| 2 | Buttons and icon buttons | Establishes action hierarchy across settings and dashboard. | +| 2 | Form controls | Settings pages need dense, predictable editing surfaces. | +| 2 | Changelog/release notes panel | Keeps alpha caveats and release provenance readable. | +| 3 | Client/session views | Connects Nimbus host identity with Lucent client direction. | + +## Progressive Overhaul + +```mermaid +flowchart LR + A["Release-safe links and visible names"] --> B["Dashboard shell and status surfaces"] + B --> C["Settings controls and navigation density"] + C --> D["Client, stream, and pairing views"] + D --> E["Shared Vue components and token cleanup"] + E --> F["Lucent cross-product identity check"] +``` + +## First PR Slices + +| Slice | Scope | Validation | +| --- | --- | --- | +| UI-1 | Browser title, login shell, dashboard greeting, resource links, app-shell token pass | Vite build and VM screenshot | +| UI-2 | Dashboard status cards and update/release alert layout | Vite build and responsive screenshots | +| UI-3 | Settings navigation and form density pass | Vite build, keyboard navigation, mobile width check | +| UI-4 | Changelog and support surfaces | Vite build, no upstream release links | + +## Implementation Notes + +- The first implementation slice should replace inherited Apollo/Sunshine visual + anchors before larger layout work: shell navigation, login/logout brand mark, + and semantic Tailwind/Naive UI color tokens. +- Keep source compatibility names separate from visible product identity. If a + backend feature still uses an inherited folder, profile, or executable name, + prefer neutral wording such as "managed profile" until that runtime migration + is complete. + +## Figma Status + +A private `Nimbus UI Kit v0.1` Figma file exists for maintainer exploration. +The current Starter-plan MCP call limit blocked automated canvas population, so +the repo keeps this document as the source of truth until the Figma board can be +filled from the same component and token plan. + +Do not treat the Figma board as implementation authority until it has been +reviewed against actual Web UI screenshots and accepted as the current design +contract. diff --git a/docs/upstream-issue-radar.md b/docs/upstream-issue-radar.md index cf3f6a4213..3c07c390de 100644 --- a/docs/upstream-issue-radar.md +++ b/docs/upstream-issue-radar.md @@ -1,8 +1,10 @@ # Upstream Issue Radar -Snapshot date: 2026-05-20 +Snapshot date: 2026-06-19 -Source: open issues from `Nonary/Vibepollo`, fetched with GitHub CLI. +Source: open issues from `Nonary/Vibepollo`, fetched with GitHub CLI. Current +open count: 19. +Closed/status checks for tracked issues were verified with `gh issue view`. This file is not a copy of the upstream backlog. It is a triage radar for issues that may matter to Nimbus users. Do not mirror upstream issues automatically. @@ -26,59 +28,83 @@ scoped. | Issue | Bucket | Why Nimbus Should Care | | --- | --- | --- | -| [#238 Secondary monitor never recovers properly](https://github.com/Nonary/Vibepollo/issues/238) | Adopt now | Display recovery is core to the Nimbus host promise. Also suggests a useful dashboard warning when a saved display snapshot is stale. | -| [#235 Windows 10 install fails due to PATH variables](https://github.com/Nonary/Vibepollo/issues/235) | Adopt now | Installer reliability is a first-release blocker. This also intersects with Nimbus rebranding and install-path planning. | -| [#228 Virtual display does not reset on WebRTC stream close](https://github.com/Nonary/Vibepollo/issues/228) | Adopt now | Virtual display lifecycle cleanup is a high-value Nimbus reliability lane. | -| [#224 Crashes corrupting sunshine_state.json](https://github.com/Nonary/Vibepollo/issues/224) | Adopt now | State corruption after crashes is a trust issue. Nimbus should prefer backup, validation, and recovery behavior early. | -| [#223 Physical display setting unintended behavior](https://github.com/Nonary/Vibepollo/issues/223) | Adopt now | Physical monitor selection, virtual display overrides, and capture target behavior are central host features. The issue includes locally tested patch direction. | +| [#274 HDR washout on reconnect to a retained stream](https://github.com/Nonary/Vibepollo/issues/274) | Adopt now | Nimbus now suppresses HDR state changes during retained-resume virtual-display recreation to avoid the transient SDR flip. Validate on HDR WGC hardware before closing the risk. | +| [#271 Detached commands leave screensaver disabled after session teardown](https://github.com/Nonary/Vibepollo/issues/271) | Adopt now | Nimbus now snapshots and restores the Windows screen saver active flag across stream lifecycle. Validate with Playnite Fullscreen or another detached command before closing the risk. | ## Watch | Issue | Bucket | Why To Watch | | --- | --- | --- | -| [#231 Low FPS issues while streaming](https://github.com/Nonary/Vibepollo/issues/231) | Watch | Important performance signal, but title alone lacks enough reproduction detail. Needs logs, capture mode, client, GPU, and stream settings. | -| [#222 RTSP Error 54 after update](https://github.com/Nonary/Vibepollo/issues/222) | Watch | Upgrade regression signal. Needs reproduction and logs before Nimbus owns it. | -| [#220 Session not closing after Playnite Fullscreen exit](https://github.com/Nonary/Vibepollo/issues/220) | Watch | Playnite lifecycle issues matter, but should be grouped with broader launcher/session cleanup. | -| [#219 App override resolution defaults to unintended resolution](https://github.com/Nonary/Vibepollo/issues/219) | Watch | App-specific overrides are relevant to Nimbus, especially for TV/client profiles. Needs reproduction. | +| [#278 RTX HDR creates blank/undeletable app entries](https://github.com/Nonary/Vibepollo/issues/278) | Watch | Fresh 1.17.0 beta 6 report suggests RTX HDR toggling can corrupt app entries, but it needs logs and reproduction before Nimbus owns it. | +| [#275 Slow Web UI in Firefox on v1.16.0-stable.3](https://github.com/Nonary/Vibepollo/issues/275) | Watch | Web UI responsiveness matters, but this is a single browser/GPU report with logs attached. Compare current Nimbus UI before taking code action. | +| [#272 Foundation-compatible runtime bitrate / ABR endpoints](https://github.com/Nonary/Vibepollo/issues/272) | Watch | Runtime bitrate control may matter for future clients, but it is product/API scope rather than first-pass host reliability. | +| [#269 Advanced WebRTC configuration and firewall traversal](https://github.com/Nonary/Vibepollo/issues/269) | Watch | Useful remote-streaming signal, but broad enough that Nimbus should first define support boundaries and avoid premature networking scope. | +| [#242 Device override dropdown missing](https://github.com/Nonary/Vibepollo/issues/242) | Watch | Config override UI parity is relevant, but should be grouped with the broader per-app and per-device override audit. | | [#213 Remote sessions fail over Tailscale/ZeroTier](https://github.com/Nonary/Vibepollo/issues/213) | Watch | Remote networking support matters, but Nimbus should first define support boundaries for VPN/overlay networks. | | [#208 Web UI inaccessible after outside-LAN connection attempt](https://github.com/Nonary/Vibepollo/issues/208) | Watch | Potential network/session-state issue. Needs logs and security-minded review. | -| [#177 Frame limiter offset](https://github.com/Nonary/Vibepollo/issues/177) | Watch | Frame pacing matters, but needs a focused repro setup before action. | -| [#174 Framestutter/choppy low-FPS content](https://github.com/Nonary/Vibepollo/issues/174) | Watch | Strongly relevant to Shield/TV streaming quality, but likely needs hardware and capture-mode comparisons. | -| [#152 No system tray icon](https://github.com/Nonary/Vibepollo/issues/152) | Watch | User-facing reliability issue. Could become Adopt now if reproduced on current Nimbus base. | -| [#85 DRM Protected Content Error](https://github.com/Nonary/Vibepollo/issues/85) | Watch | Appears environment/client-flow sensitive. Needs careful reproduction before code changes. | +| [#177 Frame limiter offset](https://github.com/Nonary/Vibepollo/issues/177) | Watch | Frame pacing matters, and the VRR/RTSS offset use case is clearer now, but still needs client and hardware-specific reproduction before action. | ## Upstream Fixed | Issue | Bucket | Why | | --- | --- | --- | -| [#236 HEVC Main10/HDR not advertised despite `hevc_mode = 3`](https://github.com/Nonary/Vibepollo/issues/236) | Upstream fixed | Labeled `fixed` upstream. Wait for release/sync or inspect the fixing commit before creating Nimbus work. | +| [#260 Video hangs in WGC](https://github.com/Nonary/Vibepollo/issues/260) | Upstream fixed | Closed upstream with `fixed` on 2026-06-10. Nimbus has the local constant-WGC cached-frame equivalent in the current WGC dirty work; keep a WGC freeze smoke case. | +| [#250 Per-app display settings cannot mirror primary without layout changes](https://github.com/Nonary/Vibepollo/issues/250) | Upstream fixed | Closed upstream on 2026-06-12. Keep display-override parity in Nimbus validation, not as a separate active item. | +| [#244 UAC prompt stream freeze/crash](https://github.com/Nonary/Vibepollo/issues/244) | Upstream fixed | Closed upstream on 2026-06-06. Retain as a UAC/display-transition regression case before dropping release-risk tracking. | +| [#241 Settings page crash in Lossless Scaling status API](https://github.com/Nonary/Vibepollo/issues/241) | Upstream fixed | Closed upstream with `fixed` on 2026-06-10. Nimbus now uses non-throwing default-path checks and returns a safe unavailable payload if the status handler fails. | +| [#228 Virtual display does not reset on WebRTC stream close](https://github.com/Nonary/Vibepollo/issues/228) | Upstream fixed | Closed upstream with `fixed` on 2026-06-12. Nimbus now forces final WebRTC display cleanup when no app remains and shortens the idle grace period while a virtual display is active. | +| [#224 Crashes corrupting sunshine_state.json](https://github.com/Nonary/Vibepollo/issues/224) | Upstream fixed | Closed upstream on 2026-06-09. Nimbus should still keep crash-safe state writes and recovery in scope. | +| [#223 Physical display setting unintended behavior](https://github.com/Nonary/Vibepollo/issues/223) | Upstream fixed | Closed upstream with `fixed` on 2026-06-12. Nimbus now leaves physical-target topologies unpinned except for explicit `ensure_only_display`, with unit coverage. | +| [#236 HEVC Main10/HDR not advertised despite `hevc_mode = 3`](https://github.com/Nonary/Vibepollo/issues/236) | Synced | Upstream 1.16.0-alpha.4 HDR/10-bit and NVENC API 12.1 fixes were cherry-picked into Nimbus after the 2026-05-23 upstream refresh. | +| [#238 Secondary monitor never recovers properly](https://github.com/Nonary/Vibepollo/issues/238) | Upstream fixed | Closed upstream in `v1.16.0-beta.2` with user confirmation. Keep as a Nimbus display-restore validation case before removing from release-risk tracking. | +| [#235 Windows 10 install fails due to PATH variables](https://github.com/Nonary/Vibepollo/issues/235) | Upstream fixed | Closed upstream as completed on 2026-05-24. Keep as a Nimbus installer validation case before removing from release-risk tracking. | +| [#237 Direct Steam Extension](https://github.com/Nonary/Vibepollo/issues/237) | Upstream fixed | Closed upstream as completed on 2026-05-24. Treat as a future product-scope comparison, not an immediate Nimbus task. | +| [#246 Auto-update selected release line](https://github.com/Nonary/Vibepollo/issues/246) | Upstream fixed | Opened after the previous radar snapshot and closed upstream as completed on 2026-05-24. Relevant only if Nimbus adopts Vibepollo's update-policy behavior. | +| [#255 Force Constant Capture not working except for version 1.15.4 stable](https://github.com/Nonary/Vibepollo/issues/255) | Upstream fixed | Closed upstream with `fixed` after the previous snapshot. Nimbus has the local cached-frame timeout equivalent in WGC dirty work; keep one constant-capture smoke case. | +| [#249 Installer deletes custom files when updating](https://github.com/Nonary/Vibepollo/issues/249) | Upstream fixed | Closed upstream after confirmation that installers above 1.15.5 avoid the deletion path. Nimbus should still preserve user-owned scripts and local glue during updates. | +| [#231 Low FPS issues while streaming](https://github.com/Nonary/Vibepollo/issues/231) | Upstream fixed | Closed upstream on 2026-06-08. Treat as performance smoke coverage unless new reports appear. | +| [#222 RTSP Error 54 after update](https://github.com/Nonary/Vibepollo/issues/222) | Upstream fixed | Closed upstream in `v1.16.0-beta.3`. Useful only as an upgrade regression smoke test unless Nimbus sees the same failure. | +| [#220 Session not closing after Playnite Fullscreen exit](https://github.com/Nonary/Vibepollo/issues/220) | Upstream fixed | Closed upstream in `v1.16.0-beta.3`. Keep in the launcher/session cleanup checklist, but no separate Nimbus task yet. | +| [#219 App override resolution defaults to unintended resolution](https://github.com/Nonary/Vibepollo/issues/219) | Upstream fixed | Closed upstream on 2026-06-11. Keep app-override resolution in profile validation. | +| [#181 PIN always successful](https://github.com/Nonary/Vibepollo/issues/181) | Upstream fixed | Closed upstream with `upstream bug` label on 2026-06-11. Track only if Nimbus diverges from inherited auth behavior. | +| [#174 Framestutter/choppy low-FPS content](https://github.com/Nonary/Vibepollo/issues/174) | Upstream fixed | Closed upstream on 2026-06-07. Keep frame-pacing checks hardware-specific. | +| [#152 No system tray icon](https://github.com/Nonary/Vibepollo/issues/152) | Upstream fixed | Closed upstream with `fixed` on 2026-06-12. Retain as a packaging/tray smoke case. | +| [#85 DRM Protected Content Error](https://github.com/Nonary/Vibepollo/issues/85) | Upstream fixed | Closed upstream on 2026-06-11. Keep as environment-sensitive support history, not active Nimbus work. | ## Docs And Support | Issue | Bucket | Why | | --- | --- | --- | -| [#237 Direct Steam Extension](https://github.com/Nonary/Vibepollo/issues/237) | Docs/support | Feature request. Keep as a future integration idea, not first maintenance work. | +| [#276 Host Shortcuts Panel in WebUI](https://github.com/Nonary/Vibepollo/issues/276) | Docs/support | Couch-gaming shortcuts and text input are useful UX ideas, but they need security and interaction design before becoming a host reliability task. | +| [#254 Steam Controller support](https://github.com/Nonary/Vibepollo/issues/254) | Docs/support | New discussion points toward client-side Moonlight SDL controller mapping. Track as compatibility guidance, not a Nimbus host priority. | +| [#243 Tray icon update indicator](https://github.com/Nonary/Vibepollo/issues/243) | Docs/support | Useful release-notification UX, but not a launch reliability blocker. | | [#232 Custom cover art for Playnite apps and app reordering](https://github.com/Nonary/Vibepollo/issues/232) | Docs/support | Useful UX request but not a launch blocker. | -| [#226 Mac version planned?](https://github.com/Nonary/Vibepollo/issues/226) | Docs/support | Needs a clear platform-support statement. | +| [#226 Mac version planned?](https://github.com/Nonary/Vibepollo/issues/226) | Docs/support | Closed upstream with an entitlement/cost explanation. Nimbus needs a clear platform-support statement rather than a port promise. | | [#206 Custom app cover not loading in Artemis](https://github.com/Nonary/Vibepollo/issues/206) | Docs/support | May belong to host metadata/client compatibility, but starts as support triage. | | [#188 WebRTC input on touch devices](https://github.com/Nonary/Vibepollo/issues/188) | Docs/support | WebRTC UX request. Relevant later, not first host stability pass. | -| [#181 PIN always successful](https://github.com/Nonary/Vibepollo/issues/181) | Docs/support | Labeled `upstream bug`. Track, but avoid duplicating upstream unless Nimbus diverges. | -| [#29 Virus detected alerts](https://github.com/Nonary/Vibepollo/issues/29) | Docs/support | Installer trust and false-positive guidance should be addressed before Nimbus releases. | +| [#29 Virus detected alerts](https://github.com/Nonary/Vibepollo/issues/29) | Docs/support | Updated upstream with SignPath/code-signing discussion. Nimbus release notes should separate binary reputation, code signing, and actual security claims. | +| [#245 Automatic restart after crash](https://github.com/Nonary/Vibepollo/issues/245) | Docs/support | Closed after the reporter confirmed automatic restart occurs but may take several minutes. Document expected recovery timing if Nimbus inherits similar service behavior. | +| [#216 Winuhid](https://github.com/Nonary/Vibepollo/issues/216) | Docs/support | Closed upstream because driver signing makes it unrealistic unless Sunshine carries it. Track only as an upstream dependency note. | ## Needs Hardware Or Volunteers | Issue | Bucket | Why | | --- | --- | --- | -| [#216 Winuhid](https://github.com/Nonary/Vibepollo/issues/216) | Needs hardware | Input driver behavior needs a controlled Windows/client test setup. | +| [#265 1.16.0 not working with dual-GPU](https://github.com/Nonary/Vibepollo/issues/265) | Needs hardware | Open with `logs requested` and multiple dual-GPU confirmations. Nimbus needs matching multi-GPU hardware to validate adapter selection, virtual display creation, and encoder choice. | | [#172 AMD/AMF latency improvements](https://github.com/Nonary/Vibepollo/issues/172) | Needs hardware | Requires AMD GPU owners and repeatable latency measurements. | ## First Nimbus Backlog Candidates -1. Display snapshot staleness and recovery warning. -2. Installer PATH handling on Windows 10/11. -3. Virtual display cleanup when WebRTC sessions end. -4. Crash-safe state write and state backup/recovery. -5. Physical monitor app override behavior. +1. Retained-session virtual display resume, especially HDR state stability on + reconnect. +2. Detached command/session teardown cleanup, including screensaver state + restoration. +3. Dual-GPU adapter selection and virtual display behavior once hardware is + available. +4. WGC, UAC, tray, settings, and state-storage fixes that upstream has closed, + kept as Nimbus validation cases before release. +5. Installer and updater preservation of user-owned files. +6. Remote-networking and WebRTC support boundaries. These are good early credibility fixes because they are user-visible, aligned with Nimbus host reliability, and do not require inventing a new product vision. diff --git a/docs/windows-installer-fixture.md b/docs/windows-installer-fixture.md index 0663901f5a..c8f8d630a8 100644 --- a/docs/windows-installer-fixture.md +++ b/docs/windows-installer-fixture.md @@ -1,6 +1,6 @@ # Nimbus Windows Installer Fixture -Snapshot date: 2026-05-20 +Snapshot date: 2026-05-21 This fixture is the release gate for the first Nimbus alpha. It is intentionally separate from the local package build audit: the package build proves that @@ -28,18 +28,36 @@ flowchart TD ## Candidate Artifact -The current local package validation produced these artifacts from commit -`82fa5cdd`: +The current local package validation produced these artifacts from source commit +`10ed2c32`. The documentation commit that records these hashes may be newer than +the validated source commit. | Artifact | Size | SHA256 | | --- | ---: | --- | -| `build/nimbus-package-validation/cpack_artifacts/NimbusSetup.exe` | 25,866,752 | `F0C161A08BD9696E500EC694B0723CB769A5E5A0C8E3B031B5838DBCAE4E3338` | -| `build/nimbus-package-validation/cpack_artifacts/Nimbus.msi` | 25,602,498 | `525AAB47E3539CE29521AC67592996BFA1535E8AE91405442C1A82EAC403FDF1` | - -Before tagging `nimbus-v0.1.0-alpha.1`, rebuild the package from the final -release-prep commit and replace the hashes in the fixture results. The older -hashes above are proof of the Phase C build, not final release-candidate -hashes. +| `build/nimbus-package-validation/cpack_artifacts/NimbusSetup.exe` | 25,885,696 | `3CAA664566F29D988C959F87C4DC1519DB409E8BDF84752EF26C1D9ADA453A52` | +| `build/nimbus-package-validation/cpack_artifacts/Nimbus.msi` | 25,619,015 | `F7B04CE0922FECC823CC363B4794DACDD90E92E10F6F70B388965B33A28F4D01` | + +Before tagging `nimbus-v0.1.0-alpha.1`, either use these artifacts for fixture +testing or rebuild the package from the final release-prep commit and replace +the hashes here. These are still local validation artifacts until the fixture +matrix below is recorded. + +Current artifact metadata: + +| Field | Value | +| --- | --- | +| Bootstrapper product | `Nimbus Installer` | +| File version | `0.0.0.53` | +| Product version | `1.16.0-alpha.3-50-g10ed2c32` | +| Company | `Kuno Labs` | +| Signing | Unsigned; SignPath skipped because `SIGNPATH_API_TOKEN` is unset | + +Version caveat: this is a pre-tag fixture candidate. It may display `0.0.0` in +the installer, Web UI, or Programs & Features even though file metadata includes +the generated four-part Windows file version above. That is acceptable for +local VM validation only. Public alpha artifacts must be rebuilt from the +`nimbus-v0.1.0-alpha.1` tag, or an equivalent explicit `TAG` build environment, +so user-facing version surfaces no longer look like an unversioned build. ## Fixture Matrix @@ -47,15 +65,72 @@ hashes. | --- | --- | --- | --- | | Fresh install on clean Windows 11 x64 | Yes | Pending | Installer log, screenshots, service state | | Launch Web UI after install | Yes | Pending | Local URL opens and login/setup path is reachable | -| Pair with a compatible client | Yes | Pending | Artemis, Moonlight, or current compatible client can see the host | -| Start a short stream session | Yes | Pending | One local-network stream smoke, with hardware encoder noted | +| Pair with a compatible client | Yes | Observed pass | Artemis on Shield TV paired with the Nimbus VM host | +| Start a short stream session | Yes | Observed pass | Artemis on Shield TV streamed from the Nimbus VM host and disconnected cleanly | | Uninstall | Yes | Pending | Add/Remove Programs entry removed and service stopped | | Reinstall after uninstall | Yes | Pending | Reinstall completes without manual cleanup | | Upgrade from Vibepollo | Strongly recommended | Pending | Config, credentials, and paired-client state checked | -| Upgrade from Apollo | Strongly recommended | Pending | Config, credentials, and paired-client state checked | +| Upgrade preserves user-owned files | Yes | Code guard added, fixture pending | Create custom root/config/scripts files before update; confirm they survive | +| Upgrade from Apollo | Strongly recommended | Pending | Export/import bundle, config, credentials, and paired-client state checked | | Coexistence with Sunshine | Optional for alpha | Pending | Record whether install replaces or coexists | | Windows SmartScreen/AV behavior | Yes | Pending | Unsigned prompt or false-positive notes recorded | +## Partial Manual Fixture Notes + +Date: 2026-05-21 + +Current candidate artifact: `NimbusSetup.exe` from source commit `10ed2c32` +(`0.0.0.53`). The observations below were gathered during the maintainer VM +smoke loop on the immediately preceding candidate series. Screenshots and +fixture handoff folders are local validation evidence and are not committed to +the public repository. + +Observed so far: + +| Check | Status | Notes | +| --- | --- | --- | +| Default install path | Observed pass | Fresh install defaults to `C:\Program Files\Nimbus`. | +| Visible installer and app branding | Observed pass | Maintainer-confirmed visible checks use Nimbus wording, including installer surfaces and Web UI/dashboard copy. | +| Windows service after install | Observed compatibility pass | Service is running. Internal service name remains `ApolloService`; visible service description is `Nimbus Service`. | +| Web UI launch path | Observed pass | Tray context menu can open the local Web UI. Browser certificate warning is expected for the inherited local HTTPS flow. | +| Uninstall confirmation modal | Observed pass | Modal copy correctly describes uninstall options and factory-reset behavior. | +| Uninstaller quick tips | Fixed, retest pending | An older candidate reused install/upgrade tips in uninstall mode. Fixed in `0af1f3b8`; retest with the current `0.0.0.53` candidate. | +| Manual tray update check | Observed pass | Maintainer confirmed the `Check for Update` tray action shows visible feedback in the VM. | +| Compatible-client pairing | Observed pass | Artemis on Shield TV paired with the Nimbus VM host. | +| Compatible-client stream smoke | Observed pass | Artemis on Shield TV streamed from the Nimbus VM host and disconnected cleanly; no issues observed so far. | +| Pairing notification target | Fixed, retest pending | Pairing toast now opens `/clients?sec=pair` so the Clients page scrolls directly to the Pair Client section. | +| Runtime log branding | Fixed, retest pending | Fixture logs still showed `VibeshineDisplayRestore` and `vibeshine state`; active runtime logs now use Nimbus wording while legacy cleanup still removes old Vibeshine tasks. | +| Settings naming polish | Fixed, retest pending | General and Files settings no longer show `Vibeshine`, `sunshine_state.json`, `sunshine.log`, or `vibeshine_state.json` as low-risk visible placeholders. | +| Upgrade user-file preservation | Code guard added, retest pending | Nimbus-line MSI uninstall now snapshots config/covers/credentials/log/session/script files before uninstall and restores only missing files afterward. | +| Windows Defender prompt | Observed pass | Maintainer reported no Defender warning during the install smoke. | + +Latest local upgrade-preservation handoff: + +- `build/vm-fixture-handoff/-upgrade-preservation` +- Includes `NimbusSetup.exe` and + `test_upgrade_preservation_fixture.ps1` for prepare/verify checks. + +Still blocking the alpha gate: + +- Confirm the `0.0.0.53` uninstaller quick tips show uninstall-specific copy. +- Confirm clicking the pairing notification scrolls directly to the Pair Client + section in the next candidate. +- Confirm the next candidate logs `NimbusDisplayRestore` and `Nimbus state file` + instead of new runtime `Vibeshine` task/state messages. +- Confirm the General and Files settings pages do not show inherited host names + or filenames as visible placeholder text. +- Confirm uninstall removes or stops the service and removes the Add/Remove + Programs entry. +- Confirm reinstall after uninstall completes without manual cleanup. +- Before a Vibepollo-to-Nimbus or Nimbus-to-Nimbus upgrade, run + `scripts\test_upgrade_preservation_fixture.ps1 -Stage prepare`; after the + upgrade, rerun it with `-Stage verify` to confirm custom root, config, + scripts, credentials, logs, and session-history files survived. +- Record upgrade behavior from Apollo and/or Vibepollo where practical. +- For Apollo upgrade testing, use + [Apollo To Nimbus Switch Guide](apollo-to-nimbus-switch.md) and attach the + sanitized `switch-bundle-summary.md` plus post-import fixture evidence. + ## Evidence Commands The recommended path is to run the evidence collector before install, after diff --git a/nimbus.ico b/nimbus.ico new file mode 100644 index 0000000000..a0bbff8549 Binary files /dev/null and b/nimbus.ico differ diff --git a/nimbus.png b/nimbus.png new file mode 100644 index 0000000000..c4182a11f9 Binary files /dev/null and b/nimbus.png differ diff --git a/nimbus.svg b/nimbus.svg new file mode 100644 index 0000000000..f0a6040504 --- /dev/null +++ b/nimbus.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packaging/windows/LICENSE.rtf b/packaging/windows/LICENSE.rtf index 73e23dc358..053276184d 100644 --- a/packaging/windows/LICENSE.rtf +++ b/packaging/windows/LICENSE.rtf @@ -1,6 +1,6 @@ {\rtf1\ansi\deff0 {\fonttbl{\f0\fswiss Helvetica;}} -\viewkind4\uc1\pard\sa200\sl276\slmult1\f0\fs20 Vibepollo - Self-hosted game stream host for Moonlight.\par +\viewkind4\uc1\pard\sa200\sl276\slmult1\f0\fs20 Nimbus - Self-hosted game stream host for Moonlight-compatible clients.\par Copyright (C) 2025 Nonary\par \par This program is free software: you can redistribute it and/or modify\par diff --git a/packaging/windows/bootstrapper/VibeshineInstaller.cs b/packaging/windows/bootstrapper/VibeshineInstaller.cs index f66a1c8e54..be770cb55b 100644 --- a/packaging/windows/bootstrapper/VibeshineInstaller.cs +++ b/packaging/windows/bootstrapper/VibeshineInstaller.cs @@ -527,10 +527,24 @@ public InstallerWindow(InstallerArguments arguments) { Margin = new Thickness(0, 0, 0, 4) }); - tipsStack.Children.Add(new TextBlock { - Text = "You can install or upgrade Nimbus while actively streaming. No system restart is required. " + var quickTipsBody = BuildFlavor.IsUninstallOnly + ? "Uninstall removes the Nimbus service, firewall rules, and MSI-installed program files. " + + "User settings are preserved unless you choose Factory reset in the confirmation step." + : "You can install or upgrade Nimbus while actively streaming. No system restart is required. " + "After you click Install or Upgrade, the current streaming session will end, then you can usually " - + "start streaming again after about 1–2 minutes without issues.", + + "start streaming again after about 1–2 minutes without issues."; + var quickTipsCommandIntro = BuildFlavor.IsUninstallOnly + ? "You can also uninstall from an elevated shell:" + : "You can also install from an SSH session on this host (run in an elevated shell):"; + var quickTipsCommand = BuildFlavor.IsUninstallOnly + ? "NimbusSetup.exe /uninstall /quiet" + : "NimbusSetup.exe /qn /norestart"; + var quickTipsFooter = BuildFlavor.IsUninstallOnly + ? "Click Uninstall Nimbus to choose removal options." + : "Click the buttons below to proceed."; + + tipsStack.Children.Add(new TextBlock { + Text = quickTipsBody, FontSize = 12.5, Foreground = new SolidColorBrush(Color.FromRgb(211, 220, 246)), Margin = new Thickness(0, 0, 0, 10), @@ -538,7 +552,7 @@ public InstallerWindow(InstallerArguments arguments) { }); tipsStack.Children.Add(new TextBlock { - Text = "You can also install from an SSH session on this host (run in an elevated shell):", + Text = quickTipsCommandIntro, FontSize = 13, Foreground = new SolidColorBrush(Color.FromRgb(203, 219, 241)), Margin = new Thickness(0, 0, 0, 6), @@ -546,7 +560,7 @@ public InstallerWindow(InstallerArguments arguments) { }); tipsStack.Children.Add(new TextBox { - Text = "NimbusSetup.exe /qn /norestart", + Text = quickTipsCommand, IsReadOnly = true, FontFamily = new FontFamily("Consolas"), FontSize = 12.5, @@ -559,7 +573,7 @@ public InstallerWindow(InstallerArguments arguments) { }); tipsStack.Children.Add(new TextBlock { - Text = "Click the buttons below to proceed.", + Text = quickTipsFooter, FontSize = 12.5, Foreground = new SolidColorBrush(Color.FromRgb(211, 220, 246)), Margin = new Thickness(0, 0, 0, 0), @@ -1247,6 +1261,7 @@ private static string BuildApolloInstallWarning(InstallerRunner.InstalledProduct return "Apollo" + versionSuffix + " was detected on this PC.\n\n" + "Nimbus replaces Apollo and cannot be installed while Apollo is installed.\n" + "Continuing will uninstall Apollo before installation.\n\n" + + "This alpha does not import Apollo settings during installation. If you already exported an Apollo-to-Nimbus switch bundle, keep it safe; import it after Nimbus installs. If you have not backed up Apollo yet, click Cancel first.\n\n" + "Click Uninstall Apollo to proceed."; } @@ -1260,7 +1275,7 @@ private string BuildLegacySunshineMigrationWarning() { return "Legacy Sunshine" + versionSuffix + " was detected on this PC.\n\n" + "Nimbus replaces Sunshine. The bootstrapper will uninstall Sunshine first, then start the installation.\n" - + "No settings will be lost during this migration.\n\n" + + "Nimbus will install to its own directory and will not import Sunshine settings during installation. Back up anything you need before continuing.\n\n" + "Click Uninstall Sunshine to proceed."; } @@ -1272,7 +1287,7 @@ private string BuildLegacyApolloMigrationWarning() { return "Legacy Apollo" + versionSuffix + " was detected on this PC.\n\n" + "Nimbus replaces legacy Apollo and will automatically uninstall it first, then install Nimbus.\n" - + "No settings will be carried over.\n\n" + + "This alpha does not import legacy Apollo settings during installation. If you already exported an Apollo-to-Nimbus switch bundle, keep it safe; import it after Nimbus installs. If you have not backed up Apollo yet, click Cancel first.\n\n" + "Click Uninstall Apollo to proceed."; } @@ -1352,17 +1367,8 @@ private string BuildInstallButtonLabel() { } private string ResolvePreferredInstallDirectory() { - var candidates = new[] { - _installedProduct == null ? null : _installedProduct.InstallLocation, - _legacySunshineProduct == null ? null : _legacySunshineProduct.InstallLocation, - _legacySunshineRegistration == null ? null : _legacySunshineRegistration.InstallLocation, - _legacyApolloRegistration == null ? null : _legacyApolloRegistration.InstallLocation - }; - - foreach (var candidate in candidates) { - if (!string.IsNullOrWhiteSpace(candidate)) { - return candidate; - } + if (_installedProduct != null && !string.IsNullOrWhiteSpace(_installedProduct.InstallLocation)) { + return _installedProduct.InstallLocation; } return InstallerRunner.DefaultInstallDirectory; @@ -2151,7 +2157,7 @@ public static void WriteHelp() { Console.WriteLine(" /?, /h, --help Show this help message"); Console.WriteLine(); Console.WriteLine("Supported MSI properties:"); - Console.WriteLine(" INSTALL_ROOT= Install to a custom directory (default: %ProgramFiles%\\Apollo)"); + Console.WriteLine(" INSTALL_ROOT= Install to a custom directory (default: %ProgramFiles%\\Nimbus)"); Console.WriteLine(" INSTALL_SUDOVDA=0 Skip Virtual Display Driver installation"); Console.WriteLine(); Console.WriteLine("Examples:"); @@ -2222,6 +2228,68 @@ internal static class InstallerRunner { "apollosvc", "vibepollo" }; + private static readonly HashSet UpgradePreservationRootDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) { + "config", + "covers", + "credentials", + "logs", + "scripts", + "session_history" + }; + private static readonly HashSet UpgradePreservationBlockedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) { + ".git", + "assets", + "bin", + "drivers", + "node_modules", + "third-party", + "tools", + "web" + }; + private static readonly HashSet UpgradePreservationExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { + ".bat", + ".cmd", + ".conf", + ".ico", + ".ini", + ".jpeg", + ".jpg", + ".json", + ".lnk", + ".log", + ".md", + ".pem", + ".png", + ".ps1", + ".psd1", + ".psm1", + ".toml", + ".txt", + ".url", + ".webp", + ".xml", + ".yaml", + ".yml", + ".zip" + }; + private static readonly HashSet UpgradePreservationBlockedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { + ".cab", + ".cat", + ".com", + ".dll", + ".dylib", + ".exe", + ".exp", + ".inf", + ".lib", + ".msi", + ".msp", + ".obj", + ".pdb", + ".scr", + ".so", + ".sys" + }; internal sealed class InstalledProductInfo { public string ProductCode { get; set; } @@ -2243,6 +2311,14 @@ internal sealed class PayloadMsiInfo { public Version Version { get; set; } } + internal sealed class UpgradePreservationBundle { + public string SourceInstallLocation { get; set; } + public string BackupRoot { get; set; } + public int FileCount { get; set; } + public InstalledProductKind ProductKind { get; set; } + public string ProductDisplayName { get; set; } + } + internal sealed class LegacySunshineRegistration { public string DisplayName { get; set; } public string DisplayVersion { get; set; } @@ -2279,7 +2355,7 @@ public static string DefaultInstallDirectory { get { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Apollo"); + ProductIdentity.Name); } } @@ -3397,12 +3473,15 @@ private static bool IsInstallerPayloadPath(string msiPath) { } try { - var fullPath = Path.GetFullPath(msiPath); - var tempRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), ProductIdentity.TempRootName)); - return fullPath.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase); + foreach (var root in GetEmbeddedMsiExtractRoots()) { + if (PathIsUnderDirectory(msiPath, root)) { + return true; + } + } } catch { - return false; } + + return false; } private static bool LogShowsMsiAccessFailure(string logPath, string msiPath) { @@ -4318,6 +4397,12 @@ public static InstallerResult RunCli(InstallerArguments arguments) { string injectedMsiPath = null; var recoveryDetails = new List(); + if (!IsProcessElevated() + && string.IsNullOrWhiteSpace(arguments.MsiPathOverride) + && ShouldElevateCliForEmbeddedPayload(cliArgs, hasOperation)) { + return RunElevatedBootstrapperCli(arguments); + } + if (!hasOperation) { installMsiPath = ResolveMsiPath(arguments.MsiPathOverride); injectedMsiPath = installMsiPath; @@ -4779,6 +4864,32 @@ private static bool IsServiceRunning(string serviceName) { } } + private static bool ShouldElevateCliForEmbeddedPayload(IReadOnlyList cliArgs, bool hasOperation) { + if (!hasOperation) { + return true; + } + if (cliArgs == null) { + return false; + } + + for (var index = 0; index < cliArgs.Count; index++) { + var operation = cliArgs[index]; + if (!IsOperationSwitch(operation)) { + continue; + } + if (!string.Equals(operation, "/i", StringComparison.OrdinalIgnoreCase) + && !string.Equals(operation, "/package", StringComparison.OrdinalIgnoreCase) + && !string.Equals(operation, "/a", StringComparison.OrdinalIgnoreCase) + && !string.Equals(operation, "/x", StringComparison.OrdinalIgnoreCase)) { + return false; + } + + return index + 1 >= cliArgs.Count || LooksLikeSwitch(cliArgs[index + 1]); + } + + return false; + } + private static string BuildCompetingProductUninstallFailureMessage(string uninstallMessage) { var prefix = "Failed to uninstall Apollo, Nimbus, Vibepollo, or Sunshine before starting Nimbus installation."; if (string.IsNullOrWhiteSpace(uninstallMessage)) { @@ -4909,6 +5020,7 @@ private static InstallerResult UninstallCompetingProducts( if (product.IsWindowsInstaller && !string.IsNullOrWhiteSpace(product.ProductCode)) { logPath = BuildLogPath(logPhase + "_remove"); lastLogPath = logPath; + var preservationBundle = CreateUpgradePreservationBundle(product, logPath); var args = new List { "/x", product.ProductCode, @@ -4924,6 +5036,7 @@ private static InstallerResult UninstallCompetingProducts( CleanupStaleComponentClientsForInstallLocation(product.InstallLocation, logPath); code = RunMsiexec(args, hiddenWindow, requestElevationIfNeeded); if (code == 0 || code == 3010 || code == 1605) { + RestoreUpgradePreservationBundle(preservationBundle, logPath); CleanupCustomArpRegistration(product.InstallLocation, logPath); ScheduleSelfDeleteAndEmptyInstallRootCleanup(product.InstallLocation, logPath); } @@ -5009,8 +5122,12 @@ private static InstallerResult UninstallInstalledProducts( TryStopRelatedServicesAndProcesses(logPath); CleanupStaleComponentClientsForInstallLocation(product.InstallLocation, logPath); + var preservationBundle = factoryResetAppData + ? null + : CreateUpgradePreservationBundle(product, logPath); var code = RunMsiexec(args, hiddenWindow, requestElevationIfNeeded); if (code == 0 || code == 3010 || code == 1605) { + RestoreUpgradePreservationBundle(preservationBundle, logPath); CleanupCustomArpRegistration(product.InstallLocation, logPath); ScheduleSelfDeleteAndEmptyInstallRootCleanup(product.InstallLocation, logPath); } @@ -5044,6 +5161,208 @@ private static InstallerResult UninstallInstalledProducts( }; } + private static UpgradePreservationBundle CreateUpgradePreservationBundle(InstalledProductInfo product, string logPath) { + if (product == null || !IsNimbusLineProduct(product.Kind)) { + return null; + } + + var installLocation = NormalizePath(product.InstallLocation); + if (string.IsNullOrWhiteSpace(installLocation) || !Directory.Exists(installLocation)) { + return null; + } + + var backupRoot = Path.Combine( + Path.GetTempPath(), + ProductIdentity.TempRootName, + "upgrade-preserve", + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")); + var copied = 0; + var errors = 0; + + foreach (var sourcePath in EnumerateFilesForUpgradePreservation(installLocation)) { + if (!ShouldPreserveUpgradeFile(installLocation, sourcePath)) { + continue; + } + + var relativePath = GetRelativePathUnderDirectory(installLocation, sourcePath); + if (string.IsNullOrWhiteSpace(relativePath)) { + continue; + } + + try { + var destinationPath = Path.Combine(backupRoot, relativePath); + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) { + Directory.CreateDirectory(destinationDirectory); + } + File.Copy(sourcePath, destinationPath, true); + copied++; + } catch (Exception ex) { + errors++; + AppendInstallerLogMessage(logPath, "Could not preserve upgrade file '" + sourcePath + "': " + ex.Message); + } + } + + if (copied == 0) { + TryDeleteKnownPath(backupRoot); + return null; + } + + AppendInstallerLogMessage( + logPath, + "Preserved " + copied + " user-owned file(s) from " + + (product.DisplayName ?? product.Kind.ToString()) + + " before uninstall. Backup: " + backupRoot + + (errors > 0 ? " (" + errors + " file(s) skipped)." : ".")); + + return new UpgradePreservationBundle { + SourceInstallLocation = installLocation, + BackupRoot = backupRoot, + FileCount = copied, + ProductKind = product.Kind, + ProductDisplayName = product.DisplayName ?? product.Kind.ToString() + }; + } + + private static void RestoreUpgradePreservationBundle(UpgradePreservationBundle bundle, string logPath) { + if (bundle == null + || string.IsNullOrWhiteSpace(bundle.BackupRoot) + || string.IsNullOrWhiteSpace(bundle.SourceInstallLocation) + || !Directory.Exists(bundle.BackupRoot)) { + return; + } + + var restored = 0; + var skippedExisting = 0; + var errors = 0; + try { + Directory.CreateDirectory(bundle.SourceInstallLocation); + } catch (Exception ex) { + AppendInstallerLogMessage(logPath, "Could not recreate install root for preserved files: " + ex.Message); + return; + } + + foreach (var backupPath in EnumerateFilesForUpgradePreservation(bundle.BackupRoot)) { + var relativePath = GetRelativePathUnderDirectory(bundle.BackupRoot, backupPath); + if (string.IsNullOrWhiteSpace(relativePath)) { + continue; + } + + var destinationPath = Path.Combine(bundle.SourceInstallLocation, relativePath); + try { + if (File.Exists(destinationPath)) { + skippedExisting++; + continue; + } + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) { + Directory.CreateDirectory(destinationDirectory); + } + File.Copy(backupPath, destinationPath, false); + restored++; + } catch (Exception ex) { + errors++; + AppendInstallerLogMessage(logPath, "Could not restore preserved file '" + destinationPath + "': " + ex.Message); + } + } + + AppendInstallerLogMessage( + logPath, + "Restored " + restored + " preserved user-owned file(s)" + + (skippedExisting > 0 ? "; skipped " + skippedExisting + " existing package file(s)" : string.Empty) + + (errors > 0 ? "; " + errors + " restore error(s). Backup retained: " + bundle.BackupRoot : ".")); + + if (errors == 0) { + TryDeleteKnownPath(bundle.BackupRoot); + } + } + + private static IEnumerable EnumerateFilesForUpgradePreservation(string directory) { + string[] files; + try { + files = Directory.GetFiles(directory); + } catch { + yield break; + } + + foreach (var file in files) { + yield return file; + } + + string[] directories; + try { + directories = Directory.GetDirectories(directory); + } catch { + yield break; + } + + foreach (var childDirectory in directories) { + var directoryName = Path.GetFileName(childDirectory); + if (UpgradePreservationBlockedDirectories.Contains(directoryName) + && !UpgradePreservationRootDirectories.Contains(directoryName)) { + continue; + } + + foreach (var file in EnumerateFilesForUpgradePreservation(childDirectory)) { + yield return file; + } + } + } + + private static bool ShouldPreserveUpgradeFile(string rootDirectory, string filePath) { + var relativePath = GetRelativePathUnderDirectory(rootDirectory, filePath); + if (string.IsNullOrWhiteSpace(relativePath)) { + return false; + } + + var segments = relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) { + return false; + } + + var extension = Path.GetExtension(filePath); + if (UpgradePreservationBlockedExtensions.Contains(extension)) { + return false; + } + + for (var i = 0; i < segments.Length - 1; i++) { + if (UpgradePreservationBlockedDirectories.Contains(segments[i]) + && !UpgradePreservationRootDirectories.Contains(segments[i])) { + return false; + } + } + + if (UpgradePreservationRootDirectories.Contains(segments[0])) { + return string.IsNullOrWhiteSpace(extension) + ? false + : UpgradePreservationExtensions.Contains(extension); + } + + return segments.Length == 1 + && !string.IsNullOrWhiteSpace(extension) + && UpgradePreservationExtensions.Contains(extension); + } + + private static string GetRelativePathUnderDirectory(string rootDirectory, string path) { + if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(path)) { + return string.Empty; + } + + try { + var normalizedRoot = Path.GetFullPath(rootDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var normalizedPath = Path.GetFullPath(path); + if (!normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) { + return string.Empty; + } + return normalizedPath.Substring(normalizedRoot.Length); + } catch { + return string.Empty; + } + } + private static void CleanupCustomArpRegistration(string installLocation, string logPath) { var normalizedInstallLocation = NormalizePath(installLocation); if (string.IsNullOrWhiteSpace(normalizedInstallLocation)) { @@ -5289,10 +5608,7 @@ private static string ExtractEmbeddedMsi(bool forceFreshExtract = false) { } private static string BuildEmbeddedMsiExtractDirectory(string versionToken, bool forceFreshExtract) { - var root = Path.Combine( - Path.GetTempPath(), - ProductIdentity.TempRootName, - versionToken); + var root = Path.Combine(GetEmbeddedMsiExtractRoot(), versionToken); if (!forceFreshExtract) { return root; } @@ -5300,6 +5616,31 @@ private static string BuildEmbeddedMsiExtractDirectory(string versionToken, bool return Path.Combine(root, "recovery_" + Guid.NewGuid().ToString("N")); } + private static string GetEmbeddedMsiExtractRoot() { + // Windows Installer may run in the service context and fail to read per-user temp payloads. + if (IsProcessElevated()) { + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + if (!string.IsNullOrWhiteSpace(programData)) { + return Path.Combine(programData, "Nimbus", "InstallerCache"); + } + } + + return Path.Combine(Path.GetTempPath(), ProductIdentity.TempRootName); + } + + private static IEnumerable GetEmbeddedMsiExtractRoots() { + var roots = new List { + Path.Combine(Path.GetTempPath(), ProductIdentity.TempRootName) + }; + + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + if (!string.IsNullOrWhiteSpace(programData)) { + roots.Add(Path.Combine(programData, "Nimbus", "InstallerCache")); + } + + return roots; + } + private static void WriteStreamAtomically(Stream input, string destinationPath) { if (input == null) { throw new InvalidOperationException("The embedded MSI payload could not be read."); diff --git a/packaging/windows/bootstrapper/build_bootstrapper.ps1 b/packaging/windows/bootstrapper/build_bootstrapper.ps1 index ec52bb63d7..3d3623fac4 100644 --- a/packaging/windows/bootstrapper/build_bootstrapper.ps1 +++ b/packaging/windows/bootstrapper/build_bootstrapper.ps1 @@ -225,7 +225,7 @@ $sourceFile = Resolve-PathStrict (Join-Path $scriptDir "VibeshineInstaller.cs") $manifestFile = Resolve-PathStrict (Join-Path $scriptDir "app.manifest") # Keep the branded product icon on the bootstrapper; the installer now uses # explicit process/window shell metadata to stay distinct from the installed app. -$iconPath = Resolve-PathStrict (Join-Path $repoRoot "apollo.ico") +$iconPath = Resolve-PathStrict (Join-Path $repoRoot "nimbus.ico") $licensePath = Resolve-PathStrict (Join-Path $repoRoot "LICENSE") $cscPath = Resolve-CscPath $frameworkRoot = Resolve-PathStrict "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319" diff --git a/packaging/windows/wix/custom_actions.wxs b/packaging/windows/wix/custom_actions.wxs index 81a45b4dc3..28e76cadee 100644 --- a/packaging/windows/wix/custom_actions.wxs +++ b/packaging/windows/wix/custom_actions.wxs @@ -91,7 +91,7 @@ #include #include -#include #include +#include #include #include #include @@ -26,6 +26,7 @@ #include #include #include +#include // lib includes #include @@ -1164,7 +1165,7 @@ namespace confighttp { void getFaviconImage(resp_https_t response, req_https_t request) { print_req(request); - std::ifstream in(WEB_DIR "images/apollo.ico", std::ios::binary); + std::ifstream in(WEB_DIR "images/nimbus.ico", std::ios::binary); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/x-icon"); headers.emplace("X-Frame-Options", "DENY"); @@ -1173,17 +1174,17 @@ namespace confighttp { } /** - * @brief Get the Apollo logo image. + * @brief Get the Nimbus 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 */ - void getApolloLogoImage(resp_https_t response, req_https_t request) { + void getNimbusLogoImage(resp_https_t response, req_https_t request) { print_req(request); - std::ifstream in(WEB_DIR "images/logo-apollo-45.png", std::ios::binary); + std::ifstream in(WEB_DIR "images/logo-nimbus-45.png", std::ios::binary); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/png"); headers.emplace("X-Frame-Options", "DENY"); @@ -3791,6 +3792,102 @@ namespace confighttp { constexpr int kGoldenSnapshotLatestVersion = 2; + struct golden_current_mode_t { + unsigned int width {}; + unsigned int height {}; + double refresh_hz {}; + }; + + struct golden_current_summary_t { + bool valid {false}; + bool active_virtual_display {false}; + std::set devices; + std::unordered_map modes; + std::unordered_map hdr; + std::unordered_map> origins; + std::string primary; + }; + + static std::string normalized_display_id(std::string id) { + id.erase(id.begin(), std::find_if(id.begin(), id.end(), [](unsigned char ch) { + return !std::isspace(ch); + })); + id.erase(std::find_if(id.rbegin(), id.rend(), [](unsigned char ch) { + return !std::isspace(ch); + }).base(), + id.end()); + std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return id; + } + + static bool contains_ci(const std::string &haystack, const std::string &needle) { + if (needle.empty()) { + return true; + } + if (haystack.size() < needle.size()) { + return false; + } + for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + bool match = true; + for (size_t j = 0; j < needle.size(); ++j) { + if (std::tolower(static_cast(haystack[i + j])) != + std::tolower(static_cast(needle[j]))) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; + } + + static bool equals_ci(const std::string &lhs, const std::string &rhs) { + return lhs.size() == rhs.size() && contains_ci(lhs, rhs); + } + + static bool is_virtual_display_device(const display_device::EnumeratedDevice &device) { + if (contains_ci(device.m_device_id, "SUDOVDA") || + contains_ci(device.m_device_id, "SUDOMAKER") || + contains_ci(device.m_display_name, "SUDOVDA") || + contains_ci(device.m_display_name, "SUDOMAKER") || + contains_ci(device.m_friendly_name, "SUDOVDA") || + contains_ci(device.m_friendly_name, "SUDOMAKER")) { + return true; + } + if (equals_ci(device.m_friendly_name, "SudoMaker Virtual Display Adapter")) { + return true; + } + return device.m_edid && equals_ci(device.m_edid->m_manufacturer_id, "SMK"); + } + + static bool is_active_display_device(const display_device::EnumeratedDevice &device) { + return device.m_info.has_value() || !device.m_display_name.empty(); + } + + static std::optional floating_to_double(const display_device::FloatingPoint &value) { + if (std::holds_alternative(value)) { + return std::get(value); + } + const auto &rat = std::get(value); + if (rat.m_denominator == 0) { + return std::nullopt; + } + return static_cast(rat.m_numerator) / static_cast(rat.m_denominator); + } + + static bool nearly_equal_refresh(double lhs, double rhs) { + if (!std::isfinite(lhs) || !std::isfinite(rhs)) { + return false; + } + const double diff = std::abs(lhs - rhs); + const double scale = std::max({1.0, std::abs(lhs), std::abs(rhs)}); + return diff <= scale * 1e-4; + } + static std::optional read_json_file_nofail(const std::filesystem::path &path) { try { std::ifstream file(path, std::ios::binary); @@ -3840,6 +3937,213 @@ namespace confighttp { return false; } + static std::set snapshot_topology_devices(const nlohmann::json &root) { + std::set ids; + auto topology = root.find("topology"); + if (topology != root.end() && topology->is_array()) { + for (const auto &group : *topology) { + if (!group.is_array()) { + continue; + } + for (const auto &device : group) { + if (device.is_string()) { + auto id = normalized_display_id(device.get()); + if (!id.empty()) { + ids.insert(std::move(id)); + } + } + } + } + } + if (ids.empty()) { + auto modes = root.find("modes"); + if (modes != root.end() && modes->is_object()) { + for (auto it = modes->begin(); it != modes->end(); ++it) { + auto id = normalized_display_id(it.key()); + if (!id.empty()) { + ids.insert(std::move(id)); + } + } + } + } + return ids; + } + + static std::unordered_map snapshot_modes(const nlohmann::json &root) { + std::unordered_map modes; + auto modes_it = root.find("modes"); + if (modes_it == root.end() || !modes_it->is_object()) { + return modes; + } + for (auto it = modes_it->begin(); it != modes_it->end(); ++it) { + if (!it->is_object()) { + continue; + } + auto id = normalized_display_id(it.key()); + const auto width = it->value("w", 0u); + const auto height = it->value("h", 0u); + const auto num = it->value("num", 0u); + const auto den = it->value("den", 0u); + if (id.empty() || width == 0 || height == 0 || den == 0) { + continue; + } + modes.emplace(std::move(id), golden_current_mode_t { + .width = width, + .height = height, + .refresh_hz = static_cast(num) / static_cast(den), + }); + } + return modes; + } + + static std::unordered_map snapshot_hdr_states(const nlohmann::json &root) { + std::unordered_map states; + auto hdr_it = root.find("hdr"); + if (hdr_it == root.end() || !hdr_it->is_object()) { + return states; + } + for (auto it = hdr_it->begin(); it != hdr_it->end(); ++it) { + if (!it->is_string()) { + continue; + } + auto id = normalized_display_id(it.key()); + auto value = boost::algorithm::to_lower_copy(it->get()); + if (id.empty() || (value != "on" && value != "off")) { + continue; + } + states.emplace(std::move(id), value == "on"); + } + return states; + } + + static std::unordered_map> snapshot_origins(const nlohmann::json &root) { + std::unordered_map> origins; + auto origins_it = root.find("origins"); + if (origins_it == root.end() || !origins_it->is_object()) { + return origins; + } + for (auto it = origins_it->begin(); it != origins_it->end(); ++it) { + if (!it->is_object()) { + continue; + } + auto id = normalized_display_id(it.key()); + if (id.empty()) { + continue; + } + origins.emplace(std::move(id), std::make_pair(it->value("x", 0), it->value("y", 0))); + } + return origins; + } + + static golden_current_summary_t current_golden_comparison_summary() { + golden_current_summary_t summary; + const auto devices = display_helper_integration::enumerate_devices(display_device::DeviceEnumerationDetail::Full); + if (!devices) { + return summary; + } + + std::set exclusions; + for (auto id : config::video.dd.snapshot_exclude_devices) { + id = normalized_display_id(std::move(id)); + if (!id.empty()) { + exclusions.insert(std::move(id)); + } + } + + for (const auto &device : *devices) { + if (is_virtual_display_device(device)) { + if (is_active_display_device(device)) { + summary.active_virtual_display = true; + } + continue; + } + if (!device.m_info || device.m_display_name.empty()) { + continue; + } + + auto id = normalized_display_id(device.m_device_id.empty() ? device.m_display_name : device.m_device_id); + if (id.empty() || exclusions.contains(id)) { + continue; + } + + summary.devices.insert(id); + if (auto refresh = floating_to_double(device.m_info->m_refresh_rate)) { + summary.modes[id] = golden_current_mode_t { + .width = device.m_info->m_resolution.m_width, + .height = device.m_info->m_resolution.m_height, + .refresh_hz = *refresh, + }; + } + if (device.m_info->m_hdr_state) { + summary.hdr[id] = *device.m_info->m_hdr_state == display_device::HdrState::Enabled; + } + summary.origins[id] = std::make_pair(device.m_info->m_origin_point.m_x, device.m_info->m_origin_point.m_y); + if (device.m_info->m_primary) { + summary.primary = id; + } + } + + summary.valid = !summary.devices.empty(); + return summary; + } + + static std::optional snapshot_current_mismatch_reason(const nlohmann::json &root) { + const auto current = current_golden_comparison_summary(); + if (current.active_virtual_display) { + return std::nullopt; + } + if (!current.valid) { + return std::nullopt; + } + + const auto snapshot_devices = snapshot_topology_devices(root); + if (snapshot_devices.empty()) { + return "invalid_snapshot"; + } + if (snapshot_devices != current.devices) { + return "display_set_changed"; + } + + const auto modes = snapshot_modes(root); + for (const auto &[id, mode] : modes) { + auto current_mode = current.modes.find(id); + if (current_mode == current.modes.end()) { + continue; + } + if (mode.width != current_mode->second.width || + mode.height != current_mode->second.height || + !nearly_equal_refresh(mode.refresh_hz, current_mode->second.refresh_hz)) { + return "display_mode_changed"; + } + } + + const auto hdr_states = snapshot_hdr_states(root); + for (const auto &[id, hdr] : hdr_states) { + auto current_hdr = current.hdr.find(id); + if (current_hdr != current.hdr.end() && hdr != current_hdr->second) { + return "hdr_changed"; + } + } + + auto primary_it = root.find("primary"); + if (primary_it != root.end() && primary_it->is_string()) { + const auto primary = normalized_display_id(primary_it->get()); + if (!primary.empty() && !current.primary.empty() && primary != current.primary) { + return "primary_changed"; + } + } + + const auto origins = snapshot_origins(root); + for (const auto &[id, origin] : origins) { + auto current_origin = current.origins.find(id); + if (current_origin != current.origins.end() && origin != current_origin->second) { + return "layout_changed"; + } + } + + return ""; + } + void getGoldenStatus(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; @@ -3850,6 +4154,9 @@ namespace confighttp { std::optional snapshot_version; bool has_layout = false; bool needs_layout_upgrade = false; + bool out_of_date = false; + bool comparison_available = false; + std::string out_of_date_reason; try { for (const auto &p : golden_snapshot_candidates()) { if (file_exists_nofail(p)) { @@ -3859,8 +4166,23 @@ namespace confighttp { has_layout = snapshot_has_layout_data(*root); const bool latest_schema = snapshot_version && *snapshot_version >= kGoldenSnapshotLatestVersion; needs_layout_upgrade = !latest_schema || !has_layout; + out_of_date = needs_layout_upgrade; + if (needs_layout_upgrade) { + out_of_date_reason = "schema_upgrade_required"; + } + if (!has_active_stream_sessions()) { + if (auto mismatch = snapshot_current_mismatch_reason(*root)) { + comparison_available = true; + if (!mismatch->empty()) { + out_of_date = true; + out_of_date_reason = *mismatch; + } + } + } } else { needs_layout_upgrade = true; + out_of_date = true; + out_of_date_reason = "unreadable_snapshot"; } break; } @@ -3872,6 +4194,9 @@ namespace confighttp { out["latest_snapshot_version"] = kGoldenSnapshotLatestVersion; out["has_layout"] = has_layout; out["needs_layout_upgrade"] = needs_layout_upgrade; + out["out_of_date"] = out_of_date; + out["comparison_available"] = comparison_available; + out["out_of_date_reason"] = out_of_date_reason; send_response(response, out); } @@ -4314,9 +4639,11 @@ namespace confighttp { register_api_route("^/api/logs/export_crash/manifest$", "GET", getCrashBundleManifest); register_api_route("^/api/logs/export_crash$", "GET", downloadCrashBundle); #endif - server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; - server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; - server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getApolloLogoImage; // legacy alias + server.resource["^/images/nimbus.ico$"]["GET"] = getFaviconImage; + server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; // legacy alias + server.resource["^/images/logo-nimbus-45.png$"]["GET"] = getNimbusLogoImage; + server.resource["^/images/logo-apollo-45.png$"]["GET"] = getNimbusLogoImage; // legacy alias + server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getNimbusLogoImage; // legacy alias server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; register_api_route("^/api/token$", "POST", generateApiToken); register_api_route("^/api/tokens$", "GET", listApiTokens); diff --git a/src/confighttp_playnite.cpp b/src/confighttp_playnite.cpp index 068a560ea8..e92e48d440 100644 --- a/src/confighttp_playnite.cpp +++ b/src/confighttp_playnite.cpp @@ -602,10 +602,8 @@ namespace confighttp { return std::nullopt; } pt::ptree tree; - try { - pt::read_json(path.string(), tree); - } catch (const std::exception &e) { - BOOST_LOG(warning) << "Crash dismissal: failed to read state file: " << e.what(); + if (!statefile::read_json_with_recovery(path.string(), tree)) { + BOOST_LOG(warning) << "Crash dismissal: failed to read state file"; return std::nullopt; } auto root_it = tree.find("root"); @@ -636,10 +634,8 @@ namespace confighttp { fs::path path(path_str); pt::ptree tree; if (fs::exists(path)) { - try { - pt::read_json(path.string(), tree); - } catch (const std::exception &e) { - BOOST_LOG(warning) << "Crash dismissal: failed to read existing state file: " << e.what(); + if (!statefile::read_json_with_recovery(path.string(), tree)) { + BOOST_LOG(warning) << "Crash dismissal: failed to read existing state file"; tree = {}; } } diff --git a/src/confighttp_rtss.cpp b/src/confighttp_rtss.cpp index fd4b314de1..d485abc5ab 100644 --- a/src/confighttp_rtss.cpp +++ b/src/confighttp_rtss.cpp @@ -42,6 +42,27 @@ namespace confighttp { using lossless_paths::discover_lossless_candidates; using lossless_paths::resolve_lossless_candidate; + namespace { + constexpr auto k_lossless_default_hint = "C:\\\\Program Files (x86)\\\\Steam\\\\steamapps\\\\common\\\\Lossless Scaling\\\\LosslessScaling.exe"; + + nlohmann::json make_lossless_status_unavailable_response() { + return { + {"configured_path", config::lossless_scaling.exe_path}, + {"checked_path", config::lossless_scaling.exe_path}, + {"configured_exists", false}, + {"checked_exists", false}, + {"configured_is_directory", false}, + {"checked_is_directory", false}, + {"default_path", k_lossless_default_hint}, + {"default_exists", false}, + {"default_is_directory", false}, + {"suggested_path", k_lossless_default_hint}, + {"candidates", nlohmann::json::array()}, + {"message", "Lossless Scaling status unavailable. Check the Nimbus logs for details."} + }; + } + } // namespace + void getRtssStatus(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; @@ -127,7 +148,7 @@ namespace confighttp { } else if (!fl.nvcp_ready) { add_segment(provider_message, "NVIDIA Control Panel integration unavailable (NvAPI not ready)."); } else { - add_segment(provider_message, "NVIDIA Control Panel limiter selected (not recommended). Sunshine recommends RTSS for smoother pacing."); + add_segment(provider_message, "NVIDIA Control Panel limiter selected (not recommended). Nimbus recommends RTSS for smoother pacing."); } } else if (prefer_rtss) { if (!rtss.path_exists) { @@ -137,10 +158,10 @@ namespace confighttp { } else { add_segment(provider_message, std::string("Frame limiter configured for ") + describe_provider(configured_id) + "; awaiting next stream."); if (!rtss.process_running) { - add_segment(provider_message, "Sunshine will launch RTSS automatically when streaming starts."); + add_segment(provider_message, "Nimbus will launch RTSS automatically when streaming starts."); } if (rtss_bootstrap_pending) { - add_segment(provider_message, "Sunshine will refresh RTSS configuration automatically on the next stream."); + add_segment(provider_message, "Nimbus will refresh RTSS configuration automatically on the next stream."); } } } else { @@ -154,17 +175,17 @@ namespace confighttp { if (prefer_rtss) { add_segment(provider_message, "RTSS provides the smoothest pacing; NVIDIA's limiter is not recommended because it cannot guarantee perfect frame pacing."); } else if (fl.configured_provider == platf::frame_limiter_provider::nvidia_control_panel) { - add_segment(provider_message, "Sunshine recommends installing RTSS for the smoothest streaming experience; NVIDIA's limiter is not recommended because it cannot guarantee perfect frame pacing."); + add_segment(provider_message, "Nimbus recommends installing RTSS for the smoothest streaming experience; NVIDIA's limiter is not recommended because it cannot guarantee perfect frame pacing."); } std::string override_message; if (fl.disable_vsync) { if (fl.nv_overrides_supported) { - override_message = "NVIDIA overrides ready: Sunshine will force VSYNC off during streams."; + override_message = "NVIDIA overrides ready: Nimbus will force VSYNC off during streams."; } else if (fl.nvidia_available && !fl.nvcp_ready) { - override_message = "NvAPI unavailable; Sunshine will fall back to forcing the highest available refresh rate during streams."; + override_message = "NvAPI unavailable; Nimbus will fall back to forcing the highest available refresh rate during streams."; } else if (!fl.nvidia_available) { - override_message = "No NVIDIA GPU detected; Sunshine will force the highest available refresh rate during streams as a best-effort VSYNC workaround."; + override_message = "No NVIDIA GPU detected; Nimbus will force the highest available refresh rate during streams as a best-effort VSYNC workaround."; } } @@ -180,7 +201,7 @@ namespace confighttp { send_response(response, out); } - void getLosslessScalingStatus(resp_https_t response, req_https_t request) { + void getLosslessScalingStatus(resp_https_t response, req_https_t request) try { if (!authenticate(response, request)) { return; } @@ -236,6 +257,8 @@ namespace confighttp { const bool checked_is_directory = checked_path && std::filesystem::is_directory(*checked_path, checked_ec); std::error_code default_ec; const bool default_is_directory = default_path && std::filesystem::is_directory(*default_path, default_ec); + std::error_code default_exists_ec; + const bool default_exists = default_path && std::filesystem::exists(*default_path, default_exists_ec); nlohmann::json out; out["configured_path"] = configured_utf8; @@ -245,7 +268,7 @@ namespace confighttp { out["configured_is_directory"] = configured_is_directory; out["checked_is_directory"] = checked_is_directory; out["default_path"] = default_hint; - out["default_exists"] = resolved_default.has_value() || (default_path && std::filesystem::exists(*default_path)); + out["default_exists"] = resolved_default.has_value() || default_exists; out["default_is_directory"] = default_is_directory; std::string suggested_utf8 = configured_utf8; @@ -294,6 +317,12 @@ namespace confighttp { out["message"] = message; send_response(response, out); + } catch (const std::exception &e) { + BOOST_LOG(warning) << "Lossless Scaling status check failed: " << e.what(); + send_response(response, make_lossless_status_unavailable_response()); + } catch (...) { + BOOST_LOG(warning) << "Lossless Scaling status check failed with an unknown exception"; + send_response(response, make_lossless_status_unavailable_response()); } } // namespace confighttp diff --git a/src/crypto.cpp b/src/crypto.cpp index 43f71b80a8..d6313182fe 100644 --- a/src/crypto.cpp +++ b/src/crypto.cpp @@ -35,8 +35,6 @@ namespace crypto { // Expired or not-yet-valid certificates are fine. Sometimes Moonlight is running on embedded devices // that don't have accurate clocks (or haven't yet synchronized by the time Moonlight first runs). // This behavior also matches what GeForce Experience does. - // TODO: Checking for X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY is a temporary workaround to get moonlight-embedded to work on the raspberry pi - case X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY: case X509_V_ERR_CERT_NOT_YET_VALID: case X509_V_ERR_CERT_HAS_EXPIRED: return 1; diff --git a/src/http_auth.cpp b/src/http_auth.cpp index 8354b49a3f..8d4a2e60f1 100644 --- a/src/http_auth.cpp +++ b/src/http_auth.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -425,7 +426,9 @@ namespace confighttp { return fs::exists(path); }; dependencies.read_json = [](const std::string &path, pt::ptree &tree) { - boost::property_tree::json_parser::read_json(path, tree); + if (!statefile::read_json_with_recovery(path, tree)) { + throw std::runtime_error("state JSON read failed"); + } }; dependencies.write_json = [](const std::string &path, const pt::ptree &tree) { statefile::write_json_atomic(path, tree); @@ -465,7 +468,9 @@ namespace confighttp { return fs::exists(path); }; deps.read_json = [](const std::string &path, pt::ptree &tree) { - boost::property_tree::json_parser::read_json(path, tree); + if (!statefile::read_json_with_recovery(path, tree)) { + throw std::runtime_error("state JSON read failed"); + } }; deps.write_json = [](const std::string &path, const pt::ptree &tree) { statefile::write_json_atomic(path, tree); diff --git a/src/main.cpp b/src/main.cpp index 3cf541cb05..3401c40db9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -613,7 +613,7 @@ int main(int argc, char *argv[]) { BOOST_LOG(fatal) << "HTTP interface failed to initialize"sv; #ifdef _WIN32 - BOOST_LOG(fatal) << "To relaunch Apollo successfully, use the shortcut in the Start Menu. Do not run sunshine.exe manually."sv; + BOOST_LOG(fatal) << "To relaunch Nimbus successfully, use the shortcut in the Start Menu. Do not run sunshine.exe manually."sv; std::this_thread::sleep_for(10s); #endif @@ -649,8 +649,8 @@ int main(int argc, char *argv[]) { #ifdef _WIN32 // If we're using the default port and GameStream is enabled, warn the user if (config::sunshine.port == 47989 && is_gamestream_enabled()) { - BOOST_LOG(fatal) << "GameStream is still enabled in GeForce Experience! This *will* cause streaming problems with Apollo!"sv; - BOOST_LOG(fatal) << "Disable GameStream on the SHIELD tab in GeForce Experience or change the Port setting on the Advanced tab in the Apollo Web UI."sv; + BOOST_LOG(fatal) << "GameStream is still enabled in GeForce Experience! This *will* cause streaming problems with Nimbus!"sv; + BOOST_LOG(fatal) << "Disable GameStream on the SHIELD tab in GeForce Experience or change the Port setting on the Advanced tab in the Nimbus Web UI."sv; } #endif diff --git a/src/network.cpp b/src/network.cpp index 131f209a0f..2a8a4940bd 100644 --- a/src/network.cpp +++ b/src/network.cpp @@ -229,6 +229,6 @@ namespace net { } } - return !instancename.empty() ? instancename : "Apollo"; + return !instancename.empty() ? instancename : "Nimbus"; } } // namespace net diff --git a/src/nvenc/nvenc_api.h b/src/nvenc/nvenc_api.h index 6e46fc52c5..cef2aa30ac 100644 --- a/src/nvenc/nvenc_api.h +++ b/src/nvenc/nvenc_api.h @@ -137,7 +137,6 @@ namespace nvenc::api { constexpr bool supports_separate_bit_depth_fields(uint32_t api_version) { switch (api_version) { - case make_api_version(12U, 1U): case make_api_version(12U, 2U): case make_api_version(13U, 0U): return true; diff --git a/src/nvenc/nvenc_base.cpp b/src/nvenc/nvenc_base.cpp index 48df9f01a3..42ad05f205 100644 --- a/src/nvenc/nvenc_base.cpp +++ b/src/nvenc/nvenc_base.cpp @@ -509,12 +509,12 @@ namespace nvenc { }; const auto set_hevc_10bit_format = [&](auto &format_config) { -#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 1) +#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 2) if (api::supports_separate_bit_depth_fields(selected_api_version)) { format_config.inputBitDepth = NV_ENC_BIT_DEPTH_10; format_config.outputBitDepth = NV_ENC_BIT_DEPTH_10; } else { - // SDK 13 removed the old HEVC bit-depth field names, but the v11/v12.0 + // SDK 12.2 removed the old HEVC bit-depth field names, but the v11/v12.1 // layout still occupies these reserved bits. format_config.reserved3 = 2; } @@ -524,9 +524,20 @@ namespace nvenc { }; const auto set_av1_10bit_format = [&](auto &format_config) { -#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 1) - format_config.inputBitDepth = NV_ENC_BIT_DEPTH_10; - format_config.outputBitDepth = NV_ENC_BIT_DEPTH_10; +#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 2) + if (api::supports_separate_bit_depth_fields(selected_api_version)) { + format_config.inputBitDepth = NV_ENC_BIT_DEPTH_10; + format_config.outputBitDepth = NV_ENC_BIT_DEPTH_10; + } else { + // SDK 12.2 moved AV1 bit depth out of the packed bitfield, but API + // 12.1 drivers still read the original input/output bit-depth bits. +#if NVENCAPI_MAJOR_VERSION > 12 + format_config.enableTemporalSVC = 1; + format_config.reserved4 = 1; +#else + format_config.reserved4 = 18; +#endif + } #else format_config.inputPixelBitDepthMinus8 = 2; format_config.pixelBitDepthMinus8 = 2; @@ -545,7 +556,7 @@ namespace nvenc { } else { format_config.entropyCodingMode = NV_ENC_H264_ENTROPY_CODING_MODE_CABAC; } -#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 1) +#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 2) if (api::supports_separate_bit_depth_fields(selected_api_version)) { format_config.inputBitDepth = buffer_is_10bit() ? NV_ENC_BIT_DEPTH_10 : NV_ENC_BIT_DEPTH_8; format_config.outputBitDepth = buffer_is_10bit() ? NV_ENC_BIT_DEPTH_10 : NV_ENC_BIT_DEPTH_8; @@ -565,7 +576,7 @@ namespace nvenc { if (buffer_is_10bit()) { set_hevc_10bit_format(format_config); } -#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 1) +#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 2) else if (api::supports_separate_bit_depth_fields(selected_api_version)) { format_config.inputBitDepth = NV_ENC_BIT_DEPTH_8; format_config.outputBitDepth = NV_ENC_BIT_DEPTH_8; @@ -603,10 +614,13 @@ namespace nvenc { format_config.enableBitstreamPadding = config.insert_filler_data; if (buffer_is_10bit()) { set_av1_10bit_format(format_config); - } else { + } +#if NVENCAPI_MAJOR_VERSION > 12 || (NVENCAPI_MAJOR_VERSION == 12 && NVENCAPI_MINOR_VERSION >= 2) + else if (api::supports_separate_bit_depth_fields(selected_api_version)) { format_config.inputBitDepth = NV_ENC_BIT_DEPTH_8; format_config.outputBitDepth = NV_ENC_BIT_DEPTH_8; } +#endif format_config.colorPrimaries = colorspace.primaries; format_config.transferCharacteristics = colorspace.tranfer_function; format_config.matrixCoefficients = colorspace.matrix; diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index 64df208984..03e8903405 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -898,10 +898,8 @@ namespace nvhttp { pt::ptree vibeshine_tree; if (fs::exists(vibeshine_path)) { - try { - pt::read_json(vibeshine_path, vibeshine_tree); - } catch (const std::exception &e) { - BOOST_LOG(error) << "Couldn't read "sv << vibeshine_path << ": "sv << e.what(); + if (!statefile::read_json_with_recovery(vibeshine_path, vibeshine_tree)) { + BOOST_LOG(error) << "Couldn't read "sv << vibeshine_path; vibeshine_tree = {}; } } @@ -952,15 +950,14 @@ namespace nvhttp { if (share_state_file) { update::state.last_notified_version = root.value("last_notified_version", ""); } else if (fs::exists(vibeshine_path)) { - try { - pt::ptree vibeshine_tree; - pt::read_json(vibeshine_path, vibeshine_tree); + pt::ptree vibeshine_tree; + if (statefile::read_json_with_recovery(vibeshine_path, vibeshine_tree)) { update::state.last_notified_version = vibeshine_tree.get("root.last_notified_version", ""); #ifdef _WIN32 http::shared_virtual_display_guid = vibeshine_tree.get("root.shared_virtual_display_guid", ""); #endif - } catch (const std::exception &e) { - BOOST_LOG(warning) << "Couldn't read "sv << vibeshine_path << " for notification state: "sv << e.what(); + } else { + BOOST_LOG(warning) << "Couldn't read "sv << vibeshine_path << " for notification state"; update::state.last_notified_version.clear(); #ifdef _WIN32 http::shared_virtual_display_guid.clear(); @@ -2596,6 +2593,15 @@ namespace nvhttp { } if (request) { + if (!allow_display_changes && + launch_session->virtual_display_recreated_on_demand && + launch_session->virtual_display && + request->configuration && + request->configuration->m_hdr_state) { + request->configuration->m_hdr_state.reset(); + BOOST_LOG(info) << "Display helper: suppressing HDR state change during retained resume virtual-display recreation."; + } + if (!display_helper_integration::apply(*request)) { if (helper_session_available) { BOOST_LOG(warning) << "Display helper: failed to apply display configuration; continuing with existing display."; diff --git a/src/platform/common.h b/src/platform/common.h index 1836ca76d1..83c68d0719 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -573,6 +573,10 @@ namespace platf { std::unique_ptr audio_control(); +#ifdef _WIN32 + void restore_default_audio_device(); +#endif + /** * @brief Get the display_t instance for the given hwdevice_type. * If display_name is empty, use the first monitor that's compatible you can find @@ -818,7 +822,7 @@ namespace platf { */ platform_caps::caps_t get_capabilities(); -#define SERVICE_NAME "Apollo" +#define SERVICE_NAME "Nimbus" #define SERVICE_TYPE "_nvstream._tcp" namespace publish { diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index c6db800c66..2273a2a781 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -1647,6 +1647,17 @@ namespace platf { return control; } + void restore_default_audio_device() { + audio::co_init_t co_init; + audio::audio_control_t audio_ctrl; + if (audio_ctrl.init() != 0) { + BOOST_LOG(warning) << "Unable to initialize Windows audio control for default output restore"sv; + return; + } + + audio_ctrl.reset_default_device(); + } + std::unique_ptr init() { if (dxgi::init()) { return nullptr; diff --git a/src/platform/windows/display.h b/src/platform/windows/display.h index f4ddc63372..5cd8bab2ba 100644 --- a/src/platform/windows/display.h +++ b/src/platform/windows/display.h @@ -464,6 +464,7 @@ namespace platf::dxgi { std::unique_ptr _ipc_session; ::video::config_t _config; std::string _display_name; + std::shared_ptr last_cached_frame; bool _session_initialized_logged = false; bool _frame_locked = false; }; @@ -536,6 +537,7 @@ namespace platf::dxgi { * @brief Name of the display being captured. */ std::string _display_name; + std::shared_ptr last_cached_frame; /** * @brief Last width of the staging texture for the base class texture. diff --git a/src/platform/windows/display_helper_integration.cpp b/src/platform/windows/display_helper_integration.cpp index 1d22c6d38c..1e22e4276c 100644 --- a/src/platform/windows/display_helper_integration.cpp +++ b/src/platform/windows/display_helper_integration.cpp @@ -203,9 +203,13 @@ namespace { constexpr std::chrono::milliseconds kHelperIpcReadyTimeout {5000}; constexpr std::chrono::milliseconds kHelperIpcReadyPoll {100}; - // Stream-start requirement: stop any helper restore activity immediately. + // Stream-start requirement: stop very recent helper restore activity quickly. + // Once the helper has had time to begin an actual restore, do not kill/overwrite + // that in-flight restore from a later stream-start probe; the helper will either + // finish restoring or an explicit APPLY will supersede it. constexpr std::chrono::milliseconds kDisarmRestoreBudget {150}; constexpr std::chrono::milliseconds kDisarmRetryThrottle {150}; + constexpr std::chrono::milliseconds kDisarmRestoreGrace {5000}; constexpr std::chrono::milliseconds kDeferredApplyInitialDelay {2000}; constexpr std::chrono::milliseconds kDeferredApplyRetryBase {500}; constexpr std::chrono::milliseconds kDeferredApplyRetryMax {10000}; @@ -215,6 +219,16 @@ namespace { bool ensure_helper_started(bool force_restart = false, bool force_enable = false); const char *virtual_layout_to_string(const display_helper_integration::VirtualDisplayArrangement layout); + bool helper_process_running() { + std::lock_guard lg(helper_mutex()); + if (HANDLE h = helper_proc().get_process_handle()) { + return WaitForSingleObject(h, 0) == WAIT_TIMEOUT; + } + return false; + } + + bool restore_expected_with_live_helper(); + std::chrono::milliseconds deferred_apply_retry_delay(int attempts) { if (attempts <= 0) { return kDeferredApplyRetryBase; @@ -586,6 +600,17 @@ namespace { return duration_cast(steady_clock::now().time_since_epoch()).count(); } + bool restore_expected_with_live_helper() { + if (!g_restore_expected.load(std::memory_order_relaxed)) { + return false; + } + if (helper_process_running()) { + return true; + } + g_restore_expected.store(false, std::memory_order_relaxed); + return false; + } + // Active session display parameters snapshot for re-apply on reconnect. // We do NOT cache serialized JSON, only the subset of session fields that // affect display configuration. On reconnect, we rebuild the full @@ -625,20 +650,25 @@ namespace { return false; } - bool helper_running = false; - { - std::lock_guard lg(helper_mutex()); - if (HANDLE h = helper_proc().get_process_handle()) { - helper_running = (WaitForSingleObject(h, 0) == WAIT_TIMEOUT); - } - } - + const bool helper_running = helper_process_running(); if (!helper_running) { + g_restore_expected.store(false, std::memory_order_relaxed); return false; } const bool restore_expected = g_restore_expected.load(std::memory_order_relaxed); const auto now_us = now_steady_us(); + const auto last_revert_us = g_last_revert_us.load(std::memory_order_relaxed); + if (restore_expected && last_revert_us > 0) { + const auto disarm_grace_us = kDisarmRestoreGrace.count() * 1000LL; + if ((now_us - last_revert_us) >= disarm_grace_us) { + BOOST_LOG(info) << "Display helper: restore has been pending for more than " + << kDisarmRestoreGrace.count() + << "ms; not sending DISARM so the unconfirmed restore can complete."; + return false; + } + } + const auto last_attempt_us = g_last_disarm_attempt_us.load(std::memory_order_relaxed); // Don't spam DISARM frames (they share the helper's job/message queues with APPLY/REVERT). @@ -695,7 +725,6 @@ namespace { // Fail-safe: if we recently initiated a helper restore, and DISARM couldn't be delivered quickly, // terminate the helper so restore activity stops immediately (prevents virtual display crash loops). - const auto last_revert_us = g_last_revert_us.load(std::memory_order_relaxed); const bool revert_recent = (now_us - last_revert_us) < (30LL * 1000LL * 1000LL); if (revert_recent) { BOOST_LOG(warning) << "Display helper: DISARM could not be delivered within " @@ -1001,12 +1030,9 @@ namespace { } std::string build_revert_payload(bool prefer_golden_if_current_missing) { - if (!prefer_golden_if_current_missing) { - return {}; - } - nlohmann::json j = nlohmann::json::object(); - j["sunshine_prefer_golden_if_current_missing"] = true; + j["sunshine_prefer_golden_if_current_missing"] = prefer_golden_if_current_missing; + j["sunshine_always_restore_from_golden"] = config::video.dd.always_restore_from_golden; return j.dump(); } @@ -1116,9 +1142,17 @@ namespace display_helper_integration { // Stream-start policy: if a helper is already running, hard-restart it immediately // rather than attempting graceful STOP (avoids apply timeouts and wedged restore loops). + // Exception: if we recently asked the helper to restore and did not cancel it inside + // the short disarm grace window, keep that helper alive and let APPLY supersede the + // restore through IPC. Killing it here can strand the host in a partially restored + // physical-display mode when a monitor input is still switched away. // In SYSTEM/no-user-session mode we still keep hard restart to recover stale pipe state, // but we avoid in-process display API fallback if helper IPC remains unavailable. - const bool hard_restart = (request.session != nullptr); + const bool restore_expected = restore_expected_with_live_helper(); + const bool hard_restart = (request.session != nullptr) && !restore_expected; + if (request.session && restore_expected) { + BOOST_LOG(info) << "Display helper: reusing existing helper because an unconfirmed restore is pending; APPLY will supersede it."; + } bool helper_ready = ensure_helper_started(hard_restart, true); if (!helper_ready && hard_restart) { @@ -1140,6 +1174,7 @@ namespace display_helper_integration { const bool ok = platf::display_helper_client::send_apply_json(*payload); BOOST_LOG(info) << "Display helper: APPLY dispatch result=" << (ok ? "true" : "false"); if (ok && request.session) { + g_restore_expected.store(false, std::memory_order_relaxed); g_last_apply_completed_us.store(now_steady_us(), std::memory_order_relaxed); set_active_session( *request.session, @@ -1257,6 +1292,11 @@ namespace display_helper_integration { } bool snapshot_current_display_state() { + if (restore_expected_with_live_helper()) { + BOOST_LOG(info) << "Display helper: skipping SNAPSHOT_CURRENT while an unconfirmed restore is pending."; + return false; + } + if (!ensure_helper_started()) { BOOST_LOG(info) << "Display helper unavailable; cannot snapshot current display state."; return false; diff --git a/src/platform/windows/display_helper_integration.h b/src/platform/windows/display_helper_integration.h index 29e960ae80..4cdb7fa2da 100644 --- a/src/platform/windows/display_helper_integration.h +++ b/src/platform/windows/display_helper_integration.h @@ -28,7 +28,7 @@ namespace display_helper_integration { // Launch the helper (if needed) and send REVERT. // Returns true if the helper accepted the command; false to allow fallback. - bool revert(bool prefer_golden_if_current_missing = false); + bool revert(bool prefer_golden_if_current_missing = true); // Attempt to cancel any pending restore/revert requests on a running helper. // Returns true if a DISARM command was sent successfully. diff --git a/src/platform/windows/display_helper_request_helpers.cpp b/src/platform/windows/display_helper_request_helpers.cpp index c0f7aa0800..e5c05f1115 100644 --- a/src/platform/windows/display_helper_request_helpers.cpp +++ b/src/platform/windows/display_helper_request_helpers.cpp @@ -8,6 +8,7 @@ #include "src/display_device.h" #include "src/globals.h" + #include "src/logging.h" #include "src/platform/common.h" #include "src/platform/windows/display_helper_coordinator.h" #include "src/platform/windows/frame_limiter_nvcp.h" @@ -78,6 +79,17 @@ namespace display_helper_integration::helpers { return it->cmd.empty() && it->playnite_id.empty(); } + bool output_name_targets_virtual(const std::string &output_name) { + return boost::iequals(output_name, VDISPLAY::SUDOVDA_VIRTUAL_DISPLAY_SELECTION) || + VDISPLAY::is_virtual_display_output(output_name); + } + + bool session_has_physical_output_override(const rtsp_stream::launch_session_t &session) { + return session.output_name_override && + !session.output_name_override->empty() && + !output_name_targets_virtual(*session.output_name_override); + } + rtsp_stream::launch_session_t make_display_request_session_snapshot_impl(const rtsp_stream::launch_session_t &session) { rtsp_stream::launch_session_t snapshot {}; snapshot.id = session.id; @@ -217,7 +229,8 @@ namespace display_helper_integration::helpers { effective_video_config_.output_name = *runtime_output_override; } } - if (effective_video_config_.dd.configuration_option == config::video_t::dd_t::config_option_e::disabled && + if (session_.virtual_display && + effective_video_config_.dd.configuration_option == config::video_t::dd_t::config_option_e::disabled && !effective_video_config_.output_name.empty()) { effective_video_config_.dd.configuration_option = config::video_t::dd_t::config_option_e::ensure_active; } @@ -459,7 +472,8 @@ namespace display_helper_integration::helpers { effective_video_config_.output_name = *runtime_output_override; } } - if (effective_video_config_.dd.configuration_option == config::video_t::dd_t::config_option_e::disabled && + if (session_.virtual_display && + effective_video_config_.dd.configuration_option == config::video_t::dd_t::config_option_e::disabled && !effective_video_config_.output_name.empty()) { effective_video_config_.dd.configuration_option = config::video_t::dd_t::config_option_e::ensure_active; } @@ -477,6 +491,16 @@ namespace display_helper_integration::helpers { } BOOST_LOG(info) << "Display helper topology: default device_id=" << default_device_id; + if (!session_.virtual_display) { + // Physical capture target: applySettings() derives topology from the device + // preparation option, so only pin a single-display topology when requested. + if (effective_video_config_.dd.configuration_option == config::video_t::dd_t::config_option_e::ensure_only_display && + topology.topology.empty() && !default_device_id.empty()) { + topology.topology = {{default_device_id}}; + } + return; + } + BOOST_LOG(debug) << "session_.virtual_display_layout_override has_value: " << session_.virtual_display_layout_override.has_value(); if (session_.virtual_display_layout_override) { BOOST_LOG(debug) << "session_.virtual_display_layout_override value: " << static_cast(*session_.virtual_display_layout_override); @@ -664,6 +688,15 @@ namespace display_helper_integration::helpers { } std::optional build_request_from_session(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + const auto effective_config_option = + session.dd_config_option_override.value_or(video_config.dd.configuration_option); + if (!session.virtual_display && + session_has_physical_output_override(session) && + effective_config_option == config::video_t::dd_t::config_option_e::disabled) { + BOOST_LOG(info) << "Display helper: physical output override with display configuration disabled; using capture target only."; + return std::nullopt; + } + DisplayApplyBuilder builder; SessionDisplayConfigurationHelper config_helper(video_config, session); if (!config_helper.configure(builder)) { diff --git a/src/platform/windows/display_wgc.cpp b/src/platform/windows/display_wgc.cpp index f731ebb4e1..8a61524ab6 100644 --- a/src/platform/windows/display_wgc.cpp +++ b/src/platform/windows/display_wgc.cpp @@ -15,6 +15,7 @@ // local includes #include "ipc/ipc_session.h" #include "ipc/misc_utils.h" +#include "src/config.h" #include "src/logging.h" #include "src/platform/windows/display.h" #include "src/platform/windows/display_vram.h" @@ -95,6 +96,19 @@ namespace platf::dxgi { return timeout; } + + bool forward_cached_wgcc_frame(capture_e status, const std::shared_ptr &cached_frame, std::shared_ptr &img_out) { + if (status != capture_e::timeout || config::video.capture != "wgcc" || !cached_frame) { + return false; + } + + const auto now = std::chrono::steady_clock::now(); + img_out = cached_frame; + img_out->frame_timestamp = now; + img_out->host_processing_timestamp = now; + img_out->capture_pacing_timestamp = now; + return true; + } } // namespace display_wgc_ipc_vram_t::display_wgc_ipc_vram_t() = default; @@ -116,9 +130,11 @@ namespace platf::dxgi { capture_format = DXGI_FORMAT_UNKNOWN; // Start with unknown format (prevents race condition/crash on first frame) + const bool advanced_color_capture = is_hdr(); + // Create session _ipc_session = std::make_unique(); - if (_ipc_session->init(config, display_name, device.get())) { + if (_ipc_session->init(config, display_name, device.get(), advanced_color_capture)) { return -1; } @@ -150,6 +166,10 @@ namespace platf::dxgi { auto capture_status = _ipc_session->wait_for_frame(timeout); if (capture_status != capture_e::ok) { + if (forward_cached_wgcc_frame(capture_status, last_cached_frame, img_out)) { + return capture_e::ok; + } + return capture_status; } @@ -268,6 +288,7 @@ namespace platf::dxgi { // use compositor timestamp jitter as the capture-loop sleep anchor. img->capture_pacing_timestamp = host_processing_timestamp; img_out = img; + last_cached_frame = img; return capture_e::ok; } @@ -285,6 +306,7 @@ namespace platf::dxgi { } gpu_tex.copy_to(&src); + _frame_locked = true; return capture_e::ok; } @@ -344,9 +366,11 @@ namespace platf::dxgi { // The display helper handles resolution changes before capture starts if needed. // We use the dimensions set by display_base_t::init() which reflect the actual monitor size. + const bool advanced_color_capture = is_hdr(); + // Create session _ipc_session = std::make_unique(); - if (_ipc_session->init(config, display_name, device.get())) { + if (_ipc_session->init(config, display_name, device.get(), advanced_color_capture)) { return -1; } @@ -379,6 +403,10 @@ namespace platf::dxgi { auto status = _ipc_session->acquire(timeout, gpu_tex, frame_qpc); if (status != capture_e::ok) { + if (forward_cached_wgcc_frame(status, last_cached_frame, img_out)) { + return capture_e::ok; + } + // For the default mode just return the capture status on timeouts. return status; } @@ -487,6 +515,7 @@ namespace platf::dxgi { img->frame_timestamp = frame_timestamp; img->host_processing_timestamp = host_processing_timestamp; img->capture_pacing_timestamp = host_processing_timestamp; + last_cached_frame = img_out; return capture_e::ok; } diff --git a/src/platform/windows/ipc/ipc_session.cpp b/src/platform/windows/ipc/ipc_session.cpp index 9fcce7529f..d6b4a8db77 100644 --- a/src/platform/windows/ipc/ipc_session.cpp +++ b/src/platform/windows/ipc/ipc_session.cpp @@ -34,6 +34,8 @@ namespace platf::dxgi { namespace { constexpr auto kRecentDesktopSwitchGrace = std::chrono::seconds(3); + constexpr auto kHelperHandleWaitTimeout = std::chrono::seconds(15); + constexpr auto kHelperHandleProgressInterval = std::chrono::seconds(3); constexpr std::int64_t kWgcMinUpdateInterval100ns = 10000; // 1 ms constexpr uint32_t kWgcLatencyInitialBufferSize = 2; std::atomic g_last_wgc_desktop_switch_us {0}; @@ -174,11 +176,12 @@ namespace platf::dxgi { } } - int ipc_session_t::init(const ::video::config_t &config, std::string_view display_name, ID3D11Device *device) { + int ipc_session_t::init(const ::video::config_t &config, std::string_view display_name, ID3D11Device *device, bool advanced_color_capture) { _process_helper = std::make_unique(); _config = config; _display_name = display_name; _device.copy_from(device); + _advanced_color_capture = advanced_color_capture; return 0; } @@ -258,16 +261,6 @@ namespace platf::dxgi { std::filesystem::path mainExeDir = std::filesystem::path(exePathBuffer).parent_path(); std::string pipe_guid = generate_guid(); - std::filesystem::path exe_path = mainExeDir / L"tools" / L"sunshine_wgc_capture.exe"; - std::wstring arguments = platf::from_utf8(pipe_guid); - - if (!_process_helper->start(exe_path.wstring(), arguments)) { - auto err = GetLastError(); - BOOST_LOG(error) << "Failed to start sunshine_wgc_capture executable at: " << exe_path.wstring() - << " (error code: " << err << ")"; - return; - } - auto on_message = [this](std::span msg) { if (msg.size() == 1) { handle_desktop_switch_message(msg); @@ -291,6 +284,16 @@ namespace platf::dxgi { return; } + std::filesystem::path exe_path = mainExeDir / L"tools" / L"sunshine_wgc_capture.exe"; + std::wstring arguments = platf::from_utf8(pipe_guid); + + if (!_process_helper->start(exe_path.wstring(), arguments)) { + auto err = GetLastError(); + BOOST_LOG(error) << "Failed to start sunshine_wgc_capture executable at: " << exe_path.wstring() + << " (error code: " << err << ")"; + return; + } + control_pipe->wait_for_client_connection(5000); if (!control_pipe->is_connected()) { @@ -302,6 +305,7 @@ namespace platf::dxgi { // Send config data to helper process config_data_t config_data = {}; config_data.dynamic_range = _config.dynamicRange; + config_data.advanced_color_capture = _advanced_color_capture ? 1u : 0u; config_data.log_level = config::sunshine.min_log_level; config_data.min_update_interval_100ns = wgc_min_update_interval_100ns(_config); config_data.target_fps = wgc_target_fps(_config); @@ -335,11 +339,14 @@ namespace platf::dxgi { return; } - constexpr auto handle_wait_timeout = std::chrono::seconds(3); - auto deadline = std::chrono::steady_clock::now() + handle_wait_timeout; + const auto handle_wait_start = std::chrono::steady_clock::now(); + auto deadline = handle_wait_start + kHelperHandleWaitTimeout; + auto next_progress_log = handle_wait_start + kHelperHandleProgressInterval; std::array control_buffer {}; bool handle_received = false; bool timed_out_waiting = false; + bool helper_exited = false; + DWORD helper_exit_code = 0; while (!handle_received) { auto now = std::chrono::steady_clock::now(); @@ -348,11 +355,31 @@ namespace platf::dxgi { break; } + if (HANDLE helper_process = _process_helper ? _process_helper->get_process_handle() : nullptr) { + const DWORD wait_result = WaitForSingleObject(helper_process, 0); + if (wait_result == WAIT_OBJECT_0) { + helper_exited = true; + GetExitCodeProcess(helper_process, &helper_exit_code); + break; + } + } + + if (now >= next_progress_log) { + const auto elapsed_ms = std::chrono::duration_cast(now - handle_wait_start).count(); + BOOST_LOG(warning) << "Still waiting for WGC helper shared handles after " << elapsed_ms + << "ms; helper startup may be delayed by system load."; + next_progress_log = now + kHelperHandleProgressInterval; + } + const auto remaining = std::chrono::duration_cast(deadline - now); const int wait_ms = std::max(1, static_cast(remaining.count())); size_t bytes_read = 0; - auto result = control_pipe->receive(std::span(control_buffer.data(), control_buffer.size()), bytes_read, wait_ms); + auto result = control_pipe->receive( + std::span(control_buffer.data(), control_buffer.size()), + bytes_read, + std::min(wait_ms, 250) + ); if (result == PipeResult::Success) { if (bytes_read == sizeof(shared_handle_data_t)) { @@ -380,10 +407,14 @@ namespace platf::dxgi { } if (!handle_received) { + if (helper_exited) { + BOOST_LOG(error) << "WGC helper exited before sending shared handle data (exit_code=" << helper_exit_code << ')'; + } if (timed_out_waiting) { - BOOST_LOG(error) << "Timed out waiting for handle data from helper process (3s)"; + BOOST_LOG(error) << "Timed out waiting for handle data from helper process (" + << std::chrono::duration_cast(kHelperHandleWaitTimeout).count() << "s)"; } - BOOST_LOG(error) << "Failed to receive handle data from helper process! Helper is likely deadlocked!"; + BOOST_LOG(error) << "Failed to receive handle data from WGC helper process."; _process_helper->terminate(); return; } @@ -573,9 +604,9 @@ namespace platf::dxgi { return capture_e::reinit; } - // The helper signals the frame-ready event only after publishing metadata - // and releasing this keyed mutex. After acquiring the mutex here, the - // metadata snapshot and shared texture contents refer to the latest frame. + // The helper publishes metadata while holding the keyed mutex, then signals + // the frame-ready event after releasing it. After acquiring the mutex here, + // the metadata snapshot and shared texture contents refer to the latest frame. frame_metadata_snapshot_t snapshot; if (!read_frame_metadata_snapshot(_frame_metadata, snapshot)) { (void) _keyed_mutex->ReleaseSync(0); @@ -745,7 +776,10 @@ namespace platf::dxgi { DWORD exit_code = 0; _process_helper->terminate(); // best effort - _process_helper->wait(exit_code); + if (!_process_helper->wait_for(exit_code, 3000)) { + BOOST_LOG(warning) << "Timed out waiting for WGC helper process teardown; resetting process owner."; + _process_helper = std::make_unique(); + } _last_helper_stop = std::chrono::steady_clock::now(); } diff --git a/src/platform/windows/ipc/ipc_session.h b/src/platform/windows/ipc/ipc_session.h index 41a5a17472..eec95ae787 100644 --- a/src/platform/windows/ipc/ipc_session.h +++ b/src/platform/windows/ipc/ipc_session.h @@ -49,9 +49,10 @@ namespace platf::dxgi { * @param config Video configuration. * @param display_name Display name for the session. * @param device D3D11 device for shared texture operations (not owned). + * @param advanced_color_capture True when the target display is already HDR/Advanced Color. * @return `0` on success; non-zero otherwise. */ - int init(const ::video::config_t &config, std::string_view display_name, ID3D11Device *device); + int init(const ::video::config_t &config, std::string_view display_name, ID3D11Device *device, bool advanced_color_capture); /** * @brief Start the helper process and set up IPC connection if not already initialized. @@ -191,6 +192,7 @@ namespace platf::dxgi { UINT _height = 0; ///< Shared texture height. ::video::config_t _config; ///< Cached video config. std::string _display_name; ///< Display name copy. + bool _advanced_color_capture = false; ///< True when target display is already Advanced Color/HDR. std::chrono::steady_clock::time_point _last_helper_stop {}; ///< Last time we tore down the helper. }; diff --git a/src/platform/windows/ipc/pipes.cpp b/src/platform/windows/ipc/pipes.cpp index 6656e0dc34..160d99c798 100644 --- a/src/platform/windows/ipc/pipes.cpp +++ b/src/platform/windows/ipc/pipes.cpp @@ -1055,8 +1055,10 @@ namespace platf::dxgi { DWORD err = GetLastError(); if (err == ERROR_IO_PENDING) { return handle_pending_receive_operation(ctx, timeout_ms, dst, bytesRead); - } else if (err == ERROR_BROKEN_PIPE) { - BOOST_LOG(warning) << "Pipe broken during ReadFile (ERROR_BROKEN_PIPE)"; + } else if (err == ERROR_BROKEN_PIPE || + err == ERROR_PIPE_NOT_CONNECTED || + err == ERROR_NO_DATA) { + BOOST_LOG(warning) << "Pipe disconnected during ReadFile (error=" << err << ")"; // Reflect disconnected state immediately so higher layers don't think we're still connected _connected.store(false, std::memory_order_release); return PipeResult::BrokenPipe; @@ -1077,7 +1079,9 @@ namespace platf::dxgi { return Success; } else { DWORD overlappedErr = GetLastError(); - if (overlappedErr == ERROR_BROKEN_PIPE) { + if (overlappedErr == ERROR_BROKEN_PIPE || + overlappedErr == ERROR_PIPE_NOT_CONNECTED || + overlappedErr == ERROR_NO_DATA) { BOOST_LOG(warning) << "IPC pipe connection to helper process was lost."; _connected.store(false, std::memory_order_release); return BrokenPipe; diff --git a/src/platform/windows/ipc/pipes.h b/src/platform/windows/ipc/pipes.h index fff843385a..a67d090758 100644 --- a/src/platform/windows/ipc/pipes.h +++ b/src/platform/windows/ipc/pipes.h @@ -62,6 +62,7 @@ namespace platf::dxgi { /** * @brief Structure for configuration data shared via IPC. * @param dynamic_range Dynamic range setting. + * @param advanced_color_capture Whether the target output is already in HDR/Advanced Color. * @param log_level Logging level. * @param display_name Display name (wide string, max 32 chars). * @param adapter_luid LUID of the DXGI adapter to use for D3D11 device creation. @@ -80,6 +81,7 @@ namespace platf::dxgi { struct config_data_t { int dynamic_range; + uint32_t advanced_color_capture; int log_level; wchar_t display_name[32]; LUID adapter_luid; diff --git a/src/platform/windows/ipc/process_handler.cpp b/src/platform/windows/ipc/process_handler.cpp index 0c0a549f7e..2e5da10912 100644 --- a/src/platform/windows/ipc/process_handler.cpp +++ b/src/platform/windows/ipc/process_handler.cpp @@ -293,10 +293,14 @@ bool ProcessHandler::start( } bool ProcessHandler::wait(DWORD &exit_code) { + return wait_for(exit_code, INFINITE); +} + +bool ProcessHandler::wait_for(DWORD &exit_code, DWORD timeout_ms) { if (!running_ || pi_.hProcess == nullptr) { return false; } - DWORD wait_result = WaitForSingleObject(pi_.hProcess, INFINITE); + DWORD wait_result = WaitForSingleObject(pi_.hProcess, timeout_ms); if (wait_result != WAIT_OBJECT_0) { return false; } @@ -316,7 +320,9 @@ bool ProcessHandler::wait(DWORD &exit_code) { void ProcessHandler::terminate() { if (running_ && pi_.hProcess) { - TerminateProcess(pi_.hProcess, 1); + if (!TerminateProcess(pi_.hProcess, 1)) { + BOOST_LOG(warning) << "Failed to terminate helper process, error=" << GetLastError(); + } // Do not clear running_/handles here: callers may need to wait() for full teardown // to avoid overlapping helper instances and destabilizing the driver stack. } diff --git a/src/platform/windows/ipc/process_handler.h b/src/platform/windows/ipc/process_handler.h index 5a4a399fef..bb4968fb5a 100644 --- a/src/platform/windows/ipc/process_handler.h +++ b/src/platform/windows/ipc/process_handler.h @@ -56,6 +56,14 @@ class ProcessHandler { */ bool wait(DWORD &exit_code); + /** + * @brief Wait up to timeout_ms for the process to exit and obtain its exit code. + * @param exit_code Receives process exit code on success. + * @param timeout_ms Maximum wait time in milliseconds. + * @return `true` if the process exited before the timeout; `false` otherwise. + */ + bool wait_for(DWORD &exit_code, DWORD timeout_ms); + /** * @brief Terminate the process if still running (best-effort). */ diff --git a/src/platform/windows/misc.cpp b/src/platform/windows/misc.cpp index d79ba63fb1..7e9a0af48e 100644 --- a/src/platform/windows/misc.cpp +++ b/src/platform/windows/misc.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include #include @@ -112,6 +114,8 @@ namespace platf { bool enabled_mouse_keys = false; MOUSEKEYS previous_mouse_keys_state; + std::mutex screen_saver_state_mutex; + std::optional previous_screen_saver_active; HANDLE qos_handle = nullptr; @@ -369,7 +373,7 @@ namespace platf { if (elevated && (elevationType == TokenElevationTypeDefault && !IsUserAdmin(userToken))) { // We don't have to strip the token or do anything here, but let's give the user a warning so they're aware what is happening. BOOST_LOG(warning) << "This command requires elevation and the current user account logged in does not have administrator rights. " - << "For security reasons Sunshine will retain the same access level as the current user and will not elevate it."; + << "For security reasons Nimbus will retain the same access level as the current user and will not elevate it."; } // User has a limited token, this means they have UAC enabled and is an Administrator @@ -1267,7 +1271,48 @@ namespace platf { } } + void capture_screen_saver_active_state() { + std::lock_guard lock(screen_saver_state_mutex); + if (previous_screen_saver_active) { + return; + } + + BOOL active = FALSE; + if (!SystemParametersInfoW(SPI_GETSCREENSAVEACTIVE, 0, &active, 0)) { + const auto winerr = GetLastError(); + BOOST_LOG(warning) << "Unable to get Windows screen saver active state: "sv << winerr; + return; + } + + previous_screen_saver_active = active ? TRUE : FALSE; + BOOST_LOG(debug) << "Windows screen saver active state saved: "sv << (active ? "enabled"sv : "disabled"sv); + } + + void restore_screen_saver_active_state() { + std::optional desired_state; + { + std::lock_guard lock(screen_saver_state_mutex); + desired_state = previous_screen_saver_active; + previous_screen_saver_active.reset(); + } + + if (!desired_state) { + return; + } + + const BOOL desired_active = *desired_state ? TRUE : FALSE; + if (!SystemParametersInfoW(SPI_SETSCREENSAVEACTIVE, desired_active, nullptr, SPIF_SENDCHANGE)) { + const auto winerr = GetLastError(); + BOOST_LOG(warning) << "Unable to restore Windows screen saver active state: "sv << winerr; + return; + } + + BOOST_LOG(info) << "Windows screen saver active state restored to "sv << (desired_active ? "enabled"sv : "disabled"sv); + } + void streaming_will_start() { + capture_screen_saver_active_state(); + static std::once_flag load_wlanapi_once_flag; std::call_once(load_wlanapi_once_flag, []() { // wlanapi.dll is not installed by default on Windows Server, so we load it dynamically @@ -1358,7 +1403,7 @@ namespace platf { // If there is no mouse connected, enable Mouse Keys to force the cursor to appear if (!GetSystemMetrics(SM_MOUSEPRESENT)) { - BOOST_LOG(info) << "A mouse was not detected. Sunshine will enable Mouse Keys while streaming to force the mouse cursor to appear."; + BOOST_LOG(info) << "A mouse was not detected. Nimbus will enable Mouse Keys while streaming to force the mouse cursor to appear."; // Get the current state of Mouse Keys so we can restore it when streaming is over previous_mouse_keys_state.cbSize = sizeof(previous_mouse_keys_state); @@ -1424,6 +1469,8 @@ namespace platf { BOOST_LOG(warning) << "Unable to restore original state of Mouse Keys: "sv << winerr; } } + + restore_screen_saver_active_state(); } void restart_on_exit() { @@ -1433,14 +1480,14 @@ namespace platf { WCHAR executable[MAX_PATH]; if (GetModuleFileNameW(nullptr, executable, ARRAYSIZE(executable)) == 0) { auto winerr = GetLastError(); - BOOST_LOG(fatal) << "Failed to get Sunshine path: "sv << winerr; + BOOST_LOG(fatal) << "Failed to get Nimbus path: "sv << winerr; return; } PROCESS_INFORMATION process_info; if (!CreateProcessW(executable, GetCommandLineW(), nullptr, nullptr, false, CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (LPSTARTUPINFOW) &startup_info, &process_info)) { auto winerr = GetLastError(); - BOOST_LOG(fatal) << "Unable to restart Sunshine: "sv << winerr; + BOOST_LOG(fatal) << "Unable to restart Nimbus: "sv << winerr; return; } @@ -1992,7 +2039,7 @@ namespace platf { WCHAR hostname[256]; if (GetHostNameW(hostname, ARRAYSIZE(hostname)) == SOCKET_ERROR) { BOOST_LOG(error) << "GetHostNameW() failed: "sv << WSAGetLastError(); - return "Sunshine"s; + return "Nimbus"s; } return to_utf8(std::wstring_view {hostname}); } diff --git a/src/platform/windows/publish.cpp b/src/platform/windows/publish.cpp index 600253de69..a3eba5c941 100644 --- a/src/platform/windows/publish.cpp +++ b/src/platform/windows/publish.cpp @@ -174,21 +174,21 @@ namespace platf::publish { mdns_registration_t(): existing_instance(nullptr) { if (service(true, existing_instance)) { - BOOST_LOG(error) << "Unable to register Apollo mDNS service"sv; + BOOST_LOG(error) << "Unable to register Nimbus mDNS service"sv; return; } - BOOST_LOG(info) << "Registered Apollo mDNS service"sv; + BOOST_LOG(info) << "Registered Nimbus mDNS service"sv; } ~mdns_registration_t() override { if (existing_instance) { if (service(false, existing_instance)) { - BOOST_LOG(error) << "Unable to unregister Apollo mDNS service"sv; + BOOST_LOG(error) << "Unable to unregister Nimbus mDNS service"sv; return; } - BOOST_LOG(info) << "Unregistered Apollo mDNS service"sv; + BOOST_LOG(info) << "Unregistered Nimbus mDNS service"sv; } } diff --git a/src/platform/windows/virtual_display.cpp b/src/platform/windows/virtual_display.cpp index 3d06d47230..60eec860a2 100644 --- a/src/platform/windows/virtual_display.cpp +++ b/src/platform/windows/virtual_display.cpp @@ -1985,15 +1985,13 @@ namespace VDISPLAY { return std::nullopt; } - try { - pt::ptree tree; - pt::read_json(path.string(), tree); + pt::ptree tree; + if (statefile::read_json_with_recovery(path.string(), tree)) { if (auto guid_str = tree.get_optional("root.virtual_display_guid")) { if (auto parsed = parse_uuid_string(*guid_str)) { return parsed; } } - } catch (...) { } return std::nullopt; } @@ -2033,11 +2031,7 @@ namespace VDISPLAY { std::lock_guard lock(statefile::state_mutex()); const fs::path path(path_str); pt::ptree tree; - try { - if (fs::exists(path)) { - pt::read_json(path.string(), tree); - } - } catch (...) { + if (fs::exists(path) && !statefile::read_json_with_recovery(path.string(), tree)) { tree = pt::ptree {}; } diff --git a/src/platform/windows/virtual_display_cleanup.cpp b/src/platform/windows/virtual_display_cleanup.cpp index cb6ca4eb77..17fe1fd37b 100644 --- a/src/platform/windows/virtual_display_cleanup.cpp +++ b/src/platform/windows/virtual_display_cleanup.cpp @@ -88,6 +88,7 @@ namespace platf::virtual_display_cleanup { << ", enforce_db_restore=" << (enforce_db_restore ? "true" : "false") << ", revert_order=" << (revert_order == revert_order_t::restore_before_remove ? "restore_before_remove" : "remove_before_restore") + << ", prefer_golden_if_current_missing=" << (prefer_golden_if_current_missing ? "true" : "false") << ")"; const bool had_active_virtual_display = has_active_virtual_display(); diff --git a/src/platform/windows/virtual_display_cleanup.h b/src/platform/windows/virtual_display_cleanup.h index caa2c993be..6492b46c34 100644 --- a/src/platform/windows/virtual_display_cleanup.h +++ b/src/platform/windows/virtual_display_cleanup.h @@ -20,7 +20,7 @@ namespace platf::virtual_display_cleanup { std::string_view reason, bool enforce_db_restore = true, revert_order_t revert_order = revert_order_t::remove_before_restore, - bool prefer_golden_if_current_missing = false + bool prefer_golden_if_current_missing = true ); } // namespace platf::virtual_display_cleanup diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 68814eaa2d..867fd2de66 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1247,10 +1247,14 @@ namespace rtsp_stream { // Prefer 10-bit SDR encoding when enabled globally or overridden per-client. const auto client_prefer_10bit_sdr_override = nvhttp::get_client_prefer_10bit_sdr_override(session.client_uuid); const bool prefer_10bit_sdr = client_prefer_10bit_sdr_override.value_or(config::video.prefer_10bit_sdr); - if (prefer_10bit_sdr && !session.enable_hdr && config.monitor.dynamicRange == 0) { - const bool hevc_main10 = config.monitor.videoFormat == 1 && video::active_hevc_mode >= 3; - const bool av1_main10 = config.monitor.videoFormat == 2 && video::active_av1_mode >= 3; - if (hevc_main10 || av1_main10) { + const bool hevc_main10 = config.monitor.videoFormat == 1 && video::active_hevc_mode >= 3; + const bool av1_main10 = config.monitor.videoFormat == 2 && video::active_av1_mode >= 3; + const bool supports_10bit_dynamic_range = hevc_main10 || av1_main10; + if (config.monitor.dynamicRange == 0) { + if (session.enable_hdr && supports_10bit_dynamic_range) { + BOOST_LOG(info) << "RTSP ANNOUNCE requested SDR while launch HDR is enabled; using HDR 10-bit encode"; + config.monitor.dynamicRange = 1; + } else if (prefer_10bit_sdr && !session.enable_hdr && supports_10bit_dynamic_range) { BOOST_LOG(info) << "Preferring 10-bit SDR encode for compatible client request"; config.monitor.dynamicRange = 1; config.monitor.prefer_sdr_10bit = true; diff --git a/src/state_storage.cpp b/src/state_storage.cpp index 2d5fab267d..fdbd3a10dd 100644 --- a/src/state_storage.cpp +++ b/src/state_storage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,66 @@ namespace statefile { std::once_flag migration_once; + fs::path backup_path_for(const fs::path &path) { + fs::path backup = path; + backup += ".bak"; + return backup; + } + + fs::path corrupt_path_for(const fs::path &path) { + const auto seconds = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count(); + fs::path corrupt = path; + corrupt += ".corrupt." + std::to_string(seconds); + return corrupt; + } + + void preserve_corrupt_file(const fs::path &path) { + std::error_code ec; + if (!fs::exists(path, ec) || ec) { + return; + } + + auto corrupt = corrupt_path_for(path); + for (int i = 1; fs::exists(corrupt, ec) && !ec && i < 100; ++i) { + corrupt = corrupt_path_for(path); + corrupt += "." + std::to_string(i); + } + + fs::copy_file(path, corrupt, fs::copy_options::skip_existing, ec); + if (ec) { + BOOST_LOG(warning) << "statefile: failed to preserve corrupt JSON "sv << path.string() + << " as "sv << corrupt.string() << ": "sv << ec.message(); + } else { + BOOST_LOG(warning) << "statefile: preserved corrupt JSON "sv << path.string() + << " as "sv << corrupt.string(); + } + } + + void refresh_json_backup_if_parseable(const fs::path &path) { + std::error_code ec; + if (!fs::exists(path, ec) || ec) { + return; + } + + pt::ptree existing; + try { + pt::read_json(path.string(), existing); + } catch (const std::exception &e) { + BOOST_LOG(warning) << "statefile: leaving previous backup in place because current JSON is not parseable: " + << path.string() << ": " << e.what(); + return; + } + + const auto backup = backup_path_for(path); + fs::copy_file(path, backup, fs::copy_options::overwrite_existing, ec); + if (ec) { + BOOST_LOG(warning) << "statefile: failed to refresh backup "sv << backup.string() + << ": "sv << ec.message(); + } + } + pt::ptree &ensure_root(pt::ptree &tree) { auto it = tree.find("root"); if (it == tree.not_found()) { @@ -45,13 +106,7 @@ namespace statefile { if (!fs::exists(path)) { return false; } - try { - pt::read_json(path.string(), out); - return true; - } catch (const std::exception &e) { - BOOST_LOG(warning) << "statefile: failed to read "sv << path.string() << ": "sv << e.what(); - return false; - } + return read_json_with_recovery(path.string(), out); } void write_tree(const fs::path &path, const pt::ptree &tree) { @@ -234,6 +289,58 @@ namespace statefile { if (file_handler::write_file(path.c_str(), out.str()) != 0) { throw std::runtime_error("atomic JSON write failed"); } + refresh_json_backup_if_parseable(fs::path(path)); + } + + bool read_json_with_recovery(const std::string &path, pt::ptree &tree) { + const fs::path primary(path); + if (primary.empty()) { + tree = pt::ptree {}; + return false; + } + + std::error_code ec; + if (!fs::exists(primary, ec) || ec) { + tree = pt::ptree {}; + return false; + } + + try { + pt::read_json(primary.string(), tree); + return true; + } catch (const std::exception &primary_error) { + BOOST_LOG(warning) << "statefile: failed to read "sv << primary.string() + << ", attempting backup recovery: "sv << primary_error.what(); + } + + preserve_corrupt_file(primary); + + const auto backup = backup_path_for(primary); + if (!fs::exists(backup, ec) || ec) { + tree = pt::ptree {}; + BOOST_LOG(warning) << "statefile: no parseable backup available for "sv << primary.string(); + return false; + } + + pt::ptree recovered; + try { + pt::read_json(backup.string(), recovered); + } catch (const std::exception &backup_error) { + tree = pt::ptree {}; + BOOST_LOG(warning) << "statefile: backup recovery failed for "sv << primary.string() + << " from "sv << backup.string() << ": "sv << backup_error.what(); + return false; + } + + tree = recovered; + try { + write_json_atomic(primary.string(), recovered); + BOOST_LOG(info) << "statefile: restored "sv << primary.string() << " from backup "sv << backup.string(); + } catch (const std::exception &restore_error) { + BOOST_LOG(warning) << "statefile: loaded backup for "sv << primary.string() + << " but failed to restore primary: "sv << restore_error.what(); + } + return true; } const std::string &sunshine_state_path() { @@ -405,7 +512,7 @@ namespace statefile { migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { - BOOST_LOG(warning) << "statefile: cannot save snapshot exclusions - vibeshine state path is empty"; + BOOST_LOG(warning) << "statefile: cannot save snapshot exclusions - Nimbus state file path is empty"; return; } @@ -434,7 +541,7 @@ namespace statefile { BOOST_LOG(error) << "statefile: failed to write "sv << path.string() << ": "sv << e.what(); return; } - BOOST_LOG(info) << "statefile: persisted " << devices.size() << " snapshot exclusion device(s) to vibeshine state"; + BOOST_LOG(info) << "statefile: persisted " << devices.size() << " snapshot exclusion device(s) to Nimbus state file"; } std::vector load_snapshot_exclude_devices() { diff --git a/src/state_storage.h b/src/state_storage.h index 3c17f864c2..5ba75a6b29 100644 --- a/src/state_storage.h +++ b/src/state_storage.h @@ -24,6 +24,16 @@ namespace statefile { */ void write_json_atomic(const std::string &path, const boost::property_tree::ptree &tree); + /** + * @brief Read JSON with best-effort recovery from a sibling `.bak` file. + * + * If the primary file is malformed, the malformed copy is preserved as + * `.corrupt.` and a parseable `.bak` is restored in memory and on disk. + * + * @return `true` when either the primary file or backup was loaded; `false` otherwise. + */ + bool read_json_with_recovery(const std::string &path, boost::property_tree::ptree &tree); + /** * @brief Best-effort repair for Windows config ACL inheritance. * diff --git a/src/system_tray.cpp b/src/system_tray.cpp index 8561df45de..35873be98c 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -12,20 +12,20 @@ #include #include #include - #define TRAY_ICON WEB_DIR "images/apollo.ico" - #define TRAY_ICON_PLAYING WEB_DIR "images/apollo-playing.ico" - #define TRAY_ICON_PAUSING WEB_DIR "images/apollo-pausing.ico" - #define TRAY_ICON_LOCKED WEB_DIR "images/apollo-locked.ico" + #define TRAY_ICON WEB_DIR "images/nimbus.ico" + #define TRAY_ICON_PLAYING WEB_DIR "images/nimbus-playing.ico" + #define TRAY_ICON_PAUSING WEB_DIR "images/nimbus-pausing.ico" + #define TRAY_ICON_LOCKED WEB_DIR "images/nimbus-locked.ico" #elif defined(__linux__) || defined(linux) || defined(__linux) #define TRAY_ICON SUNSHINE_TRAY_PREFIX "-tray" #define TRAY_ICON_PLAYING SUNSHINE_TRAY_PREFIX "-playing" #define TRAY_ICON_PAUSING SUNSHINE_TRAY_PREFIX "-pausing" #define TRAY_ICON_LOCKED SUNSHINE_TRAY_PREFIX "-locked" #elif defined(__APPLE__) || defined(__MACH__) - #define TRAY_ICON WEB_DIR "images/logo-apollo-16.png" - #define TRAY_ICON_PLAYING WEB_DIR "images/apollo-playing-16.png" - #define TRAY_ICON_PAUSING WEB_DIR "images/apollo-pausing-16.png" - #define TRAY_ICON_LOCKED WEB_DIR "images/apollo-locked-16.png" + #define TRAY_ICON WEB_DIR "images/logo-nimbus-16.png" + #define TRAY_ICON_PLAYING WEB_DIR "images/nimbus-playing-16.png" + #define TRAY_ICON_PAUSING WEB_DIR "images/nimbus-pausing-16.png" + #define TRAY_ICON_LOCKED WEB_DIR "images/nimbus-locked-16.png" #include #endif @@ -33,6 +33,7 @@ // standard includes #include + #include #include #include #include @@ -114,6 +115,13 @@ namespace system_tray { platf::restart(); } +#ifdef _WIN32 + void tray_restore_audio_cb([[maybe_unused]] struct tray_menu *item) { + BOOST_LOG(info) << "Restoring default audio output from system tray"sv; + platf::restore_default_audio_device(); + } +#endif + void tray_quit_cb([[maybe_unused]] struct tray_menu *item) { BOOST_LOG(info) << "Quitting from system tray"sv; @@ -138,7 +146,7 @@ namespace system_tray { .menu = (struct tray_menu[]) { // todo - use boost/locale to translate menu strings - {.text = "Open Apollo", .cb = tray_open_ui_cb}, + {.text = "Open Nimbus", .cb = tray_open_ui_cb}, {.text = "-"}, // { .text = "-" }, // { .text = "Donate", @@ -153,9 +161,12 @@ namespace system_tray { {.text = TRAY_MSG_NO_APP_RUNNING, .cb = tray_force_stop_cb}, {.text = "Check for Update", .cb = [](tray_menu *) { BOOST_LOG(info) << "Manual update check requested from tray"sv; - update::trigger_check(true); + update::trigger_check(true, true); }}, +#ifdef _WIN32 + {.text = "Restore Audio Output", .cb = tray_restore_audio_cb}, +#endif {.text = "Restart", .cb = tray_restart_cb}, {.text = "Quit", .cb = tray_quit_cb}, {.text = nullptr} @@ -535,7 +546,11 @@ namespace system_tray { tray.notification_icon = TRAY_ICON_LOCKED; tray.tooltip = PROJECT_NAME; tray.notification_cb = []() { - launch_ui("/clients"); + const auto focus_id = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count(); + launch_ui("/clients?sec=pair&focus=" + std::to_string(focus_id)); }; tray_update(&tray); }); @@ -643,10 +658,10 @@ namespace system_tray { } #ifdef _WIN32 - std::string tmp_str = "Open Apollo (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; + std::string tmp_str = "Open Nimbus (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; static const std::string title_str = utf8ToAcp(tmp_str); #else - static const std::string title_str = "Open Apollo (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; + static const std::string title_str = "Open Nimbus (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; #endif tray.menu[0].text = title_str.c_str(); diff --git a/src/update.cpp b/src/update.cpp index 2085f5192b..90fedc0eff 100644 --- a/src/update.cpp +++ b/src/update.cpp @@ -50,7 +50,7 @@ namespace update { struct curl_slist *headers = nullptr; headers = curl_slist_append(headers, "Accept: application/vnd.github+json"); headers = curl_slist_append(headers, "X-GitHub-Api-Version: 2022-11-28"); - headers = curl_slist_append(headers, "User-Agent: Sunshine-Updater/1.0"); + headers = curl_slist_append(headers, "User-Agent: Nimbus-Updater/1.0"); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); bool tls_configured = http::configure_curl_tls(curl); @@ -113,7 +113,16 @@ namespace update { // We intentionally allow repeated notifications; do not persist last_notified_version } - static void perform_check() { + static void notify_manual_update_check(const std::string &title, const std::string &body) { +#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 + system_tray::tray_notify(title.c_str(), body.c_str()); +#else + (void) title; + (void) body; +#endif + } + + static void perform_check(bool notify_manual_result) { state.check_in_progress = true; auto fg = util::fail_guard([]() { state.check_in_progress = false; @@ -125,7 +134,9 @@ namespace update { BOOST_LOG(info) << "Update check: querying GitHub releases from repo "sv << SUNSHINE_REPO_OWNER << '/' << SUNSHINE_REPO_NAME; std::string releases_json; + bool release_query_ok = false; if (download_github_release_data(SUNSHINE_REPO_OWNER, SUNSHINE_REPO_NAME, releases_json)) { + release_query_ok = true; auto j = nlohmann::json::parse(releases_json); // Reset release info state.latest_release = release_info_t {}; @@ -193,6 +204,9 @@ namespace update { if (!state.latest_prerelease.version.empty()) { BOOST_LOG(info) << "Update check: latest prerelease tag="sv << state.latest_prerelease.version; } + } else { + state.latest_release = release_info_t {}; + state.latest_prerelease = release_info_t {}; } state.last_check_time = std::chrono::steady_clock::now(); @@ -220,16 +234,41 @@ namespace update { BOOST_LOG(info) << "Update check (tag-based): up-to-date. installed="sv << installed_version_tag << ", stable="sv << latest_stable_tag << ", prerelease="sv << latest_pre_tag; + if (notify_manual_result) { + if (!release_query_ok) { + notify_manual_update_check( + "Nimbus update check failed", + "Could not reach the Nimbus releases feed. See the Nimbus logs for details."); + } else if (latest_stable_tag.empty() && latest_pre_tag.empty()) { + notify_manual_update_check( + "Nimbus update check complete", + "No Nimbus releases are published yet."); + } else { + notify_manual_update_check( + "Nimbus update check complete", + "No newer Nimbus release was found."); + } + } } } catch (std::exception &e) { BOOST_LOG(warning) << "Update check failed: "sv << e.what(); + if (notify_manual_result) { + notify_manual_update_check( + "Nimbus update check failed", + "Could not check Nimbus releases. See the Nimbus logs for details."); + } } } - void trigger_check(bool force) { + void trigger_check(bool force, bool notify_manual_result) { const bool in_progress = state.check_in_progress.load(); if (in_progress) { BOOST_LOG(info) << "Update check trigger skipped: another check is in progress (force="sv << (force ? "true"sv : "false"sv) << ')'; + if (notify_manual_result) { + notify_manual_update_check( + "Nimbus update check", + "An update check is already running."); + } return; } if (!force && config::sunshine.update_check_interval_seconds == 0) { @@ -245,8 +284,13 @@ namespace update { } } BOOST_LOG(info) << "Update check trigger accepted (force="sv << (force ? "true"sv : "false"sv) << ')'; - std::thread([]() { - perform_check(); + if (notify_manual_result) { + notify_manual_update_check( + "Nimbus update check", + "Checking for Nimbus releases..."); + } + std::thread([notify_manual_result]() { + perform_check(notify_manual_result); }).detach(); } diff --git a/src/update.h b/src/update.h index 7e33075edf..f6eb15891b 100644 --- a/src/update.h +++ b/src/update.h @@ -47,7 +47,7 @@ namespace update { * Initiates a check for updates if not already running. If force is true, bypasses interval throttling. * @param force If true, forces the check regardless of throttling. */ - void trigger_check(bool force); + void trigger_check(bool force, bool notify_manual_result = false); /** * @brief Handle stream start event for update notification. diff --git a/src/video.cpp b/src/video.cpp index 674fe0e50d..9d75fbef54 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -75,6 +75,13 @@ namespace video { return false; } + if (auto runtime_output_name = config::runtime_output_name_override()) { + if (!runtime_output_name->empty()) { + return boost::iequals(*runtime_output_name, VDISPLAY::SUDOVDA_VIRTUAL_DISPLAY_SELECTION) || + VDISPLAY::is_virtual_display_output(*runtime_output_name); + } + } + if (!VDISPLAY::isSudaVDADriverInstalled()) { return false; } @@ -1379,6 +1386,8 @@ namespace video { */ void refresh_displays(platf::mem_type_e dev_type, std::vector &display_names, int ¤t_display_index, std::string &preferred_display_name) { // It is possible that the output name may be empty even if it wasn't before (device disconnected) or vice-versa + const auto runtime_output_override = config::runtime_output_name_override(); + const bool has_runtime_output_override = runtime_output_override && !runtime_output_override->empty(); const auto output_name = display_device::map_output_name(config::get_active_output_name()); std::string current_display_name; auto names_match = [](const std::string &lhs, const std::string &rhs) { @@ -1425,6 +1434,19 @@ namespace video { // We now have a new display name list, so reset the index back to 0 current_display_index = 0; + if (has_runtime_output_override && !output_name.empty()) { + for (int x = 0; x < display_names.size(); ++x) { + if (names_match(display_names[x], output_name)) { + current_display_index = x; + return; + } + } + + BOOST_LOG(warning) << "Runtime display override [" << *runtime_output_override + << "] mapped to [" << output_name + << "] but was not found in the capture display list"; + } + if (current_display_name.empty()) { current_display_name = display_device::map_output_name(config::video.output_name); } diff --git a/src/webrtc_stream.cpp b/src/webrtc_stream.cpp index 39b56a18d2..add0f79387 100644 --- a/src/webrtc_stream.cpp +++ b/src/webrtc_stream.cpp @@ -163,6 +163,10 @@ namespace webrtc_stream { constexpr std::size_t kVideoInflightFramesMax = 6; constexpr std::size_t kVideoInflightKeyframeExtra = 2; constexpr auto kWebrtcIdleGracePeriod = std::chrono::minutes {5}; + // While a virtual display is active the physical monitors may be disabled + // by exclusive layout. Keep the post-disconnect grace period short so the + // display cleanup runs promptly instead of leaving monitors dark for minutes. + constexpr auto kWebrtcIdleGracePeriodVirtualDisplay = std::chrono::seconds {15}; struct SharedEncodedPayloadReleaseContext { std::shared_ptr> payload; @@ -2376,7 +2380,7 @@ namespace webrtc_stream { return key; } - void stop_webrtc_capture_locked(bool allow_platform_teardown) { + void stop_webrtc_capture_locked(bool allow_platform_teardown, bool final_teardown) { if (webrtc_capture.mail) { auto shutdown_event = webrtc_capture.mail->event(mail::shutdown); shutdown_event->raise(true); @@ -2406,7 +2410,7 @@ namespace webrtc_stream { #ifdef _WIN32 if (allow_platform_teardown) { const bool is_paused = proc::proc.running() > 0; - const bool revert_enabled = config::video.dd.config_revert_on_disconnect; + const bool revert_enabled = config::video.dd.config_revert_on_disconnect || (final_teardown && !is_paused); const int paused_timeout_secs = std::max(0, config::video.dd.paused_virtual_display_timeout_secs); const bool skip_teardown_due_to_pause = is_paused && !revert_enabled; if (!skip_teardown_due_to_pause) { @@ -2487,7 +2491,7 @@ namespace webrtc_stream { } if (webrtc_capture.active.load(std::memory_order_acquire)) { - stop_webrtc_capture_locked(!rtsp_active); + stop_webrtc_capture_locked(!rtsp_active, false); } auto launch_session = build_launch_session(options, effective_app_id, audio_channels); @@ -2591,7 +2595,7 @@ namespace webrtc_stream { if (rtsp_sessions_active.load(std::memory_order_relaxed)) { return; } - stop_webrtc_capture_locked(true); + stop_webrtc_capture_locked(true, true); } #ifdef SUNSHINE_ENABLE_WEBRTC @@ -4217,6 +4221,22 @@ namespace webrtc_stream { void schedule_webrtc_idle_shutdown() { webrtc_capture.idle_shutdown_pending.store(true, std::memory_order_release); const auto token = webrtc_idle_shutdown_token.fetch_add(1, std::memory_order_acq_rel) + 1; + std::chrono::steady_clock::duration grace_period = kWebrtcIdleGracePeriod; +#ifdef _WIN32 + const auto virtual_displays = VDISPLAY::enumerateSudaVDADisplays(); + const bool virtual_display_active = std::any_of( + virtual_displays.begin(), + virtual_displays.end(), + [](const VDISPLAY::SudaVDADisplayInfo &info) { + return info.is_active; + } + ); + if (virtual_display_active) { + BOOST_LOG(info) << "WebRTC: last session closed with an active virtual display; " + << "shortening idle shutdown grace period to restore displays promptly."; + grace_period = kWebrtcIdleGracePeriodVirtualDisplay; + } +#endif task_pool.pushDelayed( [token]() { if (webrtc_idle_shutdown_token.load(std::memory_order_acquire) != token) { @@ -4232,7 +4252,7 @@ namespace webrtc_stream { stop_webrtc_capture_if_idle(); reset_webrtc_factory(); }, - kWebrtcIdleGracePeriod + grace_period ); } diff --git a/src_assets/common/assets/web/App.vue b/src_assets/common/assets/web/App.vue index 738a34b665..d717b984f2 100644 --- a/src_assets/common/assets/web/App.vue +++ b/src_assets/common/assets/web/App.vue @@ -4,12 +4,10 @@ -
-
+
+
- Vibepollo +

{{ displayTitle && displayTitle.includes('.') ? $t(displayTitle) : displayTitle @@ -91,11 +89,9 @@ class="relative flex-1 flex flex-col items-center justify-center p-6 overflow-y-auto" >
- Vibepollo +

{{ $t('auth.logout_success') }} @@ -111,7 +107,7 @@

- Vibepollo + Nimbus

@@ -161,10 +157,10 @@ const cfgStore = useConfigStore(); const { metadata } = storeToRefs(cfgStore); const linkClass = (path: string) => { - const base = 'inline-flex items-center gap-2 px-3 py-1 rounded-md text-brand'; + const base = 'nimbus-nav-link'; const active = route.path === path; - if (active) return base + ' font-semibold bg-primary/20 text-brand'; - return base + ' hover:bg-primary/10'; + if (active) return base + ' nimbus-nav-link-active'; + return base; }; const pageTitle = ref('Dashboard'); const displayTitle = computed(() => { @@ -186,7 +182,7 @@ watch( '/clients': 'clients.nav', '/webrtc': 'webrtc.nav', }; - const v = map[p] || 'Vibepollo'; + const v = map[p] || 'Nimbus'; pageTitle.value = v; }, { immediate: true }, @@ -257,7 +253,7 @@ function onMobileSelect(key: string | number): void { } // Layout container sizing via route meta: { container: 'sm'|'md'|'lg'|'xl'|'full' } -const base = 'mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6'; +const base = 'nimbus-page-container'; const sizes: Record = { sm: 'max-w-2xl', md: 'max-w-3xl', diff --git a/src_assets/common/assets/web/Login.vue b/src_assets/common/assets/web/Login.vue index 4b67128930..1d0eb92212 100644 --- a/src_assets/common/assets/web/Login.vue +++ b/src_assets/common/assets/web/Login.vue @@ -1,11 +1,11 @@ diff --git a/src_assets/common/assets/web/configs/tabs/General.vue b/src_assets/common/assets/web/configs/tabs/General.vue index 67ad0d3ff1..c33b01c928 100644 --- a/src_assets/common/assets/web/configs/tabs/General.vue +++ b/src_assets/common/assets/web/configs/tabs/General.vue @@ -93,7 +93,7 @@ function removeServerCommand(index: number) { v-model="config.sunshine_name" setting-key="sunshine_name" class="mb-6" - placeholder="Vibeshine" + placeholder="Nimbus" /> diff --git a/src_assets/common/assets/web/configs/tabs/Network.vue b/src_assets/common/assets/web/configs/tabs/Network.vue index 86ebfe49af..5cd42db46b 100644 --- a/src_assets/common/assets/web/configs/tabs/Network.vue +++ b/src_assets/common/assets/web/configs/tabs/Network.vue @@ -5,8 +5,8 @@ import { useConfigStore } from '@/stores/config'; const store = useConfigStore(); const config = store.config; -const defaultMoonlightPort = 47989; -const effectivePort = computed(() => Number(config.port ?? defaultMoonlightPort)); +const defaultStreamPort = 47989; +const effectivePort = computed(() => Number(config.port ?? defaultStreamPort));