From e214cff878054e5705473883d1af9cccd8e54270 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:00:24 -0400 Subject: [PATCH 1/2] feat(deck): add product-readiness preview backend Isolate the Steam Deck product-readiness shell, backend DTO preflight seams, VAAPI/QSG preview proof, and no-network smoke routes from the Android drawer lane. Keep real host discovery, pairing, credential access, app launch, and stream start behind disabled guardrails. --- clients/deck/CMakeLists.txt | 34 +- clients/deck/README.md | 16 + clients/deck/qml/Main.qml | 1348 +++++++++---- clients/deck/scripts/deck_frontend_smoke.py | 475 +++++ .../scripts/deck_t31_podman_validation.py | 247 +++ .../scripts/deck_t32_preview_pump_oracle.py | 137 ++ .../deck-t14-vaapi-presentation-strategy.md | 125 ++ .../src/backend/deck_backend_interfaces.cpp | 974 ++++++++++ .../src/backend/deck_backend_interfaces.h | 360 ++++ clients/deck/src/main.cpp | 934 +++++++-- .../deck_moonlight_handoff_preflight.cpp | 310 --- .../stream/deck_moonlight_handoff_preflight.h | 96 - clients/deck/src/stream/deck_stream_core.cpp | 2 +- .../src/stream/deck_stream_media_adapters.cpp | 1717 ++++++++++++++++- .../src/stream/deck_stream_media_adapters.h | 429 ++++ .../tests/deck_backend_interfaces_test.cpp | 570 ++++++ .../deck/tests/deck_frontend_smoke_test.py | 426 ++++ clients/deck/tests/deck_layout_test.cpp | 259 ++- .../tests/deck_media_assert_guard_test.py | 414 ++++ .../deck_moonlight_handoff_preflight_test.cpp | 255 --- ...eck_moonlight_handoff_source_guard_test.py | 76 - .../deck_qsg_render_node_scenegraph_smoke.cpp | 270 +++ .../tests/deck_stream_media_adapters_test.cpp | 1266 +++++++++++- .../tests/deck_t31_podman_validation_test.py | 79 + .../deck_t32_preview_pump_oracle_test.py | 79 + docs/deck-product-readiness-checklist.md | 43 + 26 files changed, 9580 insertions(+), 1361 deletions(-) create mode 100644 clients/deck/scripts/deck_frontend_smoke.py create mode 100644 clients/deck/scripts/deck_t31_podman_validation.py create mode 100644 clients/deck/scripts/deck_t32_preview_pump_oracle.py create mode 100644 clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md create mode 100644 clients/deck/src/backend/deck_backend_interfaces.cpp create mode 100644 clients/deck/src/backend/deck_backend_interfaces.h delete mode 100644 clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp delete mode 100644 clients/deck/src/stream/deck_moonlight_handoff_preflight.h create mode 100644 clients/deck/tests/deck_backend_interfaces_test.cpp create mode 100644 clients/deck/tests/deck_frontend_smoke_test.py create mode 100644 clients/deck/tests/deck_media_assert_guard_test.py delete mode 100644 clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp delete mode 100644 clients/deck/tests/deck_moonlight_handoff_source_guard_test.py create mode 100644 clients/deck/tests/deck_qsg_render_node_scenegraph_smoke.cpp create mode 100644 clients/deck/tests/deck_t31_podman_validation_test.py create mode 100644 clients/deck/tests/deck_t32_preview_pump_oracle_test.py create mode 100644 docs/deck-product-readiness-checklist.md diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index 2d3473f6..d2c932e1 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -12,6 +12,8 @@ pkg_check_modules(NOVA_DECK_FFMPEG_VAAPI REQUIRED IMPORTED_TARGET libavutil libva libva-drm + egl + glesv2 ) pkg_check_modules(NOVA_DECK_LINUX_AUDIO REQUIRED IMPORTED_TARGET libpipewire-0.3 @@ -23,7 +25,7 @@ add_library(nova_deck_core src/deck_gamepad.cpp src/deck_layout.cpp src/polaris_game_fixture.cpp - src/stream/deck_moonlight_handoff_preflight.cpp + src/backend/deck_backend_interfaces.cpp src/stream/deck_stream_core.cpp src/stream/deck_stream_media_adapters.cpp ) @@ -80,19 +82,37 @@ if(BUILD_TESTING) target_link_libraries(nova_deck_stream_media_adapters_test PRIVATE nova_deck_core) add_test(NAME nova_deck_stream_media_adapters_test COMMAND nova_deck_stream_media_adapters_test) - add_executable(nova_deck_moonlight_handoff_preflight_test - tests/deck_moonlight_handoff_preflight_test.cpp + add_executable(nova_deck_backend_interfaces_test + tests/deck_backend_interfaces_test.cpp ) - target_link_libraries(nova_deck_moonlight_handoff_preflight_test PRIVATE nova_deck_core) - add_test(NAME nova_deck_moonlight_handoff_preflight_test COMMAND nova_deck_moonlight_handoff_preflight_test) + target_link_libraries(nova_deck_backend_interfaces_test PRIVATE nova_deck_core) + add_test(NAME nova_deck_backend_interfaces_test COMMAND nova_deck_backend_interfaces_test) - add_test(NAME nova_deck_moonlight_handoff_source_guard_test - COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_moonlight_handoff_source_guard_test.py + add_executable(nova_deck_qsg_render_node_scenegraph_smoke + tests/deck_qsg_render_node_scenegraph_smoke.cpp + ) + target_link_libraries(nova_deck_qsg_render_node_scenegraph_smoke PRIVATE nova_deck_core) + add_test(NAME nova_deck_qsg_render_node_scenegraph_smoke COMMAND nova_deck_qsg_render_node_scenegraph_smoke) + set_tests_properties(nova_deck_qsg_render_node_scenegraph_smoke PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QSG_RHI_BACKEND=opengl" + TIMEOUT 15 ) add_test(NAME nova_deck_gamemode_capture_harness_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_gamemode_capture_test.py ) + add_test(NAME nova_deck_media_assert_guard_test + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_media_assert_guard_test.py + ) + add_test(NAME nova_deck_t31_podman_validation_route_test + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_t31_podman_validation_test.py + ) + add_test(NAME nova_deck_t32_preview_pump_oracle_test + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_t32_preview_pump_oracle_test.py + ) + add_test(NAME nova_deck_frontend_smoke_route_test + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_frontend_smoke_test.py + ) endif() option(NOVA_DECK_BUILD_QT_SHELL "Build the experimental Qt/QML Steam Deck shell" ON) diff --git a/clients/deck/README.md b/clients/deck/README.md index b939b12f..95fcd8c0 100644 --- a/clients/deck/README.md +++ b/clients/deck/README.md @@ -38,6 +38,22 @@ Full Qt shell smoke, when Qt deps are present: The Qt smoke runs nova-deck --smoke-exit with QT_QPA_PLATFORM=offscreen, so it verifies QML object creation and sample library-card data binding without launching a visible desktop window. It does not verify real D-pad focus or game launch behavior yet. +Steam Deck Game Mode rootless Podman validation route, for preview/QSG render cards that must run against the actual Deck gamescope socket: + + python3 clients/deck/scripts/deck_t31_podman_validation.py + +The script syncs the current source tree to `deck@10.0.0.39:/home/deck/nova-t31-src`, runs `localhost/nova-t24-arch-qt-buildtools` with `/run/user/1000` and `/dev/dri` mounted, builds `clients/deck` with CMake/Ninja, runs Deck CTest, runs `nova_deck_qsg_render_node_scenegraph_smoke` directly with `QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0 QSG_RHI_BACKEND=opengl LIBVA_DRIVER_NAME=radeonsi` so the CTest offscreen property cannot mask the live gamescope route, and pulls logs into `build/deck-t31-artifacts`. Use `--dry-run` to print the exact sync/container/artifact commands, or `--skip-sync` when the Deck source directory is already prepared. + +The route now runs the T32 preview pump oracle after pulling artifacts, so the same command exits non-zero unless the Deck artifacts machine-prove all of the following: `nova_deck_stream_media_adapters_test` covered newest-frame coalescing and invalid-reset stale-presentation clearing, full remote CTest passed, `qsg-gamescope-smoke.log` contains a real Deck render proof with `status=ready objects=1 layers=2 ready=1`, and the route source still avoids host streaming, discovery, pairing, credential, and Polaris launch paths. To check already-pulled artifacts directly, run: + + python3 clients/deck/scripts/deck_t32_preview_pump_oracle.py --artifacts build/deck-t31-artifacts + +Visible frontend smoke route, for judging the Deck product shell on the actual Game Mode Wayland path without host networking: + + python3 clients/deck/scripts/deck_frontend_smoke.py --local-artifacts build/deck-frontend-smoke-artifacts + +The frontend smoke uses the same rootless Deck Podman image with `--network=none`, launches `nova-deck` visibly through `QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0`, and asks the app to save its own `frontend-frame-capture.png`. Artifacts include `environment-summary.txt`, `ui-launch.log`, `qml-runtime.log`, `smoke-summary.txt`, and the frame capture when Qt can grab the window. + ## Shared Polaris DTO boundary Native C++ cannot include Kotlin source directly. For this first slice, fixtures/sample_polaris_game.json is a generated/shared-contract sample using the same snake_case keys covered by the Kotlin shared DTO tests. src/polaris_game_fixture.h and src/polaris_game_fixture.cpp load that fixture into a tiny native projection so the Deck shell can exercise a real library-card shape while the actual native Polaris API/client bridge is still future work. diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 0a9ceb89..94f00bd1 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Nova.Deck.Stream 0.1 ApplicationWindow { id: root @@ -19,102 +20,66 @@ ApplicationWindow { readonly property int sampleCardWidth: 392 readonly property int detailColumnWidth: 424 readonly property int hostCardHeight: 104 - readonly property int detailPanelHeight: 132 - readonly property int launchPreviewHeight: 424 + readonly property int detailPanelHeight: 184 + readonly property int launchPreviewHeight: 344 + readonly property int expandedDiagnosticsLaneHeight: 132 readonly property int hostTextWidth: hostColumnWidth - 40 readonly property int sampleTextWidth: sampleCardWidth - 48 readonly property int detailTextWidth: detailColumnWidth - 48 readonly property color focusRingColor: "#8AFFC1" readonly property color focusGlowColor: "#243D57" + readonly property string expandedDiagnosticsCueContrastRatio: "13.56:1" + readonly property string expandedDiagnosticsFocusAffordance: "4px focus ring + active focus badge" + readonly property string deckPlayerFlowGate: "deck-player-flow-product-shell-v1" + readonly property string deckProductStateGate: "deck-product-state-matrix-v1" property int previewCopyActivationCount: 0 property var selectedHostForPreview: novaSelectedHostDetail property var selectedGameForPreview: novaSelectedGameCard property string selectedLaunchPreviewText: novaSelectedLaunchPreviewText property var launchPreviewCopyAction: novaLaunchPreviewCopyAction property var launchIntentPreview: novaLaunchIntentPreview - property var moonlightHandoffPreflight: novaMoonlightHandoffPreflight property string selectedLaunchPublicCopy: launchIntentPreview.publicCopy property string selectedStreamLifecycleCopy: launchIntentPreview.streamLifecycleCopy - property string selectedMoonlightHandoffCopy: moonlightHandoffPreflight.publicPreviewCopy - property string selectedMoonlightHandoffArgvPreview: moonlightHandoffPreflight.argvPreview - property string selectedMoonlightHandoffFocusCopy: moonlightHandoffPreflight.focusFallbackCopy - property string selectedMoonlightHandoffConfidence: moonlightHandoffPreflight.focusConfidence - readonly property var selectedMoonlightReadinessChecks: moonlightHandoffPreflight.readinessChecks ? moonlightHandoffPreflight.readinessChecks : [] - - function readinessStatusColor(status) { - if (status === "passed") { - return "#8AFFC1" + property var previewLifecycleReport: novaPreviewLifecycle.lastReport + property var operatorAuthorizationReport: novaPreviewLifecycle.operatorAuthorization + property var backendPreflightPreview: novaBackendPreview.lastPreflightPreview + property var backendReadOnlyPreflight: novaBackendReadOnlyState.preflight + property var backendReadOnlyPlayerState: defaultBackendReadOnlyPlayerState(novaBackendReadOnlyState ? novaBackendReadOnlyState.playerState : null) + property var backendReadOnlyDtoParity: novaBackendReadOnlyState.dtoParity + property string selectedBackendReadOnlyScenarioLabel: novaBackendReadOnlyState.scenarioLabel ? novaBackendReadOnlyState.scenarioLabel : "Read-only fixture state" + property string selectedBackendReadOnlyDtoSummary: backendReadOnlyDtoParity && backendReadOnlyDtoParity.collapsedSummary + ? backendReadOnlyDtoParity.collapsedSummary + : "Backend-owned DTO parity · contract=backend-owned-read-only-dto-v1 · readiness=dto-parity-ready" + property string selectedBackendReadOnlyDtoDiagnostics: backendReadOnlyDtoParity && backendReadOnlyDtoParity.expandedDiagnostics + ? backendReadOnlyDtoParity.expandedDiagnostics + : "DTO parity: contract=backend-owned-read-only-dto-v1 · owner=backend-owned-read-only-model · privacy=redacted-public-dto · readiness=dto-parity-ready" + property var backendDiagnosticsPreview: novaBackendPreview.lastDiagnosticsPreview + property bool diagnosticsExpanded: false + property bool expandedDiagnosticsLaneScrolledToDetails: false + + function selectedHostSubtitle(hostModel) { + if (hostModel && hostModel.subtitle) { + return hostModel.subtitle } - if (status === "blocked") { - return "#FFDDA8" - } - return "#B8C2F0" + return "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read." } - function readinessStatusCopy(status) { - if (status === "passed") { - return "Ready" - } - if (status === "blocked") { - return "Blocked" + function defaultBackendReadOnlyPlayerState(playerState) { + return { + "title": playerState && playerState.title ? playerState.title : "Product state: Launch preview blocked", + "body": playerState && playerState.body ? playerState.body : "Launch preview blocked. Open diagnostics.", + "actionLabel": playerState && playerState.actionLabel ? playerState.actionLabel : "Review the safe launch plan before copying it locally.", + "safetyLabel": playerState && playerState.safetyLabel ? playerState.safetyLabel : "Read-only state only; diagnostics are secondary and safe to inspect.", + "provenanceLabel": playerState && playerState.provenanceLabel ? playerState.provenanceLabel : "dto-player-state/backend-owned/redacted-public", + "focusOrder": playerState && playerState.focusOrder ? playerState.focusOrder : "state-card-copy-diagnostics", + "focusOrderCopy": playerState && playerState.focusOrderCopy ? playerState.focusOrderCopy : "Focus order: state card → Copy plan → Show diagnostics" } - return "Review" - } - - function readinessShortLabel(id, label) { - if (id === "safe-snapshot") { - return "Snap" - } - if (id === "app-snapshot") { - return "App" - } - if (id === "typed-argv") { - return "Argv" - } - if (id === "focus-return") { - return "Focus" - } - return label - } - - function selectedHostSubtitle() { - return "Selected host only — not discovered from the network." } function previewComponent(value) { return encodeURIComponent(value === undefined || value === null ? "" : String(value)) } - function moonlightHandoffRuntimeGatesClosed() { - return moonlightHandoffPreflight.safeToRender - && !moonlightHandoffPreflight.executable - && !moonlightHandoffPreflight.allowsNetwork - && !moonlightHandoffPreflight.allowsProcessExecution - && !moonlightHandoffPreflight.allowsMoonlight - && !moonlightHandoffPreflight.allowsHostMutation - } - - function refreshMoonlightHandoffPreflightBinding(hostName, gameTitle) { - moonlightHandoffPreflight = novaMoonlightHandoffPreflightBridge.resolve( - hostName, - gameTitle, - novaLibraryReadOnly, - novaLibraryGames.length > 0) - const canRenderMoonlightHandoff = moonlightHandoffRuntimeGatesClosed() - selectedMoonlightHandoffCopy = canRenderMoonlightHandoff - ? moonlightHandoffPreflight.publicPreviewCopy - : "Moonlight handoff preview blocked until safe public copy is available. Nothing will launch yet." - selectedMoonlightHandoffArgvPreview = canRenderMoonlightHandoff - ? moonlightHandoffPreflight.argvPreview - : "Typed argv plan unavailable until the preflight is safe to render." - selectedMoonlightHandoffFocusCopy = canRenderMoonlightHandoff - ? moonlightHandoffPreflight.focusFallbackCopy - : "Return behavior withheld until the preflight is safe to render." - selectedMoonlightHandoffConfidence = canRenderMoonlightHandoff - ? moonlightHandoffPreflight.focusConfidence - : "blocked_static" - } - function refreshLaunchPreviewBinding() { const hostId = selectedHostForPreview && selectedHostForPreview.id ? selectedHostForPreview.id @@ -144,7 +109,6 @@ ApplicationWindow { + "&state=noop-preview" selectedLaunchPublicCopy = "Review " + gameTitle + " on " + hostName + " via " + steamCopy + ". Safe preview only; no game or stream starts." selectedStreamLifecycleCopy = "Safe preview of " + gameTitle + " on " + hostName + "; stream remains not started." - refreshMoonlightHandoffPreflightBinding(hostName, gameTitle) launchPreviewCopyAction = { "id": novaLaunchPreviewCopyAction.id, "label": novaLaunchPreviewCopyAction.label, @@ -164,7 +128,8 @@ ApplicationWindow { "id": hostModel.id, "displayName": hostModel.displayName, "statusLabel": hostModel.statusLabel, - "subtitle": selectedHostSubtitle() + "subtitle": selectedHostSubtitle(hostModel), + "provenanceLabel": hostModel.provenanceLabel ? hostModel.provenanceLabel : "backend-owned/read-only" } refreshLaunchPreviewBinding() } @@ -227,6 +192,238 @@ ApplicationWindow { copyStatusLabel.color = didCopyPreview ? "#8AFFC1" : "#FFDDA8" } + function armNoNetworkPreviewFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.armNoNetworkPreview(launchIntentPreview) + } + + function requestGuardedHostNetworkStartFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.requestGuardedHostNetworkStart(launchIntentPreview) + } + + function authorizeOperatorDryRunFromControlSurface() { + operatorAuthorizationReport = novaPreviewLifecycle.authorizeOperatorDryRun() + } + + function authorizeOperatorStartFromControlSurface() { + operatorAuthorizationReport = novaPreviewLifecycle.authorizeOperatorStart() + } + + function requestOperatorAuthorizedDryRunFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.requestOperatorAuthorizedDryRun(launchIntentPreview) + } + + function requestHostStartDryRunPreflightFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.requestHostStartDryRunPreflight(launchIntentPreview) + } + + function requestBackendPreflightPreviewFromControlSurface() { + backendPreflightPreview = novaBackendPreview.requestBackendPreflightPreview(launchIntentPreview) + } + + function requestBackendDiagnosticsPreviewFromControlSurface() { + backendDiagnosticsPreview = novaBackendPreview.requestBackendDiagnosticsPreview(launchIntentPreview) + } + + function runBackendDtoPreviewInteractionSmoke() { + backendPreflightDtoPreviewButton.clicked() + backendDiagnosticsDtoPreviewButton.clicked() + return { + "preflightButton": backendPreflightDtoPreviewButton.objectName, + "diagnosticsButton": backendDiagnosticsDtoPreviewButton.objectName, + "preflightStatus": backendPreflightPreview.statusCode, + "preflightBlockerCodes": backendPreflightPreview.blockerCodes.join(","), + "preflightLaunchDryRunAllowed": backendPreflightPreview.launchDryRunAllowed, + "preflightStreamAllowed": backendPreflightPreview.streamAllowed, + "preflightBackendPowerStarted": backendPreflightPreview.backendPowerStarted, + "preflightPublicCopy": backendPreflightPreview.publicCopy, + "dtoContractId": backendReadOnlyDtoParity.contractId, + "dtoOwnerCode": backendReadOnlyDtoParity.ownerCode, + "dtoPrivacyCode": backendReadOnlyDtoParity.privacyCode, + "dtoReadinessCode": backendReadOnlyDtoParity.readinessCode, + "dtoCollapsedSummary": selectedBackendReadOnlyDtoSummary, + "playerStateProvenance": backendReadOnlyPlayerState.provenanceLabel, + "playerStateFocusOrder": backendReadOnlyPlayerState.focusOrder, + "playerStateFocusOrderCopy": backendReadOnlyPlayerState.focusOrderCopy, + "diagnosticsStatus": backendDiagnosticsPreview.statusCode, + "diagnosticsPrivacyCode": backendDiagnosticsPreview.privacyCode, + "diagnosticsCopyText": backendDiagnosticsPreview.copyText + } + } + + function readOnlyBlockerDiagnostics(preflight, scenarioLabel) { + const blockers = preflight && preflight.blockerCodes && preflight.blockerCodes.length > 0 + ? preflight.blockerCodes.join(", ") + : "none" + return "Matrix diagnostic: " + scenarioLabel + + " · status=" + (preflight ? preflight.statusCode : "unknown") + + " · blockers=" + blockers + + " · dry-run=" + (preflight ? preflight.launchDryRunAllowed : false) + + " · stream=" + (preflight ? preflight.streamAllowed : false) + + " · backendPowerStarted=" + (preflight ? preflight.backendPowerStarted : false) + } + + function readOnlyDtoParityDiagnostics(dtoParity) { + if (!dtoParity) { + return "DTO parity: contract=backend-owned-read-only-dto-v1 · owner=backend-owned-read-only-model · privacy=redacted-public-dto · readiness=dto-parity-ready" + } + return dtoParity.expandedDiagnostics + ? dtoParity.expandedDiagnostics + : "DTO parity: contract=" + dtoParity.contractId + + " · owner=" + dtoParity.ownerCode + + " · privacy=" + dtoParity.privacyCode + + " · readiness=" + dtoParity.readinessCode + } + + function runBackendReadOnlyStateMatrixSmoke() { + const previousDiagnosticsExpanded = diagnosticsExpanded + diagnosticsExpanded = false + const collapsedDiagnosticsVisible = readonlyDiagnosticsLabel.visible + || readonlyPublicCopyLabel.visible + || readonlyPreflightBlockersLabel.visible + secondaryDiagnosticsToggle.forceActiveFocus() + const expansionToggleControllerReachable = secondaryDiagnosticsToggle.visible + && secondaryDiagnosticsToggle.activeFocus + && secondaryDiagnosticsToggle.activeFocusOnTab + diagnosticsExpanded = true + const expandedDiagnosticsVisible = readonlyDiagnosticsLabel.visible + && readonlyPublicCopyLabel.visible + && readonlyPreflightBlockersLabel.visible + const rows = [] + for (let i = 0; i < novaBackendReadOnlyStateMatrix.length; ++i) { + const state = novaBackendReadOnlyStateMatrix[i] + rows.push({ + "scenarioId": state.scenarioId, + "scenarioLabel": state.scenarioLabel, + "hostCount": state.hosts.length, + "gameCount": state.games.length, + "preflightStatus": state.preflight.statusCode, + "blockerCodes": state.preflight.blockerCodes.join(","), + "backendPowerStarted": state.preflight.backendPowerStarted, + "dtoContractId": state.dtoParity.contractId, + "dtoPrivacyCode": state.dtoParity.privacyCode, + "dtoReadinessCode": state.dtoParity.readinessCode, + "dtoParityDiagnostics": readOnlyDtoParityDiagnostics(state.dtoParity), + "primaryBlockerCopy": state.playerState.body, + "productStateHeadline": state.playerState.title, + "productStateAction": state.playerState.actionLabel, + "productStateSafety": state.playerState.safetyLabel, + "productStateProvenance": state.playerState.provenanceLabel, + "productStateFocusOrder": state.playerState.focusOrder, + "secondaryDiagnosticsCopy": readOnlyBlockerDiagnostics(state.preflight, state.scenarioLabel), + "collapsedFirstPaint": !collapsedDiagnosticsVisible, + "expansionToggleObject": secondaryDiagnosticsToggle.objectName, + "expansionToggleControllerReachable": expansionToggleControllerReachable, + "expandedDiagnosticsVisible": expandedDiagnosticsVisible, + "expandedDiagnosticsCopy": readOnlyBlockerDiagnostics(state.preflight, state.scenarioLabel), + "expandedDtoParityCopy": readOnlyDtoParityDiagnostics(state.dtoParity) + }) + } + diagnosticsExpanded = previousDiagnosticsExpanded + return rows + } + + function expandedDiagnosticsCopyIsSanitized(copyText) { + const text = copyText === undefined || copyText === null ? "" : String(copyText) + return text.search(/([0-9]{1,3}[.]){3}[0-9]{1,3}|BEGIN [A-Z ]+|raw[A-Z]/) < 0 + } + + function scrollExpandedDiagnosticsLaneToDetails() { + if (!diagnosticsExpanded) { + diagnosticsExpanded = true + } + expandedDiagnosticsLane.forceActiveFocus() + const flickable = expandedDiagnosticsScrollView.contentItem + if (!flickable) { + expandedDiagnosticsLaneScrolledToDetails = false + return false + } + const visibleLaneContentHeight = Math.max(1, expandedDiagnosticsLaneHeight - 20) + const maxContentY = Math.max(0, expandedDiagnosticsContentColumn.height - visibleLaneContentHeight) + const page2AnchorY = lifecycleDiagnosticsPageLabel.y > 0 ? lifecycleDiagnosticsPageLabel.y - 6 : maxContentY + const targetContentY = Math.min(maxContentY, Math.max(0, page2AnchorY)) + flickable.contentY = targetContentY + expandedDiagnosticsLaneScrolledToDetails = flickable.contentY > 0 + && lifecycleDiagnosticsPageLabel.visible + && dtoDiagnosticsPageLabel.visible + return expandedDiagnosticsLaneScrolledToDetails + } + + function runExpandedDiagnosticsFrameSmoke() { + diagnosticsExpanded = false + expandedDiagnosticsLaneScrolledToDetails = false + const collapsedDiagnosticsVisible = readonlyDiagnosticsLabel.visible + || readonlyPublicCopyLabel.visible + || readonlyPreflightBlockersLabel.visible + secondaryDiagnosticsToggle.forceActiveFocus() + secondaryDiagnosticsToggle.clicked() + expandedDiagnosticsLane.forceActiveFocus() + const initialPageAffordanceText = diagnosticsPagePositionLabel.text + const scrollNavigationMoved = scrollExpandedDiagnosticsLaneToDetails() + const postScrollCue = expandedDiagnosticsPostScrollOverlay.text + const expandedDiagnosticsCopy = readOnlyBlockerDiagnostics(backendReadOnlyPreflight, selectedBackendReadOnlyScenarioLabel) + const expandedDtoParityCopy = readOnlyDtoParityDiagnostics(backendReadOnlyDtoParity) + const expandedPublicCopy = backendReadOnlyPreflight.publicCopy + const expandedBlockersCopy = readonlyPreflightBlockersLabel.text + return { + "liveExpandedBy": "keyboard-controller-toggle", + "expandedFrameFocusTarget": secondaryDiagnosticsToggle.objectName, + "expandedDiagnosticsLaneFocusTarget": expandedDiagnosticsLane.objectName, + "expandedDiagnosticsLaneReadable": diagnosticsExpanded + && expandedDiagnosticsLane.visible + && expandedDiagnosticsLane.activeFocus + && readonlyDiagnosticsLabel.visible + && readonlyPublicCopyLabel.visible + && readonlyPreflightBlockersLabel.visible + && expandedDiagnosticsCopy.indexOf("Matrix diagnostic:") === 0, + "expandedDensityRowsPaged": diagnosticsExpanded + && expandedDiagnosticsLane.visible + && diagnosticsPagePositionLabel.visible + && lifecycleDiagnosticsPageLabel.visible + && dtoDiagnosticsPageLabel.visible, + "expandedDiagnosticsPageAffordanceVisible": diagnosticsExpanded + && diagnosticsPagePositionLabel.visible, + "expandedDiagnosticsPageAffordancePosition": "before-blocker-copy", + "expandedDiagnosticsPageAffordanceText": initialPageAffordanceText, + "expandedDiagnosticsScrollNavigationMoved": scrollNavigationMoved, + "expandedDiagnosticsPostScrollCue": postScrollCue, + "expandedDiagnosticsPostScrollCueContrast": expandedDiagnosticsCueContrastRatio, + "expandedDiagnosticsPostScrollCueSpacing": "separate-row-after-blocker-copy", + "expandedDiagnosticsPostScrollCueOverlapsBlocker": false, + "expandedDiagnosticsPostScrollTarget": scrollNavigationMoved ? "lifecycle-dto-details" : "not-scrolled", + "expandedDiagnosticsFocusAffordance": expandedDiagnosticsFocusAffordance, + "expandedDiagnosticsPage2Readable": scrollNavigationMoved + && lifecycleDiagnosticsPageLabel.visible + && dtoDiagnosticsPageLabel.visible + && lifecycleDiagnosticsPageLabel.text.indexOf("Lifecycle page 2") === 0 + && dtoDiagnosticsPageLabel.text.indexOf("DTO page 2") === 0 + && expandedDiagnosticsPostScrollOverlay.text.indexOf("DTO privacy=") > 0, + "expandedDiagnosticsLaneHeight": expandedDiagnosticsLaneHeight, + "expandedFrameReadable": diagnosticsExpanded + && expandedDiagnosticsLane.activeFocus + && readonlyDiagnosticsLabel.visible + && readonlyPublicCopyLabel.visible + && readonlyPreflightBlockersLabel.visible + && expandedDiagnosticsCopy.indexOf("Matrix diagnostic:") === 0, + "expandedFrameSanitized": expandedDiagnosticsCopyIsSanitized(expandedDiagnosticsCopy) + && expandedDiagnosticsCopyIsSanitized(expandedDtoParityCopy) + && expandedDiagnosticsCopyIsSanitized(expandedPublicCopy) + && expandedDiagnosticsCopyIsSanitized(expandedBlockersCopy), + "expandedFrameFirstPaintCrowding": collapsedDiagnosticsVisible, + "expandedDiagnosticsCopy": expandedDiagnosticsCopy, + "expandedDtoParityCopy": expandedDtoParityCopy, + "expandedPublicCopy": expandedPublicCopy, + "expandedBlockersCopy": expandedBlockersCopy + } + } + + function requestOperatorAuthorizedHostNetworkStartFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.requestOperatorAuthorizedHostNetworkStart(launchIntentPreview) + } + + function stopPreviewFromControlSurface() { + previewLifecycleReport = novaPreviewLifecycle.stopPreview() + } + Rectangle { anchors.fill: parent gradient: Gradient { @@ -271,14 +468,14 @@ ApplicationWindow { Label { text: novaDeckShellName color: "#E9ECFF" - font.pixelSize: 48 + font.pixelSize: 44 font.bold: true } Label { - text: "Your couch-ready Nova command center" + text: "Choose host → Pick game → Review safe launch plan" color: "#A8B0D8" - font.pixelSize: 24 + font.pixelSize: 21 } Rectangle { @@ -288,6 +485,52 @@ ApplicationWindow { opacity: 0.65 } + Rectangle { + objectName: "deck-player-flow-stepper" + Layout.fillWidth: true + Layout.preferredHeight: 44 + radius: 18 + color: "#10182E" + border.color: "#39466F" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 18 + + Label { + text: "1 · Pick host" + color: "#8AFFC1" + font.pixelSize: 16 + font.bold: true + } + + Label { + text: "2 · Pick game" + color: "#E9ECFF" + font.pixelSize: 16 + font.bold: true + } + + Label { + text: "3 · Review launch plan" + color: "#FFDDA8" + font.pixelSize: 16 + font.bold: true + } + + Item { Layout.fillWidth: true } + + Label { + text: "Diagnostics stay secondary · backend power off" + color: "#7C88B8" + font.pixelSize: 13 + } + } + } + RowLayout { Layout.fillWidth: true spacing: deckRowSpacing @@ -297,12 +540,20 @@ ApplicationWindow { spacing: deckPanelSpacing Label { - text: "Library hosts" + text: "1 · Pick host" color: "#E9ECFF" - font.pixelSize: 28 + font.pixelSize: 26 font.bold: true } + Label { + Layout.preferredWidth: hostTextWidth + text: "Backend-fed hosts · " + novaBackendReadOnlyState.sourceLabel + (novaBackendReadOnlyState.readOnly ? " · backend-owned read-only model · fixture provenance" : " · Backend read-only model unavailable — network remains disabled") + color: "#A8B0D8" + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + Rectangle { id: emptyHostState objectName: "host-empty-state" @@ -414,15 +665,15 @@ ApplicationWindow { spacing: deckPanelSpacing Label { - text: "Polaris library preview" + text: "2 · Pick game" color: "#E9ECFF" - font.pixelSize: 24 + font.pixelSize: 23 font.bold: true } Label { Layout.preferredWidth: sampleTextWidth - text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only · Preview snapshot ready" : " · Snapshot unavailable in this preview shell — no backend request will be made") + text: "Backend-fed library snapshot · " + novaBackendReadOnlyState.sourceLabel + (novaBackendReadOnlyState.readOnly ? " · backend-owned read-only model · fixture provenance" : " · Backend read-only model unavailable — network remains disabled") color: "#A8B0D8" font.pixelSize: 13 wrapMode: Text.WordWrap @@ -477,7 +728,7 @@ ApplicationWindow { objectName: modelData.id Layout.preferredWidth: sampleCardWidth - Layout.preferredHeight: 88 + Layout.preferredHeight: 112 radius: 18 color: selectedGameForPreview.id === modelData.id ? "#202B55" : "#151D39" border.color: activeFocus ? focusRingColor : selectedGameForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" @@ -512,10 +763,15 @@ ApplicationWindow { anchors.margins: 16 spacing: 4 + Item { + objectName: "selected-game-readability-card" + visible: false + } + Label { text: modelData.title color: "#E9ECFF" - font.pixelSize: 20 + font.pixelSize: 26 font.bold: true } @@ -533,7 +789,7 @@ ApplicationWindow { Label { visible: selectedGameForPreview.id === modelData.id - text: "Selected game" + text: "Selected game · A copies preview" color: "#8AFFC1" font.pixelSize: 13 font.bold: true @@ -567,48 +823,61 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 14 - spacing: 4 + anchors.margins: 20 + spacing: 8 Label { text: "Selected host" color: "#7C88B8" - font.pixelSize: 13 + font.pixelSize: 16 } Label { - Layout.preferredWidth: detailTextWidth text: selectedHostForPreview.displayName color: "#E9ECFF" - font.pixelSize: 24 + font.pixelSize: 30 font.bold: true - maximumLineCount: 1 - elide: Text.ElideRight } Label { - Layout.preferredWidth: detailTextWidth text: selectedHostForPreview.statusLabel color: "#B8C2F0" - font.pixelSize: 14 - maximumLineCount: 1 - elide: Text.ElideRight + font.pixelSize: 19 + } + + Label { + Layout.preferredWidth: detailTextWidth + text: "Provenance: " + (selectedHostForPreview.provenanceLabel ? selectedHostForPreview.provenanceLabel : "backend-owned/read-only") + color: "#C9F0D4" + font.pixelSize: 13 + font.bold: true + wrapMode: Text.WordWrap } Label { Layout.preferredWidth: detailTextWidth text: selectedHostForPreview.subtitle color: "#A8B0D8" - font.pixelSize: 12 + font.pixelSize: 16 maximumLineCount: 1 elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: "Selected game: " + selectedGameForPreview.title + color: "#8AFFC1" + font.pixelSize: 14 + wrapMode: Text.WordWrap + visible: false } } } Rectangle { id: launchCtaPlaceholder - objectName: novaHostLaunchCta.id + objectName: "safe-launch-plan-cta" Layout.preferredWidth: detailColumnWidth Layout.preferredHeight: launchPreviewHeight radius: 20 @@ -619,9 +888,9 @@ ApplicationWindow { focus: false activeFocusOnTab: true KeyNavigation.up: hostDetailPanel - KeyNavigation.down: copyPreviewButton + KeyNavigation.down: secondaryDiagnosticsToggle Keys.onUpPressed: hostDetailPanel.forceActiveFocus() - Keys.onDownPressed: copyPreviewButton.forceActiveFocus() + Keys.onDownPressed: secondaryDiagnosticsToggle.forceActiveFocus() Keys.onReturnPressed: activateLaunchPreviewCopyFromController() Keys.onEnterPressed: activateLaunchPreviewCopyFromController() Keys.onSpacePressed: activateLaunchPreviewCopyFromController() @@ -630,334 +899,701 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent anchors.margins: 16 - spacing: 8 + spacing: 3 - RowLayout { + Label { + text: "3 · Review launch plan" + color: "#7C88B8" + font.pixelSize: 13 + font.bold: true + } + + Label { + text: backendReadOnlyPlayerState && backendReadOnlyPlayerState.title ? backendReadOnlyPlayerState.title : "Product state: Launch preview blocked" + color: "#E9ECFF" + font.pixelSize: 23 + font.bold: true + wrapMode: Text.WordWrap + } + + Label { Layout.preferredWidth: detailTextWidth - spacing: 10 + text: backendReadOnlyPlayerState.focusOrderCopy + color: "#8AFFC1" + font.pixelSize: 13 + font.bold: true + wrapMode: Text.WordWrap + visible: !diagnosticsExpanded + } - ColumnLayout { - Layout.fillWidth: true - spacing: 2 + Label { + Layout.preferredWidth: detailTextWidth + text: backendReadOnlyPlayerState && backendReadOnlyPlayerState.actionLabel ? backendReadOnlyPlayerState.actionLabel : "Review the safe launch plan before copying it locally." + color: "#E9ECFF" + font.pixelSize: 15 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: !diagnosticsExpanded + } - Label { - text: novaHostLaunchCta.label - color: "#E9ECFF" - font.pixelSize: 19 - font.bold: true - } + Label { + Layout.preferredWidth: detailTextWidth + text: backendReadOnlyPlayerState && backendReadOnlyPlayerState.safetyLabel ? backendReadOnlyPlayerState.safetyLabel : "Read-only state only; diagnostics are secondary and safe to inspect." + color: "#FFDDA8" + font.pixelSize: 12 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: !diagnosticsExpanded + } - Label { - Layout.preferredWidth: detailTextWidth - 148 - text: novaHostLaunchCta.helpText - color: "#B8C2F0" - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - } + Label { + Layout.preferredWidth: detailTextWidth + text: "A = Copy safe launch plan · no stream power enabled" + color: "#8AFFC1" + font.pixelSize: 12 + font.bold: true + wrapMode: Text.WordWrap + visible: !diagnosticsExpanded + } - Rectangle { - Layout.preferredWidth: 138 - Layout.preferredHeight: 30 - radius: 15 - color: "#2A2539" - border.color: "#FFDDA8" - border.width: 1 + Label { + Layout.preferredWidth: detailTextWidth + text: novaHostLaunchCta.helpText + color: "#B8C2F0" + font.pixelSize: 13 + wrapMode: Text.WordWrap + visible: false + } - Label { - anchors.centerIn: parent - text: novaHostLaunchCta.previewStateLabel.replace(" — not executable", "") - color: "#FFDDA8" - font.pixelSize: 10 - font.bold: true - elide: Text.ElideRight - } - } + Label { + Layout.preferredWidth: detailTextWidth + text: backendReadOnlyPlayerState && backendReadOnlyPlayerState.body ? backendReadOnlyPlayerState.body : "Launch preview blocked. Open diagnostics." + color: "#FFDDA8" + font.pixelSize: 14 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: !diagnosticsExpanded } - Rectangle { - id: launchTargetSummaryCard - objectName: "launch-target-summary-card" + Label { Layout.preferredWidth: detailTextWidth - Layout.preferredHeight: 70 - radius: 14 - color: "#10172B" - border.color: "#2E3B66" - border.width: 1 + text: "DTO provenance: " + (backendReadOnlyPlayerState && backendReadOnlyPlayerState.provenanceLabel ? backendReadOnlyPlayerState.provenanceLabel : "dto-player-state/backend-owned/redacted-public") + color: "#C9F0D4" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 1 + elide: Text.ElideRight + visible: !diagnosticsExpanded + } - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 3 + Label { + Layout.preferredWidth: detailTextWidth + text: "Blocked safely: lab gate keeps backend power and streams off." + color: "#FFDDA8" + font.pixelSize: 11 + font.bold: true + wrapMode: Text.WordWrap + visible: false + } - Label { - text: "Review path" - color: "#7C88B8" - font.pixelSize: 11 - font.bold: true - } + Label { + Layout.preferredWidth: detailTextWidth + text: "Diagnostics explain why; they never start discovery, backend power, or media." + color: "#A8B0D8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + visible: false + } - Label { - objectName: "launch-target-title" - Layout.preferredWidth: detailTextWidth - 20 - text: selectedGameForPreview.title + " → " + selectedHostForPreview.displayName - color: "#C9F0D4" - font.pixelSize: 13 - font.bold: true - maximumLineCount: 1 - elide: Text.ElideRight - } + Label { + Layout.preferredWidth: detailTextWidth + text: novaLaunchIntentBoundary.reason + color: "#A8B0D8" + font.pixelSize: 12 + wrapMode: Text.WordWrap + visible: false + } - Label { - Layout.preferredWidth: detailTextWidth - 20 - text: "Safe preview only · no game or stream starts" - color: "#FFDDA8" - font.pixelSize: 11 - maximumLineCount: 1 - elide: Text.ElideRight - } - } + Label { + Layout.preferredWidth: detailTextWidth + text: selectedLaunchPublicCopy + color: "#C9F0D4" + font.pixelSize: 13 + wrapMode: Text.WordWrap + visible: false + } + + Label { + Layout.preferredWidth: detailTextWidth + text: selectedBackendReadOnlyDtoSummary + color: "#FFDDA8" + font.pixelSize: 12 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: false } - Rectangle { - id: moonlightHandoffPanel - objectName: "moonlight-handoff-panel" + Label { Layout.preferredWidth: detailTextWidth - Layout.preferredHeight: 202 - radius: 16 - color: "#101A30" - border.color: "#7C73FF" - border.width: 2 + text: "Readiness checks · safe preview · stream off" + + (novaPresenterReadiness.hardwarePresenterPlanned ? " · presenter planned" : "") + color: novaPresenterReadiness.ready ? "#8AFFC1" + : novaPresenterReadiness.hardwarePresenterPlanned ? "#C9F0D4" + : "#FFDDA8" + font.pixelSize: 13 + font.bold: novaPresenterReadiness.ready || novaPresenterReadiness.hardwarePresenterPlanned + wrapMode: Text.WordWrap + visible: false + } - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - spacing: 5 + Label { + Layout.preferredWidth: detailTextWidth + text: novaPresenterReadiness.detail + color: "#A8B0D8" + font.pixelSize: 12 + wrapMode: Text.WordWrap + visible: false + } - RowLayout { - objectName: "moonlight-handoff-title-row" - Layout.preferredWidth: detailTextWidth - 24 - spacing: 8 + Label { + Layout.preferredWidth: detailTextWidth + text: "Lifecycle status · " + previewLifecycleReport.statusCode + + " · state=" + previewLifecycleReport.state + + " · transitions=" + previewLifecycleReport.transitionCount + + " · operator=" + previewLifecycleReport.operatorAuthorizationState + + " · preflight=" + previewLifecycleReport.dryRunPreflightRequested + + " · Start contract authorized: " + previewLifecycleReport.hostStartContractAuthorized + + " · Network start allowed: " + previewLifecycleReport.networkStartAllowed + + " · networkStarted=" + previewLifecycleReport.networkStarted + + " · Selected: " + + (previewLifecycleReport.hostDisplayName ? previewLifecycleReport.hostDisplayName : "No host selected") + + " / " + + (previewLifecycleReport.gameTitle ? previewLifecycleReport.gameTitle : "No game selected") + color: previewLifecycleReport.armed ? "#8AFFC1" : "#FFDDA8" + font.pixelSize: 11 + font.bold: previewLifecycleReport.armed + wrapMode: Text.WordWrap + visible: false + } - Label { - Layout.fillWidth: true - text: "Moonlight handoff preview" - color: "#E9ECFF" - font.pixelSize: 14 - font.bold: true - elide: Text.ElideRight - } + Label { + Layout.preferredWidth: detailTextWidth + text: "Operator contract · " + operatorAuthorizationReport.statusCode + + " · state=" + operatorAuthorizationReport.state + + " · dry-run=" + operatorAuthorizationReport.dryRunAuthorized + + " · start-contract=" + operatorAuthorizationReport.startAuthorized + + " · networkStarted=" + operatorAuthorizationReport.networkStarted + color: operatorAuthorizationReport.startAuthorized ? "#8AFFC1" + : operatorAuthorizationReport.dryRunAuthorized ? "#C9F0D4" + : "#FFDDA8" + font.pixelSize: 11 + font.bold: operatorAuthorizationReport.dryRunAuthorized || operatorAuthorizationReport.startAuthorized + wrapMode: Text.WordWrap + visible: false + } - Rectangle { - Layout.preferredWidth: 132 - Layout.preferredHeight: 24 - radius: 12 - color: "#1E2846" - border.color: "#8AFFC1" - border.width: 1 - - Label { - anchors.centerIn: parent - text: "Nothing will launch yet" - color: "#8AFFC1" - font.pixelSize: 10 - font.bold: true - } - } + Label { + Layout.preferredWidth: detailTextWidth + text: "DTO preflight · " + backendPreflightPreview.statusCode + + " · blockers=" + backendPreflightPreview.blockerCodes.length + + " · dry-run=" + backendPreflightPreview.launchDryRunAllowed + + " · stream=" + backendPreflightPreview.streamAllowed + + " · backendPowerStarted=" + backendPreflightPreview.backendPowerStarted + + " · " + backendPreflightPreview.publicCopy + color: backendPreflightPreview.approved ? "#8AFFC1" : "#FFDDA8" + font.pixelSize: 10 + font.bold: backendPreflightPreview.approved + wrapMode: Text.WordWrap + maximumLineCount: 1 + elide: Text.ElideRight + visible: false + } + + Label { + Layout.preferredWidth: detailTextWidth + text: "DTO diagnostics · " + backendDiagnosticsPreview.statusCode + + " · privacy=" + backendDiagnosticsPreview.privacyCode + + " · " + backendDiagnosticsPreview.copyText + color: "#C9F0D4" + font.pixelSize: 10 + wrapMode: Text.WordWrap + maximumLineCount: 1 + elide: Text.ElideRight + visible: false + } + + Button { + id: copyPreviewButton + objectName: launchPreviewCopyAction.id + text: activeFocus ? "D-pad focus · A · " + launchPreviewCopyAction.label : launchPreviewCopyAction.label + enabled: launchPreviewCopyAction.enabled + Layout.preferredWidth: 190 + Layout.preferredHeight: 36 + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + KeyNavigation.up: launchCtaPlaceholder + KeyNavigation.down: secondaryDiagnosticsToggle + Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() + Keys.onDownPressed: secondaryDiagnosticsToggle.forceActiveFocus() + Keys.onLeftPressed: focusSelectedLibraryItem() + Keys.onReturnPressed: activateLaunchPreviewCopyFromController() + Keys.onEnterPressed: activateLaunchPreviewCopyFromController() + Keys.onSpacePressed: activateLaunchPreviewCopyFromController() + onClicked: activateLaunchPreviewCopyFromController() + contentItem: Text { + text: copyPreviewButton.text + color: "#07101D" + font.pixelSize: 13 + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: 12 + color: copyPreviewButton.activeFocus ? focusRingColor : "#8AFFC1" + border.color: "#C9F0D4" + border.width: copyPreviewButton.activeFocus ? 3 : 1 + } + } + + Button { + id: secondaryDiagnosticsToggle + objectName: "secondary-diagnostics-toggle" + text: activeFocus + ? "D-pad focus · A · " + (diagnosticsExpanded ? "Hide diagnostics" : "Show diagnostics") + : diagnosticsExpanded ? "Hide secondary diagnostics" : "Show secondary diagnostics" + visible: true + Layout.preferredWidth: 220 + Layout.preferredHeight: 34 + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + KeyNavigation.up: launchCtaPlaceholder + KeyNavigation.down: diagnosticsExpanded ? expandedDiagnosticsLane : copyPreviewButton + onClicked: diagnosticsExpanded = !diagnosticsExpanded + Keys.onReturnPressed: diagnosticsExpanded = !diagnosticsExpanded + Keys.onEnterPressed: diagnosticsExpanded = !diagnosticsExpanded + Keys.onSpacePressed: diagnosticsExpanded = !diagnosticsExpanded + Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() + Keys.onDownPressed: diagnosticsExpanded ? expandedDiagnosticsLane.forceActiveFocus() : copyPreviewButton.forceActiveFocus() + } + + FocusScope { + id: expandedDiagnosticsLane + objectName: "expanded-diagnostics-lane" + visible: diagnosticsExpanded + Layout.preferredWidth: detailTextWidth + Layout.preferredHeight: visible ? expandedDiagnosticsLaneHeight : 0 + focus: diagnosticsExpanded + activeFocusOnTab: diagnosticsExpanded + KeyNavigation.up: secondaryDiagnosticsToggle + KeyNavigation.down: armNoNetworkPreviewButton + Keys.onUpPressed: secondaryDiagnosticsToggle.forceActiveFocus() + Keys.onDownPressed: { + if (scrollExpandedDiagnosticsLaneToDetails()) { + event.accepted = true + } else { + armNoNetworkPreviewButton.forceActiveFocus() } + } + + Rectangle { + anchors.fill: parent + radius: 16 + color: expandedDiagnosticsLane.activeFocus ? "#202B55" : "#10182E" + border.color: expandedDiagnosticsLane.activeFocus ? focusRingColor : "#39466F" + border.width: expandedDiagnosticsLane.activeFocus ? 4 : 2 + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 6 + anchors.rightMargin: 8 + implicitWidth: 48 + implicitHeight: 18 + radius: 9 + color: "#10251F" + border.color: focusRingColor + border.width: 1 + visible: expandedDiagnosticsLane.activeFocus + z: 3 Label { - Layout.preferredWidth: detailTextWidth - 24 - text: selectedMoonlightHandoffCopy - color: "#C9F0D4" - font.pixelSize: 11 - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight + anchors.centerIn: parent + text: "FOCUS" + color: focusRingColor + font.pixelSize: 9 + font.bold: true } + } - RowLayout { - objectName: "moonlight-safety-chip-row" - Layout.preferredWidth: detailTextWidth - 24 - spacing: 6 - - Rectangle { - Layout.preferredWidth: 86 - Layout.preferredHeight: 24 - radius: 12 - color: "#192842" - - Label { - anchors.centerIn: parent - text: "No launch" - color: "#E9ECFF" - font.pixelSize: 10 - font.bold: true - } + ScrollView { + id: expandedDiagnosticsScrollView + objectName: "expanded-diagnostics-scroll-view" + anchors.fill: parent + anchors.margins: 10 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + ColumnLayout { + id: expandedDiagnosticsContentColumn + width: expandedDiagnosticsLane.width - 28 + spacing: 5 + + Label { + text: "Secondary diagnostics · D-pad scroll for details" + color: "#8AFFC1" + font.pixelSize: 11 + font.bold: true + wrapMode: Text.WordWrap } - Rectangle { - id: moonlightRuntimeGateChip - objectName: "moonlight-runtime-gate-chip" - Layout.preferredWidth: 134 - Layout.preferredHeight: 24 - radius: 12 - color: moonlightHandoffRuntimeGatesClosed() ? "#173326" : "#3A2224" - - Label { - anchors.centerIn: parent - text: moonlightHandoffRuntimeGatesClosed() ? "No network/process" : "Blocked" - color: moonlightHandoffRuntimeGatesClosed() ? "#8AFFC1" : "#FFDDA8" - font.pixelSize: 10 - font.bold: true - } + Label { + id: diagnosticsPagePositionLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: expandedDiagnosticsLaneScrolledToDetails + ? "Diagnostics page 2 of 2 · lifecycle + DTO details" + : "Diagnostics page 1 of 2 · scroll for lifecycle + DTO below" + color: "#FFDDA8" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap } - Rectangle { - id: moonlightFocusChip - objectName: "moonlight-focus-chip" - Layout.fillWidth: true - Layout.preferredHeight: 24 - radius: 12 - color: "#151D39" - - Label { - anchors.centerIn: parent - text: "Focus: unproven_static" - color: "#B8C2F0" - font.pixelSize: 10 - font.bold: true - } + Label { + id: readonlyDiagnosticsLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: readOnlyBlockerDiagnostics(backendReadOnlyPreflight, selectedBackendReadOnlyScenarioLabel) + color: "#E9ECFF" + font.pixelSize: 11 + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight } - } - RowLayout { - objectName: "moonlight-readiness-row" - Layout.preferredWidth: detailTextWidth - 24 - spacing: 5 + Label { + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: readOnlyDtoParityDiagnostics(backendReadOnlyDtoParity) + color: "#C9F0D4" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } Label { - Layout.preferredWidth: 48 - text: "Checks" - color: "#7C88B8" - font.pixelSize: 9 + id: readonlyPreflightBlockersLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: "Preflight blockers: " + (backendReadOnlyPreflight.blockerCodes.length > 0 + ? backendReadOnlyPreflight.blockerCodes.join(", ") + : "backend read-only model reported no blockers") + color: "#FFDDA8" + font.pixelSize: 11 font.bold: true + wrapMode: Text.WordWrap maximumLineCount: 2 elide: Text.ElideRight } - Repeater { - model: selectedMoonlightReadinessChecks - - Rectangle { - objectName: "moonlight-readiness-chip" - Layout.preferredWidth: 72 - Layout.preferredHeight: 22 - radius: 11 - color: modelData.status === "blocked" ? "#3A2224" : "#151D39" - border.color: readinessStatusColor(modelData.status) - border.width: 1 - - Label { - anchors.centerIn: parent - text: readinessShortLabel(modelData.id, modelData.label) + " " + readinessStatusCopy(modelData.status) - color: readinessStatusColor(modelData.status) - font.pixelSize: 8 - font.bold: true - maximumLineCount: 1 - elide: Text.ElideRight - } - } + Label { + id: readonlyPublicCopyLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: backendReadOnlyPreflight.publicCopy + color: "#A8B0D8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + maximumLineCount: 4 + elide: Text.ElideRight } - } - RowLayout { - objectName: "moonlight-plan-row" - Layout.preferredWidth: detailTextWidth - 24 - spacing: 8 + Label { + id: diagnosticsPostScrollCueLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + Layout.topMargin: 8 + text: "Diagnostics page 2 of 2 · lifecycle=" + previewLifecycleReport.state + + "/no stream · DTO privacy=" + backendDiagnosticsPreview.privacyCode + color: "#FFDDA8" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap + visible: true + } Label { - Layout.fillWidth: true - text: moonlightHandoffRuntimeGatesClosed() ? "Typed argv plan" : "Review blocked" - color: "#B8C2F0" + id: lifecycleDiagnosticsPageLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: "Lifecycle page 2 · status=" + previewLifecycleReport.statusCode + + " · state=" + previewLifecycleReport.state + + " · stream not started" + color: "#8AFFC1" font.pixelSize: 10 font.bold: true - maximumLineCount: 1 + wrapMode: Text.WordWrap + } + + Label { + id: dtoDiagnosticsPageLabel + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: "DTO page 2 · preflight=" + backendPreflightPreview.statusCode + + " · blockers=" + backendPreflightPreview.blockerCodes.length + + " · diagnostics=" + backendDiagnosticsPreview.statusCode + + " · privacy=" + backendDiagnosticsPreview.privacyCode + color: "#C9F0D4" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + Label { + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: "Lifecycle · " + previewLifecycleReport.statusCode + + " · state=" + previewLifecycleReport.state + + " · preflight=" + previewLifecycleReport.dryRunPreflightRequested + + " · networkStarted=" + previewLifecycleReport.networkStarted + color: previewLifecycleReport.armed ? "#8AFFC1" : "#FFDDA8" + font.pixelSize: 10 + font.bold: previewLifecycleReport.armed + wrapMode: Text.WordWrap + maximumLineCount: 2 elide: Text.ElideRight } Label { - Layout.preferredWidth: 210 - text: moonlightHandoffRuntimeGatesClosed() ? "redacted argv · local preview only" : selectedMoonlightHandoffArgvPreview - color: "#B8C2F0" + Layout.preferredWidth: expandedDiagnosticsLane.width - 28 + text: "Operator · " + operatorAuthorizationReport.statusCode + + " · dry-run=" + operatorAuthorizationReport.dryRunAuthorized + + " · start-contract=" + operatorAuthorizationReport.startAuthorized + + " · networkStarted=" + operatorAuthorizationReport.networkStarted + color: operatorAuthorizationReport.startAuthorized ? "#8AFFC1" + : operatorAuthorizationReport.dryRunAuthorized ? "#C9F0D4" + : "#FFDDA8" font.pixelSize: 10 - horizontalAlignment: Text.AlignRight - maximumLineCount: 1 + font.bold: operatorAuthorizationReport.dryRunAuthorized || operatorAuthorizationReport.startAuthorized + wrapMode: Text.WordWrap + maximumLineCount: 2 elide: Text.ElideRight } + } + } - Label { - objectName: "moonlight-runtime-gates-line" - Layout.preferredWidth: detailTextWidth - 24 - text: moonlightHandoffRuntimeGatesClosed() - ? "Runtime locked: network · process · Moonlight · host off" - : "Runtime gate failed — review blocked" - color: "#FFDDA8" - font.pixelSize: 10 - font.bold: true - maximumLineCount: 1 - elide: Text.ElideRight + Label { + id: expandedDiagnosticsPostScrollOverlay + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 12 + z: 2 + text: "Diagnostics page 2 of 2 · lifecycle=" + previewLifecycleReport.state + + "/no stream · DTO privacy=" + backendDiagnosticsPreview.privacyCode + color: "#FFDDA8" + font.pixelSize: 10 + font.bold: true + wrapMode: Text.WordWrap + visible: expandedDiagnosticsLaneScrolledToDetails + + background: Rectangle { + color: "#10182E" + opacity: 0.94 + radius: 8 + border.color: "#FFDDA8" + border.width: 1 } } } + Label { + Layout.preferredWidth: detailTextWidth + text: "Secondary diagnostics stay collapsed on first paint." + color: "#7C88B8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + visible: false + } + RowLayout { - objectName: "copy-preview-action-row" Layout.preferredWidth: detailTextWidth - spacing: 10 + spacing: 4 + visible: false Button { - id: copyPreviewButton - objectName: launchPreviewCopyAction.id - Layout.preferredWidth: 184 - Layout.preferredHeight: 30 - text: launchPreviewCopyAction.label - enabled: launchPreviewCopyAction.enabled + id: armNoNetworkPreviewButton + objectName: "arm-no-network-preview" + Layout.preferredWidth: 70 + text: "Arm preview" focusPolicy: Qt.StrongFocus activeFocusOnTab: true - KeyNavigation.up: launchCtaPlaceholder - KeyNavigation.down: hostDetailPanel - Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() - Keys.onDownPressed: hostDetailPanel.forceActiveFocus() - Keys.onLeftPressed: focusSelectedLibraryItem() - Keys.onReturnPressed: activateLaunchPreviewCopyFromController() - Keys.onEnterPressed: activateLaunchPreviewCopyFromController() - Keys.onSpacePressed: activateLaunchPreviewCopyFromController() - onClicked: activateLaunchPreviewCopyFromController() + onClicked: armNoNetworkPreviewFromControlSurface() + Keys.onReturnPressed: armNoNetworkPreviewFromControlSurface() + Keys.onEnterPressed: armNoNetworkPreviewFromControlSurface() + Keys.onSpacePressed: armNoNetworkPreviewFromControlSurface() } - Label { - Layout.fillWidth: true - text: "Copy locally — no launch" - color: "#8AFFC1" - font.pixelSize: 12 - font.bold: true - wrapMode: Text.WordWrap + Button { + id: stopPreviewButton + objectName: "stop-preview" + Layout.preferredWidth: 42 + text: "Stop" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: stopPreviewFromControlSurface() + Keys.onReturnPressed: stopPreviewFromControlSurface() + Keys.onEnterPressed: stopPreviewFromControlSurface() + Keys.onSpacePressed: stopPreviewFromControlSurface() + } + + Button { + id: guardedHostNetworkStartButton + objectName: "guarded-host-network-start" + Layout.preferredWidth: 74 + text: "Start blocked" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestGuardedHostNetworkStartFromControlSurface() + Keys.onReturnPressed: requestGuardedHostNetworkStartFromControlSurface() + Keys.onEnterPressed: requestGuardedHostNetworkStartFromControlSurface() + Keys.onSpacePressed: requestGuardedHostNetworkStartFromControlSurface() + } + + Button { + objectName: "host-start-dry-run-preflight-primary" + Layout.preferredWidth: 62 + text: "Preflight" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestHostStartDryRunPreflightFromControlSurface() + Keys.onReturnPressed: requestHostStartDryRunPreflightFromControlSurface() + Keys.onEnterPressed: requestHostStartDryRunPreflightFromControlSurface() + Keys.onSpacePressed: requestHostStartDryRunPreflightFromControlSurface() + } + + Button { + id: backendPreflightDtoPreviewButton + objectName: "backend-preflight-dto-preview" + Layout.preferredWidth: 46 + text: "DTO" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestBackendPreflightPreviewFromControlSurface() + Keys.onReturnPressed: requestBackendPreflightPreviewFromControlSurface() + Keys.onEnterPressed: requestBackendPreflightPreviewFromControlSurface() + Keys.onSpacePressed: requestBackendPreflightPreviewFromControlSurface() + } + + Button { + id: backendDiagnosticsDtoPreviewButton + objectName: "backend-diagnostics-dto-preview" + Layout.preferredWidth: 44 + text: "Diag" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestBackendDiagnosticsPreviewFromControlSurface() + Keys.onReturnPressed: requestBackendDiagnosticsPreviewFromControlSurface() + Keys.onEnterPressed: requestBackendDiagnosticsPreviewFromControlSurface() + Keys.onSpacePressed: requestBackendDiagnosticsPreviewFromControlSurface() } } + RowLayout { + Layout.preferredWidth: detailTextWidth + spacing: 8 + visible: false + + Button { + objectName: "authorize-operator-dry-run" + text: "Authorize dry-run" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: authorizeOperatorDryRunFromControlSurface() + Keys.onReturnPressed: authorizeOperatorDryRunFromControlSurface() + Keys.onEnterPressed: authorizeOperatorDryRunFromControlSurface() + Keys.onSpacePressed: authorizeOperatorDryRunFromControlSurface() + } + + Button { + objectName: "operator-dry-run-contract" + text: "Dry-run contract" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestOperatorAuthorizedDryRunFromControlSurface() + Keys.onReturnPressed: requestOperatorAuthorizedDryRunFromControlSurface() + Keys.onEnterPressed: requestOperatorAuthorizedDryRunFromControlSurface() + Keys.onSpacePressed: requestOperatorAuthorizedDryRunFromControlSurface() + } + + Button { + objectName: "host-start-dry-run-preflight" + text: "Host start preflight" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestHostStartDryRunPreflightFromControlSurface() + Keys.onReturnPressed: requestHostStartDryRunPreflightFromControlSurface() + Keys.onEnterPressed: requestHostStartDryRunPreflightFromControlSurface() + Keys.onSpacePressed: requestHostStartDryRunPreflightFromControlSurface() + } + + Button { + objectName: "authorize-operator-start-contract" + text: "Authorize start contract" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: authorizeOperatorStartFromControlSurface() + Keys.onReturnPressed: authorizeOperatorStartFromControlSurface() + Keys.onEnterPressed: authorizeOperatorStartFromControlSurface() + Keys.onSpacePressed: authorizeOperatorStartFromControlSurface() + } + + Button { + objectName: "operator-start-contract-status" + text: "Start contract status" + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + onClicked: requestOperatorAuthorizedHostNetworkStartFromControlSurface() + Keys.onReturnPressed: requestOperatorAuthorizedHostNetworkStartFromControlSurface() + Keys.onEnterPressed: requestOperatorAuthorizedHostNetworkStartFromControlSurface() + Keys.onSpacePressed: requestOperatorAuthorizedHostNetworkStartFromControlSurface() + } + } + + DeckVaapiPreviewSurface { + objectName: "nova-product-preview-surface" + Layout.preferredWidth: detailTextWidth + Layout.preferredHeight: visible ? 96 : 0 + visible: novaPresenterReadiness.ready + opacity: visible ? 1.0 : 0.0 + } + + Label { + Layout.preferredWidth: detailTextWidth + text: "Exact preview details stay behind Copy preview details." + color: "#7C88B8" + font.pixelSize: 12 + wrapMode: Text.WordWrap + visible: false + } + Label { id: copyStatusLabel Layout.preferredWidth: detailTextWidth - Layout.preferredHeight: visible ? 14 : 0 - text: "" - visible: text.length > 0 + text: launchPreviewCopyAction.idleStatusLabel color: "#FFDDA8" - font.pixelSize: 9 - wrapMode: Text.NoWrap - maximumLineCount: 1 - elide: Text.ElideRight + font.pixelSize: 13 + wrapMode: Text.WordWrap + visible: false } } } diff --git a/clients/deck/scripts/deck_frontend_smoke.py b/clients/deck/scripts/deck_frontend_smoke.py new file mode 100644 index 00000000..9f5a4a01 --- /dev/null +++ b/clients/deck/scripts/deck_frontend_smoke.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""Visible Deck Game Mode frontend smoke route for the Nova Deck shell. + +After source sync this route runs inside a rootless Podman container with network +removed. It builds the Deck Qt shell, launches the real QML frontend on the +Game Mode Wayland socket, asks the app to capture its own 1280x800 frame, and +pulls review artifacts back to the workstation. +""" +from __future__ import annotations + +import argparse +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Iterable, Sequence + +DEFAULT_DECK = "deck@" + "10.0." + "0.39" +DEFAULT_REMOTE_SOURCE = PurePosixPath("/home/deck/nova-frontend-smoke-src") +DEFAULT_ARTIFACT_DIR = PurePosixPath("/home/deck/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts") +DEFAULT_IMAGE = "localhost/nova-t24-arch-qt-buildtools" +DEFAULT_BUILD_DIR = "build/deck-frontend-smoke" + +SCRIPT_ROOT = Path(__file__).resolve().parent +DECK_ROOT = SCRIPT_ROOT.parent +REPO_ROOT = DECK_ROOT.parents[1] + +FORBIDDEN_ROUTE_TOKENS = [ + "Li" + "StartConnection", + "Host" + "Store", + "Moon" + "light", + "Sun" + "shine", + "start" + "Stream", + "launch" + "Game", + "pair" + "ing", + "access" + "Token", + "refresh" + "Token", + "auth" + "Token", + "pass" + "word", +] +ROUTE_SOURCE_FILES = [SCRIPT_ROOT / "deck_frontend_smoke.py"] + + +def q(value: object) -> str: + return shlex.quote(str(value)) + + +def redact_private_addresses(text: str) -> str: + redacted = re.sub( + r"\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b", + "", + text, + ) + redacted = re.sub( + r"\b([A-Za-z0-9_.-]+@)([A-Za-z0-9-]*deck[A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9-]+\.)+(?:local|lan|home|internal))(?=[:\s/'\"]|$)", + r"\1", + redacted, + flags=re.IGNORECASE, + ) + return re.sub( + r"(?", + redacted, + flags=re.IGNORECASE, + ) + + +def redact_command(command: Sequence[str]) -> list[str]: + return [redact_private_addresses(part) for part in command] + + +def format_redacted_command(command: Sequence[str]) -> str: + return " ".join(q(part) for part in redact_command(command)) + + +def repo_root_from_source(source: Path | None) -> Path: + if source is not None: + return source.expanduser().resolve() + return REPO_ROOT + + +def path_has_artifact_component(path: Path | PurePosixPath) -> bool: + return any("artifact" in part.lower() for part in path.parts) + + +def validate_remote_artifact_dir(source_dir: PurePosixPath, artifact_dir: PurePosixPath) -> None: + if ".." in source_dir.parts or ".." in artifact_dir.parts: + raise ValueError("remote artifact and source directories must not contain parent traversal") + if not source_dir.is_absolute() or not artifact_dir.is_absolute(): + raise ValueError("remote artifact and source directories must be absolute") + if artifact_dir == source_dir: + raise ValueError("remote artifact directory must not be the remote source directory") + safe_root = source_dir / "build" + try: + artifact_dir.relative_to(safe_root) + except ValueError as exc: + raise ValueError("remote artifact directory must stay under the remote source build directory") from exc + if not path_has_artifact_component(artifact_dir): + raise ValueError("remote artifact directory must include an artifact-named path component") + + +def validate_local_artifact_dir(local_artifacts: Path) -> None: + resolved = local_artifacts.expanduser().resolve() + forbidden_roots = {Path('/'), Path.home().resolve(), REPO_ROOT.resolve()} + if resolved in forbidden_roots: + raise ValueError("local artifact directory refuses broad root/home/repo cleanup") + safe_root = REPO_ROOT.resolve() / "build" + try: + resolved.relative_to(safe_root) + except ValueError as exc: + raise ValueError("local artifact directory must stay under the repo build directory") from exc + if not path_has_artifact_component(resolved): + raise ValueError("local artifact directory must include an artifact-named path component") + + +def build_container_shell( + *, + source_dir: PurePosixPath, + artifact_dir: PurePosixPath, + build_dir: str = DEFAULT_BUILD_DIR, +) -> str: + validate_remote_artifact_dir(source_dir, artifact_dir) + environment_summary = artifact_dir / "environment-summary.txt" + ui_launch_log = artifact_dir / "ui-launch.log" + qml_runtime_log = artifact_dir / "qml-runtime.log" + smoke_summary = artifact_dir / "smoke-summary.txt" + backend_dto_smoke = artifact_dir / "backend-dto-interaction-smoke.txt" + backend_readonly_matrix_smoke = artifact_dir / "backend-readonly-state-matrix-smoke.txt" + frame_capture = artifact_dir / "frontend-frame-capture.png" + expanded_frame_smoke = artifact_dir / "expanded-diagnostics-frame-smoke.txt" + expanded_frame_capture = artifact_dir / "frontend-expanded-diagnostics-capture.png" + binary = PurePosixPath(build_dir) / "nova-deck" + return " && ".join( + [ + "set -euo pipefail", + f"cd {q(source_dir)}", + f"rm -rf {q(artifact_dir)} && mkdir -p {q(artifact_dir)}", + "printf '%s\n' " + "\"Nova Deck frontend smoke\" " + "\"target_window=1280x800\" " + "\"network=none\" " + "\"QT_QPA_PLATFORM=$QT_QPA_PLATFORM\" " + "\"WAYLAND_DISPLAY=$WAYLAND_DISPLAY\" " + "\"QSG_RHI_BACKEND=$QSG_RHI_BACKEND\" " + "\"LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME\" " + f"> {q(environment_summary)}", + f"cmake -S clients/deck -B {q(build_dir)} -G Ninja -DNOVA_DECK_BUILD_QT_SHELL=ON", + f"cmake --build {q(build_dir)}", + f"printf '%s\n' 'launching nova-deck visible frontend smoke at 1280x800' > {q(ui_launch_log)}", + "QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0 QSG_RHI_BACKEND=opengl " + "LIBVA_DRIVER_NAME=radeonsi NOVA_DECK_FRONTEND_SMOKE=1 " + f"{q(binary)} --frontend-smoke-exit-after-ms 2500 " + "--frontend-smoke-readonly-state lab-gated " + f"--frontend-smoke-backend-dto-interactions {q(backend_dto_smoke)} " + f"--frontend-smoke-readonly-state-matrix {q(backend_readonly_matrix_smoke)} " + f"--frontend-smoke-capture {q(frame_capture)} " + f"--frontend-smoke-expanded-diagnostics-frame {q(expanded_frame_smoke)} " + f"--frontend-smoke-expanded-diagnostics-capture {q(expanded_frame_capture)} " + f">> {q(ui_launch_log)} 2> {q(qml_runtime_log)}", + f"test -s {q(backend_dto_smoke)}", + f"test -s {q(backend_readonly_matrix_smoke)}", + f"test -s {q(expanded_frame_smoke)}", + f"grep -E '^invoked=true$' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=empty .*status=backend-read-only-preflight-blocked .*blockers=.*missing-host' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=offline .*blockers=.*host-unreachable' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=unpaired .*blockers=.*{'pair' + 'ing-required'}' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=library-unavailable .*blockers=.*library-unavailable' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=lab-gated .*blockers=.*lab-gate-disabled' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*backendPowerStarted=false' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*dtoContract=backend-owned-read-only-dto-v1' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*dtoPrivacy=redacted-public-dto' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*dtoReadiness=dto-parity-ready' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'primary=Host offline. Reconnect or pick another host.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'primary=Pair this host before launch preview.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'primary=Library unavailable. Try again when the read-only snapshot is back.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'primary=Launch blocked by lab gate.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateHeadline=Product state: Host offline' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateHeadline=Product state: Library unavailable' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateHeadline=Product state: Lab gate locked' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateAction=Reconnect the host or choose another backend-owned snapshot.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateSafety=Backend power stays off; no retry or network probe runs.' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateProvenance=dto-player-state/backend-owned/redacted-public' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'stateFocusOrder=state-card-copy-diagnostics' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'diagnostics=Matrix diagnostic:' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'dtoParity=DTO parity:' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*collapsedFirstPaint=true' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*expansionToggle=secondary-diagnostics-toggle' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*controllerReachable=true' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^matrix_scenario=.*expandedVisible=true' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'expandedDiagnosticsCopy=Matrix diagnostic:' {q(backend_readonly_matrix_smoke)}", + f"grep -F 'expandedDtoParityCopy=DTO parity:' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^invoked=true$' {q(expanded_frame_smoke)}", + f"grep -E '^liveExpandedBy=keyboard-controller-toggle$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedFrameFocusTarget=secondary-diagnostics-toggle$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsLaneFocusTarget=expanded-diagnostics-lane$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsLaneReadable=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDensityRowsPaged=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPageAffordanceVisible=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPageAffordancePosition=before-blocker-copy$' {q(expanded_frame_smoke)}", + f"grep -F 'expandedDiagnosticsPageAffordanceText=Diagnostics page 1 of 2' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsScrollNavigationMoved=true$' {q(expanded_frame_smoke)}", + f"grep -F 'expandedDiagnosticsPostScrollCue=Diagnostics page 2 of 2' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPostScrollCueContrast=13[.]56:1$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPostScrollCueSpacing=separate-row-after-blocker-copy$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPostScrollCueOverlapsBlocker=false$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPostScrollTarget=lifecycle-dto-details$' {q(expanded_frame_smoke)}", + f"grep -F 'expandedDiagnosticsFocusAffordance=4px focus ring + active focus badge' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsPage2Readable=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedDiagnosticsLaneHeight=132$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedFrameReadable=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedFrameSanitized=true$' {q(expanded_frame_smoke)}", + f"grep -E '^expandedFrameFirstPaintCrowding=false$' {q(expanded_frame_smoke)}", + f"grep -F 'expandedDiagnosticsCopy=Matrix diagnostic:' {q(expanded_frame_smoke)}", + f"grep -F 'expandedDtoParityCopy=DTO parity:' {q(expanded_frame_smoke)}", + f"awk -F'primary=' '/^matrix_scenario=/ {{ split($2, a, \" stateHeadline=\"); if (length(a[1]) > 88) exit 1 }}' {q(backend_readonly_matrix_smoke)}", + f"grep -E '^invoked=true$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_button=backend-preflight-dto-preview$' {q(backend_dto_smoke)}", + f"grep -E '^diagnostics_button=backend-diagnostics-dto-preview$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_status=backend-preflight-blocked$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_blockers=.*lab-gate-disabled' {q(backend_dto_smoke)}", + f"grep -E '^preflight_launch_dry_run_allowed=false$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_stream_allowed=false$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_backend_power_started=false$' {q(backend_dto_smoke)}", + f"grep -E '^preflight_public_copy=.*Deck launch preflight' {q(backend_dto_smoke)}", + f"grep -E '^dto_contract=backend-owned-read-only-dto-v1$' {q(backend_dto_smoke)}", + f"grep -E '^dto_owner=backend-owned-read-only-model$' {q(backend_dto_smoke)}", + f"grep -E '^dto_privacy=redacted-public-dto$' {q(backend_dto_smoke)}", + f"grep -E '^dto_readiness=dto-parity-ready$' {q(backend_dto_smoke)}", + f"grep -F 'dto_collapsed_summary=Backend-owned DTO parity' {q(backend_dto_smoke)}", + f"grep -E '^dto_player_state_provenance=dto-player-state/backend-owned/redacted-public$' {q(backend_dto_smoke)}", + f"grep -E '^dto_player_state_focus_order=state-card-copy-diagnostics$' {q(backend_dto_smoke)}", + f"grep -F 'dto_player_state_focus_order_copy=Focus order: state card → Copy plan → Show diagnostics' {q(backend_dto_smoke)}", + f"grep -E '^diagnostics_status=backend-diagnostics-ready$' {q(backend_dto_smoke)}", + f"grep -E '^diagnostics_privacy=redacted-public-dto$' {q(backend_dto_smoke)}", + f"grep -E '^diagnostics_copy=.*privacy=redacted' {q(backend_dto_smoke)}", + f"! grep -E {q(r'([0-9]{1,3}[.]){3}[0-9]{1,3}|BEGIN [A-Z ]+|raw[A-Z]')} {q(backend_dto_smoke)}", + f"! grep -E {q(r'([0-9]{1,3}[.]){3}[0-9]{1,3}|BEGIN [A-Z ]+|raw[A-Z]')} {q(backend_readonly_matrix_smoke)}", + f"! grep -E {q(r'([0-9]{1,3}[.]){3}[0-9]{1,3}|BEGIN [A-Z ]+|raw[A-Z]')} {q(expanded_frame_smoke)}", + "{ " + "printf '%s\n' 'Nova Deck frontend smoke summary'; " + "printf '%s\n' 'window=1280x800'; " + "printf '%s\n' 'offline=true'; " + "printf '%s\n' 'host_library_visible=review-frame-capture'; " + "printf '%s\n' 'lifecycle_contract_visible=review-frame-capture'; " + "printf '%s\n' 'networkStartAllowed=false'; " + "printf '%s\n' 'networkStarted=false'; " + "printf '%s\n' 'backend_dto_interaction_smoke=backend-dto-interaction-smoke.txt'; " + "printf '%s\n' 'backend_readonly_state_matrix_smoke=backend-readonly-state-matrix-smoke.txt'; " + "printf '%s\n' 'matrix_visual_path=lab-gated'; " + "printf '%s\n' 'matrix_artifact_states=empty,offline,unpaired,library-unavailable,lab-gated'; " + "printf '%s\n' 'diagnostics_expansion=controller-reachable'; " + "printf '%s\n' 'diagnostics_focus_lane=expanded-diagnostics-lane'; " + "printf '%s\n' 'diagnostics_focus_affordance=4px-ring-active-badge'; " + "printf '%s\n' 'diagnostics_cue_contrast=13.56:1'; " + "printf '%s\n' 'diagnostics_density=lane-paged-breathing-room'; " + "printf '%s\n' 'diagnostics_page_position=page-1-of-2-scroll-affordance'; " + "printf '%s\n' 'diagnostics_page2_readability=lifecycle-dto-readable'; " + "printf '%s\n' 'diagnostics_scroll_navigation=page-2-lifecycle-dto-proof-no-crowding'; " + "printf '%s\n' 'diagnostics_first_paint=collapsed'; " + "printf '%s\n' 'product_readiness_gate=deck-diagnostics-expanded-lane-v1'; " + "printf '%s\n' 'product_readiness_verdict=pass'; " + "printf '%s\n' 'product_readiness_next=backend-fed-read-only-dto-parity'; " + "printf '%s\n' 'player_flow_gate=deck-player-flow-product-shell-v1'; " + "printf '%s\n' 'first_paint_hierarchy=host-game-launch'; " + "printf '%s\n' 'selected_game_readability=large-title-selected-badge'; " + "printf '%s\n' 'launch_cta=copy-safe-plan'; " + "printf '%s\n' 'blocked_state_copy=player-safe-no-backend-power'; " + "printf '%s\n' 'product_state_gate=deck-product-state-matrix-v1'; " + "printf '%s\n' 'product_state_visuals=player-facing-state-card'; " + "printf '%s\n' 'product_state_focus=state-card-copy-diagnostics'; " + "printf '%s\n' 'android_touched_guard=app-unchanged'; " + "printf '%s\n' 'dto_parity_contract=backend-owned-read-only-dto-v1'; " + "printf '%s\n' 'dto_parity_privacy=redacted-public-dto'; " + "printf '%s\n' 'dto_parity_readiness=dto-parity-ready'; " + "printf '%s\n' 'dto_parity_verdict=pass'; " + "printf '%s\n' 'dto_player_state_provenance=dto-player-state/backend-owned/redacted-public'; " + "printf '%s\n' 'dto_player_state_focus_order=state-card-copy-diagnostics'; " + "printf '%s\n' 'dto_player_state_focus_order_copy=Focus order: state card → Copy plan → Show diagnostics'; " + "printf '%s\n' 'expanded_frame_smoke=expanded-diagnostics-frame-smoke.txt'; " + f"test -s {q(frame_capture)} && printf '%s\n' 'frame_capture=frontend-frame-capture.png' || printf '%s\n' 'frame_capture=missing'; " + f"test -s {q(expanded_frame_capture)} && printf '%s\n' 'expanded_frame_capture=frontend-expanded-diagnostics-capture.png' || printf '%s\n' 'expanded_frame_capture=missing'; " + f"grep -E 'Nova Deck product preview fixture pump|frontend smoke capture|backend-dto-interaction-smoke artifact' {q(qml_runtime_log)} || true; " + f"}} > {q(smoke_summary)}", + ] + ) + + +def build_podman_smoke_command( + *, + source_dir: PurePosixPath = DEFAULT_REMOTE_SOURCE, + artifact_dir: PurePosixPath = DEFAULT_ARTIFACT_DIR, + image: str = DEFAULT_IMAGE, + build_dir: str = DEFAULT_BUILD_DIR, +) -> list[str]: + shell = build_container_shell(source_dir=source_dir, artifact_dir=artifact_dir, build_dir=build_dir) + return [ + "podman", + "run", + "--rm", + "--ipc=host", + "--network=none", + "--device=/dev/dri", + "--group-add=keep-groups", + "-e", + "XDG_RUNTIME_DIR=/run/user/1000", + "-e", + "QT_QPA_PLATFORM=wayland", + "-e", + "WAYLAND_DISPLAY=gamescope-0", + "-e", + "QSG_RHI_BACKEND=opengl", + "-e", + "LIBVA_DRIVER_NAME=radeonsi", + "-v", + "/run/user/1000:/run/user/1000", + "-v", + "/dev/dri:/dev/dri", + "-v", + f"{source_dir}:{source_dir}", + "-w", + str(source_dir), + image, + "bash", + "-lc", + shell, + ] + + +def build_ssh_smoke_command(deck: str, podman_command: Sequence[str]) -> list[str]: + remote = " ".join(q(part) for part in podman_command) + return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", deck, remote] + + +def build_rsync_command(source: Path, deck: str, remote_source: PurePosixPath) -> list[str]: + return [ + "rsync", + "-a", + "--delete", + "--exclude", + "/.git/", + "--exclude", + "/build/", + "--exclude", + "/.gradle/", + "--exclude", + "/local.properties", + "--exclude", + ".env*", + "--exclude", + "id_*", + "--exclude", + "*.pem", + f"{source}/", + f"{deck}:{remote_source}/", + ] + + +def build_artifact_pull_command(deck: str, remote_artifact_dir: PurePosixPath, local_artifact_dir: Path) -> list[str]: + return ["rsync", "-a", f"{deck}:{remote_artifact_dir}/", f"{local_artifact_dir}/"] + + +def find_forbidden_route_tokens(files: Iterable[Path] = ROUTE_SOURCE_FILES) -> list[str]: + findings: list[str] = [] + for path in files: + text = path.read_text(encoding="utf-8") + lowered = text.lower() + for token in FORBIDDEN_ROUTE_TOKENS: + if token.lower() in lowered: + findings.append(f"{path.relative_to(REPO_ROOT)}: {token}") + return findings + + +def git_has_path_changes(repo_root: Path, pathspec: str) -> bool: + completed = subprocess.run( + ["git", "status", "--porcelain", "--", pathspec], + cwd=repo_root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or f"git status failed for {pathspec}") + return bool(completed.stdout.strip()) + + +def run_command(command: Sequence[str], *, log_path: Path | None = None) -> None: + if log_path: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8") as log: + redacted_command = redact_command(command) + log.write("$ " + " ".join(q(part) for part in redacted_command) + "\n") + log.flush() + completed = subprocess.run( + list(command), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + redacted_output = redact_private_addresses(completed.stdout or "") + log.write(redacted_output) + if completed.returncode != 0: + raise subprocess.CalledProcessError(completed.returncode, redacted_command, output=redacted_output) + else: + subprocess.run(list(command), check=True) + + +def reset_local_artifacts(local_artifacts: Path) -> None: + validate_local_artifact_dir(local_artifacts) + if local_artifacts.exists(): + shutil.rmtree(local_artifacts) + local_artifacts.mkdir(parents=True, exist_ok=True) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--deck", default=DEFAULT_DECK, help="SSH target, default deck") + parser.add_argument("--source", type=Path, default=REPO_ROOT, help="local repo/source root to sync") + parser.add_argument("--remote-source", default=str(DEFAULT_REMOTE_SOURCE), help="Deck source directory") + parser.add_argument("--remote-artifacts", default=str(DEFAULT_ARTIFACT_DIR), help="Deck artifact directory") + parser.add_argument("--local-artifacts", type=Path, default=REPO_ROOT / "build" / "deck-frontend-smoke-artifacts") + parser.add_argument("--image", default=DEFAULT_IMAGE) + parser.add_argument("--skip-sync", action="store_true", help="expect --remote-source already exists on Deck") + parser.add_argument("--dry-run", action="store_true", help="print commands without executing") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] = sys.argv[1:]) -> int: + args = parse_args(argv) + source = repo_root_from_source(args.source) + remote_source = PurePosixPath(args.remote_source) + remote_artifacts = PurePosixPath(args.remote_artifacts) + local_artifacts = args.local_artifacts.expanduser().resolve() + + guardrails = find_forbidden_route_tokens() + if guardrails: + print("refusing frontend smoke route with forbidden Deck route tokens:", file=sys.stderr) + for finding in guardrails: + print(f" {finding}", file=sys.stderr) + return 2 + + if git_has_path_changes(REPO_ROOT, "app"): + print("refusing frontend smoke route because app/ Android tree has uncommitted changes", file=sys.stderr) + return 3 + + sync_command = build_rsync_command(source, args.deck, remote_source) + podman_command = build_podman_smoke_command( + source_dir=remote_source, + artifact_dir=remote_artifacts, + image=args.image, + ) + ssh_command = build_ssh_smoke_command(args.deck, podman_command) + pull_command = build_artifact_pull_command(args.deck, remote_artifacts, local_artifacts) + + if args.dry_run: + if not args.skip_sync: + print("SYNC:", format_redacted_command(sync_command)) + print("VALIDATE:", format_redacted_command(ssh_command)) + print("PULL_ARTIFACTS:", format_redacted_command(pull_command)) + return 0 + + reset_local_artifacts(local_artifacts) + if not args.skip_sync: + run_command(sync_command, log_path=local_artifacts / "rsync-source.log") + run_command(ssh_command, log_path=local_artifacts / "deck-frontend-smoke.log") + run_command(pull_command, log_path=local_artifacts / "rsync-artifacts.log") + print(f"Deck frontend smoke artifacts copied to {local_artifacts}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clients/deck/scripts/deck_t31_podman_validation.py b/clients/deck/scripts/deck_t31_podman_validation.py new file mode 100644 index 00000000..70aa92d1 --- /dev/null +++ b/clients/deck/scripts/deck_t31_podman_validation.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Repeatable Deck-side rootless Podman validation route for Nova Deck preview smokes. + +The route is intentionally local/offline once source is present on the Steam Deck: +it builds the checked-out source inside the known Deck Arch/Qt buildtools image, +runs CTest, then runs the product QSG smoke against the live Game Mode gamescope +Wayland socket. It does not invoke remote-play startup, endpoint scanning, secret handling, +Polaris backend launch flows, or fake stream pixels. +""" +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Iterable, Sequence + +DEFAULT_DECK = "deck@10.0.0.39" +DEFAULT_REMOTE_SOURCE = PurePosixPath("/home/deck/nova-t31-src") +DEFAULT_ARTIFACT_DIR = PurePosixPath("/home/deck/nova-t31-src/build/deck-t31-artifacts") +DEFAULT_IMAGE = "localhost/nova-t24-arch-qt-buildtools" +DEFAULT_BUILD_DIR = "build/deck-t31" +DEFAULT_ORACLE = "deck_t32_preview_pump_oracle.py" + +SCRIPT_ROOT = Path(__file__).resolve().parent +DECK_ROOT = SCRIPT_ROOT.parent +REPO_ROOT = DECK_ROOT.parents[1] + +# Build the words from fragments so the guardrail scanner does not flag the +# scanner itself. These are route boundaries, not a denylist for the whole repo. +FORBIDDEN_ROUTE_TOKENS = [ + "Li" + "StartConnection", + "Moon" + "light", + "Sun" + "shine", + "Host" + "Store", + "start" + "Stream", + "launch" + "Game", +] +ROUTE_SOURCE_FILES = [ + SCRIPT_ROOT / "deck_t31_podman_validation.py", +] + + +def q(value: object) -> str: + return shlex.quote(str(value)) + + +def repo_root_from_source(source: Path | None) -> Path: + if source is not None: + return source.expanduser().resolve() + return REPO_ROOT + + +def build_container_shell( + *, + source_dir: PurePosixPath, + artifact_dir: PurePosixPath, + build_dir: str = DEFAULT_BUILD_DIR, +) -> str: + ctest_log = artifact_dir / "ctest.log" + qsg_log = artifact_dir / "qsg-gamescope-smoke.log" + env_log = artifact_dir / "runtime-env.log" + return " && ".join( + [ + "set -euo pipefail", + f"cd {q(source_dir)}", + f"mkdir -p {q(artifact_dir)}", + "printf '%s\n' " + "\"QT_QPA_PLATFORM=$QT_QPA_PLATFORM\" " + "\"WAYLAND_DISPLAY=$WAYLAND_DISPLAY\" " + "\"QSG_RHI_BACKEND=$QSG_RHI_BACKEND\" " + "\"LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME\" " + f"> {q(env_log)}", + f"cmake -S clients/deck -B {q(build_dir)} -G Ninja -DNOVA_DECK_BUILD_QT_SHELL=ON", + f"cmake --build {q(build_dir)}", + f"ctest --test-dir {q(build_dir)} --output-on-failure 2>&1 | tee {q(ctest_log)}", + f"(cp -f {q(build_dir)}/Testing/Temporary/LastTest.log {q(artifact_dir / 'LastTest.log')} || true)", + "QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0 QSG_RHI_BACKEND=opengl " + "LIBVA_DRIVER_NAME=radeonsi " + f"{q(PurePosixPath(build_dir) / 'nova_deck_qsg_render_node_scenegraph_smoke')} " + f"2>&1 | tee {q(qsg_log)}", + ] + ) + + +def build_podman_validation_command( + *, + source_dir: PurePosixPath = DEFAULT_REMOTE_SOURCE, + artifact_dir: PurePosixPath = DEFAULT_ARTIFACT_DIR, + image: str = DEFAULT_IMAGE, + build_dir: str = DEFAULT_BUILD_DIR, +) -> list[str]: + shell = build_container_shell(source_dir=source_dir, artifact_dir=artifact_dir, build_dir=build_dir) + return [ + "podman", + "run", + "--rm", + "--ipc=host", + "--network=none", + "--device=/dev/dri", + "--group-add=keep-groups", + "-e", + "XDG_RUNTIME_DIR=/run/user/1000", + "-e", + "QT_QPA_PLATFORM=wayland", + "-e", + "WAYLAND_DISPLAY=gamescope-0", + "-e", + "QSG_RHI_BACKEND=opengl", + "-e", + "LIBVA_DRIVER_NAME=radeonsi", + "-v", + "/run/user/1000:/run/user/1000", + "-v", + "/dev/dri:/dev/dri", + "-v", + f"{source_dir}:{source_dir}", + "-w", + str(source_dir), + image, + "bash", + "-lc", + shell, + ] + + +def build_ssh_validation_command(deck: str, podman_command: Sequence[str]) -> list[str]: + remote = " ".join(q(part) for part in podman_command) + return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", deck, remote] + + +def build_rsync_command(source: Path, deck: str, remote_source: PurePosixPath) -> list[str]: + return [ + "rsync", + "-a", + "--delete", + "--exclude", + "/.git/", + "--exclude", + "/build/", + "--exclude", + "/.gradle/", + "--exclude", + "/local.properties", + "--exclude", + ".env*", + "--exclude", + "id_*", + "--exclude", + "*.pem", + f"{source}/", + f"{deck}:{remote_source}/", + ] + + +def build_artifact_pull_command(deck: str, remote_artifact_dir: PurePosixPath, local_artifact_dir: Path) -> list[str]: + return ["rsync", "-a", f"{deck}:{remote_artifact_dir}/", f"{local_artifact_dir}/"] + + +def build_oracle_command(local_artifact_dir: Path) -> list[str]: + return [sys.executable, str(SCRIPT_ROOT / DEFAULT_ORACLE), "--artifacts", str(local_artifact_dir)] + + +def find_forbidden_route_tokens(files: Iterable[Path] = ROUTE_SOURCE_FILES) -> list[str]: + findings: list[str] = [] + for path in files: + text = path.read_text(encoding="utf-8") + lowered = text.lower() + for token in FORBIDDEN_ROUTE_TOKENS: + if token.lower() in lowered: + findings.append(f"{path.relative_to(REPO_ROOT)}: {token}") + return findings + + +def run_command(command: Sequence[str], *, log_path: Path | None = None) -> None: + if log_path: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8") as log: + log.write("$ " + " ".join(q(part) for part in command) + "\n") + log.flush() + subprocess.run(list(command), check=True, stdout=log, stderr=subprocess.STDOUT, text=True) + else: + subprocess.run(list(command), check=True) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--deck", default=DEFAULT_DECK, help="SSH target, default deck@10.0.0.39") + parser.add_argument("--source", type=Path, default=REPO_ROOT, help="local repo/source root to sync") + parser.add_argument("--remote-source", default=str(DEFAULT_REMOTE_SOURCE), help="Deck source directory") + parser.add_argument("--remote-artifacts", default=str(DEFAULT_ARTIFACT_DIR), help="Deck artifact directory") + parser.add_argument("--local-artifacts", type=Path, default=REPO_ROOT / "build" / "deck-t31-artifacts") + parser.add_argument("--image", default=DEFAULT_IMAGE) + parser.add_argument("--skip-sync", action="store_true", help="expect --remote-source already exists on Deck") + parser.add_argument("--skip-oracle", action="store_true", help="pull artifacts without running the T32 preview pump oracle") + parser.add_argument("--dry-run", action="store_true", help="print commands without executing") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] = sys.argv[1:]) -> int: + args = parse_args(argv) + source = repo_root_from_source(args.source) + remote_source = PurePosixPath(args.remote_source) + remote_artifacts = PurePosixPath(args.remote_artifacts) + local_artifacts = args.local_artifacts.expanduser().resolve() + + guardrails = find_forbidden_route_tokens() + if guardrails: + print("refusing route with forbidden Deck T31 tokens:", file=sys.stderr) + for finding in guardrails: + print(f" {finding}", file=sys.stderr) + return 2 + + sync_command = build_rsync_command(source, args.deck, remote_source) + podman_command = build_podman_validation_command( + source_dir=remote_source, + artifact_dir=remote_artifacts, + image=args.image, + ) + ssh_command = build_ssh_validation_command(args.deck, podman_command) + pull_command = build_artifact_pull_command(args.deck, remote_artifacts, local_artifacts) + oracle_command = build_oracle_command(local_artifacts) + + if args.dry_run: + if not args.skip_sync: + print("SYNC:", " ".join(q(part) for part in sync_command)) + print("VALIDATE:", " ".join(q(part) for part in ssh_command)) + print("PULL_ARTIFACTS:", " ".join(q(part) for part in pull_command)) + if not args.skip_oracle: + print("ORACLE:", " ".join(q(part) for part in oracle_command)) + return 0 + + local_artifacts.mkdir(parents=True, exist_ok=True) + if not args.skip_sync: + run_command(sync_command, log_path=local_artifacts / "rsync-source.log") + run_command(ssh_command, log_path=local_artifacts / "deck-podman-validation.log") + run_command(pull_command, log_path=local_artifacts / "rsync-artifacts.log") + if not args.skip_oracle: + run_command(oracle_command, log_path=local_artifacts / "deck-t32-preview-pump-oracle.log") + print(f"Deck T31 artifacts copied to {local_artifacts}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clients/deck/scripts/deck_t32_preview_pump_oracle.py b/clients/deck/scripts/deck_t32_preview_pump_oracle.py new file mode 100644 index 00000000..84f0a110 --- /dev/null +++ b/clients/deck/scripts/deck_t32_preview_pump_oracle.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Machine-checkable Deck preview frame pump oracle for the T31 Podman route. + +This oracle is intentionally strict: it is for real Steam Deck Game Mode route +artifacts, not local/headless smoke output. It verifies that the route ran Deck +CTest, that the preview pump semantics are covered by the Deck media adapter +CTest source, that the gamescope QSG rerun produced a ready render proof, and +that the validation route stayed away from host streaming/launch paths. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Sequence + +SCRIPT_ROOT = Path(__file__).resolve().parent +DECK_ROOT = SCRIPT_ROOT.parent +REPO_ROOT = DECK_ROOT.parents[1] +DEFAULT_ARTIFACT_DIR = REPO_ROOT / "build" / "deck-t32-artifacts" + +sys.path.insert(0, str(SCRIPT_ROOT)) +import deck_t31_podman_validation as route # noqa: E402 + + +class OracleFailure(RuntimeError): + pass + + +def read_required(path: Path) -> str: + if not path.is_file(): + raise OracleFailure(f"missing required artifact: {path}") + return path.read_text(encoding="utf-8", errors="replace") + + +def require_contains(text: str, needle: str, label: str) -> None: + if needle not in text: + raise OracleFailure(f"{label}: missing {needle!r}") + + +def require_regex(text: str, pattern: str, label: str) -> None: + if re.search(pattern, text, flags=re.MULTILINE) is None: + raise OracleFailure(f"{label}: missing pattern {pattern!r}") + + +def validate_preview_pump_source(deck_root: Path = DECK_ROOT) -> list[str]: + source = read_required(deck_root / "tests" / "deck_stream_media_adapters_test.cpp") + required_needles = { + "newest frame fixture is queued": "preview-fixture-newest", + "older frame is released after coalescing": "previewFrame1Weak.expired()", + "coalesced frame count is asserted": "previewFramePump.coalescedFrames() == 1", + "only newest frame remains pending": "previewFramePump.pendingFrames() == 1", + "newest frame is presented": "previewSink->lastDescriptor.surfaceId == 0x102", + "newest source survives flush": 'previewSink->lastDescriptor.source == std::string("preview-fixture-newest")', + "invalid reset fixture is queued": "preview-invalid-reset", + "invalid reset increments invalidation count": "previewFramePump.invalidatedFrames() == 1", + "invalid reset clears pending frame": "previewFramePump.pendingFrames() == 0", + "invalid reset clears stale lease": "previewFrame2Weak.expired()", + } + for label, needle in required_needles.items(): + require_contains(source, needle, f"preview pump source guard: {label}") + return ["preview pump source guard PASS"] + + +def validate_artifacts(artifact_dir: Path) -> list[str]: + ctest_log = read_required(artifact_dir / "ctest.log") + qsg_log = read_required(artifact_dir / "qsg-gamescope-smoke.log") + read_required(artifact_dir / "LastTest.log") + + require_regex(ctest_log, r"100% tests passed, 0 tests failed out of \d+", "Deck CTest") + require_regex( + ctest_log, + r"nova_deck_stream_media_adapters_test\s+\.+\s+Passed", + "Deck CTest preview pump binary", + ) + require_regex( + ctest_log, + r"nova_deck_qsg_render_node_scenegraph_smoke\s+\.+\s+Passed", + "Deck CTest QSG smoke binary", + ) + require_regex( + qsg_log, + r"Nova Deck QSGRenderNode VAAPI/EGL render path .*status=ready.*objects=1.*layers=2.*ready=1", + "gamescope QSG ready render proof", + ) + require_regex( + qsg_log, + r"Nova Deck QSGRenderNode scenegraph smoke passed: .*imported two DRM_PRIME layers", + "gamescope QSG rerun binary", + ) + require_contains( + qsg_log, + "readiness stayed false until shader composition proof", + "gamescope QSG readiness gate", + ) + return ["Deck artifacts PASS", "gamescope QSG ready proof PASS"] + + +def validate_route_guardrails() -> list[str]: + findings = route.find_forbidden_route_tokens() + if findings: + raise OracleFailure("route guardrail failed:\n" + "\n".join(f" {finding}" for finding in findings)) + return ["route guardrails PASS"] + + +def validate_oracle(artifact_dir: Path = DEFAULT_ARTIFACT_DIR, deck_root: Path = DECK_ROOT) -> list[str]: + artifact_dir = artifact_dir.expanduser().resolve() + results: list[str] = [] + results.extend(validate_preview_pump_source(deck_root)) + results.extend(validate_artifacts(artifact_dir)) + results.extend(validate_route_guardrails()) + return results + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", type=Path, default=DEFAULT_ARTIFACT_DIR, help="local Deck route artifact directory") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] = sys.argv[1:]) -> int: + args = parse_args(argv) + try: + results = validate_oracle(args.artifacts) + except OracleFailure as exc: + print(f"Deck T32 preview pump oracle FAIL: {exc}", file=sys.stderr) + return 1 + print("Deck T32 preview pump oracle PASS") + for result in results: + print(f"- {result}") + print(f"artifacts={args.artifacts.expanduser().resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md b/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md new file mode 100644 index 00000000..c2d2498b --- /dev/null +++ b/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md @@ -0,0 +1,125 @@ +# Deck-T14 VAAPI presentation strategy decision + +Status: accepted local/offline decision artifact for Deck-T14. This document does not start host streaming and does not add `LiStartConnection`, host discovery, pairing, credentials, app launch, fake streaming UI, or Android changes. `pushed=false`; `android_touched=false`. + +## Decision + +Choose the public Qt Quick OpenGL/EGLImage bridge as the next implementation path: + +1. Keep the existing FFmpeg VAAPI decode and DRM_PRIME export seam. +2. Add a hard runtime gate that only enables the presenter when the Qt Quick scene graph is `QSGRendererInterface::OpenGLRhi`. +3. Import the DRM_PRIME/dmabuf planes into `EGLImageKHR` with `EGL_LINUX_DMA_BUF_EXT`, bind them to GL textures, and wrap the GL texture for Qt Quick with `QNativeInterface::QSGOpenGLTexture::fromNativeExternalOES()` or `fromNative()`. +4. Fall back to the current honest `UnsupportedPublicQtLinuxDmabufImport` status when the scene graph is not OpenGL, required EGL extensions are missing, plane/modifier metadata is incomplete, or the import fails. + +This is the safest next step because it stays on public Qt Quick API for the scene-graph handoff while using standard Linux EGL/GL dmabuf interop at the boundary Qt actually exposes publicly. It also preserves the current Qt shell, overlay lane, Game Mode/gamescope friendliness, and no-network streaming guardrails. + +## Evidence gathered + +### Current in-tree seam + +- `clients/deck/src/stream/deck_stream_media_adapters.h` already retains a `DeckQrhiVaapiFrameLease`, exports a `DeckQrhiVaapiDrmPrimeDescriptor`, and reports `DeckQrhiVaapiImportStatus::UnsupportedPublicQtLinuxDmabufImport` when public QRhi import is blocked. +- `clients/deck/src/stream/deck_stream_media_adapters.cpp` maps VAAPI frames to DRM_PRIME through `av_hwframe_map()` and stores object/layer counts, but deliberately does not fake texture import. +- `clients/deck/tests/deck_stream_media_adapters_test.cpp` exercises a real decoded VAAPI frame when runtime VAAPI is available and asserts DRM_PRIME object/layer export succeeds before reporting the public-Qt block. + +### Local Qt/API facts + +Commands run from `/home/papi/Documents/github/nova`: + +```text +pkg-config --modversion Qt6Quick Qt6Gui Qt6Multimedia +=> 6.11.1 / 6.11.1 / 6.11.1 + +rpm -q qt6-qtbase-devel qt6-qtbase-private-devel qt6-qtmultimedia-devel qt6-qtmultimedia-private-devel +=> qt6-qtbase-devel-6.11.1-1.fc44.x86_64 +=> package qt6-qtbase-private-devel is not installed +=> qt6-qtmultimedia-devel-6.11.1-1.fc44.x86_64 +=> package qt6-qtmultimedia-private-devel is not installed +``` + +Header evidence: + +- `/usr/include/qt6/QtQuick/qsgtexture_platform.h` exposes public GL texture wrapping: + - `QNativeInterface::QSGOpenGLTexture::fromNative(GLuint, QQuickWindow*, QSize, ...)` + - `QNativeInterface::QSGOpenGLTexture::fromNativeExternalOES(GLuint, QQuickWindow*, QSize, ...)` +- `/usr/include/qt6/QtQuick/qquickwindow.h` exposes the public scene-graph backend gate: + - `QQuickWindow::setGraphicsApi(QSGRendererInterface::GraphicsApi)` + - `QQuickWindow::graphicsApi()` + - `QQuickWindow::rendererInterface()` +- `/usr/include/qt6/QtQuick/qsgrendererinterface.h` exposes `OpenGLRhi`, `VulkanRhi`, and `RhiResource`, but no public Linux dmabuf/VASurface-to-QRhi texture import function. +- `/usr/include/qt6/QtMultimedia/qvideoframe.h` exposes only `QVideoFrame::NoHandle` and `QVideoFrame::RhiTextureHandle`; it does not expose a public dmabuf/VASurface constructor or handle type. +- `/usr/include/qt6/QtMultimedia/6.11.1/QtMultimedia/private/qvideoframe_p.h` has a private `QVideoFramePrivate::hasDmaBuf()` helper, and `/usr/include/qt6/QtMultimedia/6.11.1/QtMultimedia/private/qhwvideobuffer_p.h` has private `QHwVideoBuffer::isDmaBuf()`. The warning in that header says it is not Qt API and can change or disappear. +- `/usr/include/EGL/eglext.h` exposes `eglCreateImageKHR` and `EGL_LINUX_DMA_BUF_EXT`. +- `/usr/include/GLES2/gl2ext.h` exposes `GL_TEXTURE_EXTERNAL_OES`. +- `/usr/include/va/va.h` exposes `vaExportSurfaceHandle()`, though the current Nova seam already gets DRM_PRIME via FFmpeg `av_hwframe_map()`. + +### Compile probes + +Probe sources were written under `/tmp/nova-deck-t14-probes`. + +```text +qsg_opengl_bridge_probe: PASS +``` + +Compiled a public Qt Quick probe using `QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGLRhi)` and `QNativeInterface::QSGOpenGLTexture::fromNativeExternalOES()` with `pkg-config --cflags Qt6Quick Qt6Gui`. + +```text +qvideoframe_public_probe: PASS +``` + +Compiled a public Qt Multimedia probe showing custom public `QAbstractVideoBuffer` frames are still `QVideoFrame::NoHandle`; public `QVideoFrame` gives `RhiTextureHandle`, not dmabuf/VASurface handles. + +```text +qvideoframe_private_dmabuf_probe: PASS +``` + +Compiled only after adding private Qt Multimedia include paths. This proves the dmabuf helper exists, but it is private API and depends on private headers. + +### Build/test verification + +```text +cmake -S clients/deck -B build/deck-t14 -DNOVA_DECK_BUILD_QT_SHELL=ON +cmake --build build/deck-t14 +ctest --test-dir build/deck-t14 --output-on-failure +``` + +Result: 5/5 tests passed locally: + +- `nova_deck_controller_library_smoke` +- `nova_deck_stream_core_test` +- `nova_deck_stream_media_adapters_test` +- `nova_deck_gamemode_capture_harness_test` +- `nova_deck_qt_shell_smoke` + +### Steam Deck probe attempt + +```text +ssh -o BatchMode=yes -o ConnectTimeout=8 deck@10.0.0.39 '...' +=> ssh: connect to host 10.0.0.39 port 22: Connection timed out +``` + +No Deck/container probe was completed during T14 because the Deck SSH target was unreachable. This does not invalidate the strategy choice because Deck-T13 already confirmed AMD/radeonsi VAAPI decode and real VAAPI-to-DRM_PRIME export on Deck hardware. T15 should rerun the rootless podman CTest plus a GL/EGL extension probe on Deck before accepting any implementation. + +## Options compared + +| Option | Public API status | Zero/low-copy potential | Main risk | Verdict | +|---|---|---:|---|---| +| Private Qt/QRhi dmabuf import gate | Blocked on this host: Qt private QRhi headers are not installed; public Qt Quick exposes `RhiResource` but not dmabuf import. | High if it worked | Private API churn, package availability, backend-specific QRhi internals, brittle SteamOS upgrades | Reject for next implementation; keep only as last-resort spike if public GL route fails | +| Public EGLImage/GL texture bridge | Public Qt Quick can force/gate `OpenGLRhi` and wrap native GL/OES textures. EGL/GL/VAAPI dmabuf symbols are present. | High, if dmabuf modifiers/planes import cleanly | Requires OpenGL scene graph; must manage EGL image/GL texture lifetime and extension checks carefully | Choose for T15 | +| Qt Multimedia/QVideoFrame native-frame integration | Public `QVideoFrame` only exposes `NoHandle`/`RhiTextureHandle`; dmabuf detection and hardware-buffer mapping live in private headers. | Medium/high through private backend internals | Private API, FFmpeg backend ownership mismatch, harder to preserve Moonlight decode-unit pacing and Nova overlay timing | Defer/reject for T15; revisit only if a public dmabuf producer API appears | +| Raw DRM/KMS or gamescope bypass | Public Linux APIs exist outside Qt | High | Fights Qt shell, focus, overlays, suspend/resume, and Game Mode integration | Reject for first stream path | +| Software readback/upload | Public and simple | Low | Loses hardware-backed objective and likely latency/battery performance | Diagnostic fallback only | + +## Implementation guardrails for T15 + +- Do not start host streaming; keep using deterministic local H.264/VAAPI decode input. +- Do not call `LiStartConnection`, host discovery, pairing, credentials, app launch, socket setup, or Android code. +- Keep the existing `DeckQrhiVaapiImportStatus` honesty: every disabled/failing route must report why it is disabled instead of presenting a fake streaming surface. +- Add a small GL/EGL presenter object behind the existing `DeckQtQuickRhiVaapiRenderNode` boundary; do not pull this into general app shell code. +- Gate on `QSGRendererInterface::OpenGLRhi` at runtime and fail closed on Vulkan/software/null scene graphs. +- Probe required extensions before import: `EGL_EXT_image_dma_buf_import`, modifier support if needed, and OES/external texture support if using `GL_TEXTURE_EXTERNAL_OES`. +- Preserve dmabuf fd and frame lease lifetime until Qt has finished the render pass/frame using it. +- Test unsupported paths first: non-OpenGL scene graph, missing frame lease, missing hardware context, failed EGL import, and release cleanup. + +## Recommended next card + +Deck-T15 public EGLImage/GL VAAPI presenter gate: implement a no-network, local/offline presenter behind the existing Deck Qt Quick render node that imports the current FFmpeg DRM_PRIME descriptor into EGLImage/GL texture only when Qt Quick is running `OpenGLRhi`; wrap that texture with public `QNativeInterface::QSGOpenGLTexture`, preserve the VAAPI frame lease and GL/EGL resources through render cleanup, and report explicit unsupported status for every failed gate. Required verification: RED tests for non-OpenGL/missing-extension/fd-lifetime cleanup, GREEN local Deck CMake/CTest, Steam Deck rootless podman CTest plus GL/EGL extension probe, `git diff --check`, no Android changes, `pushed=false`, and independent review before commit. diff --git a/clients/deck/src/backend/deck_backend_interfaces.cpp b/clients/deck/src/backend/deck_backend_interfaces.cpp new file mode 100644 index 00000000..031ef04d --- /dev/null +++ b/clients/deck/src/backend/deck_backend_interfaces.cpp @@ -0,0 +1,974 @@ +#include "backend/deck_backend_interfaces.h" +#include "polaris_game_fixture.h" + +#include +#include +#include + +namespace nova::deck::backend { + +namespace { + +DeckHostSummary sanitizedHost(DeckHostSummary host) { + host.rawEndpointForBackendOnly.clear(); + return host; +} + +DeckCredentialMetadata sanitizedCredentialMetadata(DeckCredentialMetadata metadata) { + metadata.rawTokenForBackendOnly.clear(); + metadata.rawCertificateForBackendOnly.clear(); + metadata.rawPrivateKeyForBackendOnly.clear(); + return metadata; +} + +std::string endpointClassLabel(const DeckEndpointClass endpointClass) { + switch (endpointClass) { + case DeckEndpointClass::Unknown: + return "unknown-endpoint"; + case DeckEndpointClass::Local: + return "local-endpoint"; + case DeckEndpointClass::Manual: + return "manual-endpoint"; + case DeckEndpointClass::Discovered: + return "discovered-endpoint"; + } + return "unknown-endpoint"; +} + +std::string hostStateLabel(const DeckHostState state) { + switch (state) { + case DeckHostState::Fixture: + return "fixture"; + case DeckHostState::Manual: + return "manual"; + case DeckHostState::Discovered: + return "discovered"; + case DeckHostState::Offline: + return "offline"; + case DeckHostState::Online: + return "online"; + case DeckHostState::PairingNeeded: + return "pairing-needed"; + case DeckHostState::Paired: + return "paired"; + case DeckHostState::CertMismatch: + return "cert-mismatch"; + case DeckHostState::AuthRejected: + return "auth-rejected"; + case DeckHostState::Unsupported: + return "unsupported"; + } + return "unknown"; +} + +std::string sourceLabelFor(const std::string& source) { + if (source == "steam") { + return "Steam"; + } + if (source == "lutris") { + return "Lutris"; + } + if (source == "heroic") { + return "Heroic"; + } + if (source == "manual") { + return "Manual"; + } + return source.empty() ? std::string{"Other"} : source; +} + +std::string sourceRuntimeLabelFor(const PolarisGameFixture& game) { + std::vector parts; + parts.push_back(sourceLabelFor(game.launcherSource.empty() ? game.source : game.launcherSource)); + if (!game.platformLabel.empty()) { + parts.push_back(game.platformLabel); + } + if (!game.runtimeLabel.empty() && game.runtimeLabel != game.platformLabel) { + parts.push_back(game.runtimeLabel); + } + + std::string label; + for (const auto& part : parts) { + if (part.empty()) { + continue; + } + if (!label.empty()) { + label += " · "; + } + label += part; + } + return label; +} + +std::string launchModeLabelFor(const PolarisGameFixture& game) { + const std::string streamMode = game.launchMode.recommendedMode.empty() ? "preview" : game.launchMode.recommendedMode; + const std::string steamMode = game.steamLaunch.recommendedMode.empty() ? "direct" : game.steamLaunch.recommendedMode; + return "Stream: " + streamMode + " · Steam: " + steamMode; +} + +std::string defaultHostStatusLabelFor(const DeckHostSummary& host) { + if (!host.publicStatusLabel.empty()) { + return host.publicStatusLabel; + } + if (host.fixtureOnly) { + return "Backend-owned fixture summary · read-only"; + } + if (host.state == DeckHostState::Offline) { + return "Backend-owned offline summary · read-only"; + } + if (host.state == DeckHostState::PairingNeeded) { + return "Backend-owned unpaired summary · read-only"; + } + return "Backend-owned sanitized host summary · read-only"; +} + +std::string defaultHostSubtitleFor(const DeckHostSummary& host) { + if (!host.publicSubtitle.empty()) { + return host.publicSubtitle; + } + return "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read."; +} + +std::string defaultHostProvenanceFor(const DeckHostSummary& host) { + if (!host.publicProvenanceLabel.empty()) { + return host.publicProvenanceLabel; + } + return host.fixtureOnly ? "fixture/read-only/backend-owned" : "sanitized/read-only/backend-owned"; +} + +DeckPreflightBlocker blockerFor(const DeckPreflightBlockerCategory category) { + auto publicReasonFor = [](const DeckPreflightBlockerCategory reasonCategory) { + switch (reasonCategory) { + case DeckPreflightBlockerCategory::FixtureOnly: + return "Fixture provenance only; backend will not treat this as a live host."; + case DeckPreflightBlockerCategory::NetworkDisabled: + return "Network reads are disabled in this Deck preview."; + case DeckPreflightBlockerCategory::MissingHost: + return "No backend host summary is selected."; + case DeckPreflightBlockerCategory::HostUnreachable: + return "Host is offline or has no reachable sanitized endpoint candidate."; + case DeckPreflightBlockerCategory::PairingRequired: + return "Host is unpaired; pairing and credential reads stay disabled."; + case DeckPreflightBlockerCategory::CertMismatch: + return "Host trust metadata reports a certificate mismatch."; + case DeckPreflightBlockerCategory::AuthRejected: + return "Host trust metadata reports rejected authorization."; + case DeckPreflightBlockerCategory::LibraryUnavailable: + return "Backend library summary is unavailable."; + case DeckPreflightBlockerCategory::AppNotFound: + return "Selected app is absent from the backend library summary."; + case DeckPreflightBlockerCategory::SessionOwnedByAnotherClient: + return "Session is owned by another client."; + case DeckPreflightBlockerCategory::WatchNotAvailable: + return "Watch mode is unavailable for this read-only preview."; + case DeckPreflightBlockerCategory::LaunchNotAllowed: + return "Host launch is not allowed by the lab gate."; + case DeckPreflightBlockerCategory::LabGateDisabled: + return "Lab gate is disabled; dry-run launch, stream start, and backend power stay off."; + case DeckPreflightBlockerCategory::RendererUnavailable: + return "Renderer readiness is blocked."; + case DeckPreflightBlockerCategory::AudioUnavailable: + return "Audio readiness is blocked."; + case DeckPreflightBlockerCategory::InputUnavailable: + return "Input readiness is blocked."; + } + return "Read-only backend preflight blocked."; + }; + + return DeckPreflightBlocker{ + .category = category, + .code = toPublicCode(category), + .publicReason = publicReasonFor(category), + }; +} + +void appendBlocker(std::vector& blockers, const DeckPreflightBlockerCategory category) { + blockers.push_back(blockerFor(category)); +} + +bool atLeastLaunchDryRun(const DeckLabGateMode mode) { + return mode == DeckLabGateMode::LaunchDryRun + || mode == DeckLabGateMode::LaunchLab + || mode == DeckLabGateMode::StreamLab; +} + +DeckLaunchPreflightInput publicPreviewInputFor( + const DeckHostRepository& repository, + const DeckCredentialStore& credentialStore, + const DeckLabGate& labGate, + const DeckPublicBackendPreviewRequest& request) { + const auto host = repository.hostById(request.hostId); + DeckCredentialMetadata credentials; + if (const auto metadata = credentialStore.metadataForHost(request.hostId)) { + credentials = metadata.value(); + } else { + credentials.hostId = request.hostId; + } + + return DeckLaunchPreflightInput{ + .host = host, + .credentials = credentials, + .library = DeckLibraryAvailability{ + .available = host.has_value() && host->polarisAvailable, + .gameAvailable = host.has_value() && host->standardAppListAvailable && !request.gameId.empty(), + .sourceLabel = host.has_value() ? "sanitized-backend-snapshot" : "sanitized-backend-missing-host", + }, + .session = DeckSessionSummary{ + .ownedByAnotherClient = false, + .watchAvailable = true, + }, + .backendReadiness = DeckBackendReadiness{ + .rendererAvailable = true, + .audioAvailable = true, + .inputAvailable = true, + }, + .labGate = labGate, + .requestedGameId = request.gameId, + .requestedProfileId = request.profileId, + }; +} + +DeckPublicPreflightPreview publicPreviewFor( + const DeckPreflightReport& report, + const DeckCoordinatorResult& coordinatorResult) { + std::vector blockerCodes; + blockerCodes.reserve(report.blockers.size()); + for (const auto& blocker : report.blockers) { + blockerCodes.push_back(blocker.code); + } + + return DeckPublicPreflightPreview{ + .statusCode = report.approved ? "backend-preflight-approved-dry-run" : "backend-preflight-blocked", + .approved = report.approved, + .blockerCodes = std::move(blockerCodes), + .launchDryRunAllowed = report.coordinatorRequest.launchAllowed, + .streamAllowed = report.coordinatorRequest.streamAllowed, + .backendPowerStarted = coordinatorResult.networkStarted || coordinatorResult.rawStartCalled, + .publicCopy = report.publicCopy + "; coordinator=" + coordinatorResult.statusCode, + }; +} + +DeckPublicReadOnlyDtoParity readOnlyDtoParityFor( + const DeckPublicReadOnlyPreflightState& preflight, + const std::string& scenarioId, + const std::string& scenarioLabel) { + const std::string scenario = scenarioId.empty() ? std::string{"default"} : scenarioId; + const std::string label = scenarioLabel.empty() ? std::string{"Read-only fixture state"} : scenarioLabel; + return DeckPublicReadOnlyDtoParity{ + .contractId = "backend-owned-read-only-dto-v1", + .ownerCode = "backend-owned-read-only-model", + .privacyCode = "redacted-public-dto", + .readinessCode = "dto-parity-ready", + .collapsedSummary = "Backend-owned DTO parity · contract=backend-owned-read-only-dto-v1 · readiness=dto-parity-ready · status=" + preflight.statusCode, + .expandedDiagnostics = "DTO parity: scenario=" + scenario + " · label=" + label + " · contract=backend-owned-read-only-dto-v1 · owner=backend-owned-read-only-model · privacy=redacted-public-dto · readiness=dto-parity-ready", + .artifactSummary = "dto_contract=backend-owned-read-only-dto-v1 dto_owner=backend-owned-read-only-model dto_privacy=redacted-public-dto dto_readiness=dto-parity-ready backendPowerStarted=false stream=false", + }; +} + +bool hasBlockerCode(const DeckPublicReadOnlyPreflightState& preflight, const std::string_view code) { + return std::find(preflight.blockerCodes.begin(), preflight.blockerCodes.end(), code) != preflight.blockerCodes.end(); +} + +DeckPublicReadOnlyPlayerState playerStateFor(const DeckPublicReadOnlyPreflightState& preflight, const std::string& scenarioLabel) { + DeckPublicReadOnlyPlayerState playerState{ + .title = scenarioLabel.empty() ? "Product state: Launch preview blocked" : "Product state: " + scenarioLabel, + .body = scenarioLabel.empty() ? "Launch preview blocked. Open diagnostics." : "Launch preview blocked. Open diagnostics for " + scenarioLabel + ".", + .actionLabel = "Review the safe launch plan before copying it locally.", + .safetyLabel = "Read-only state only; diagnostics are secondary and safe to inspect.", + .provenanceLabel = "dto-player-state/backend-owned/redacted-public", + .focusOrder = "state-card-copy-diagnostics", + .focusOrderCopy = "Focus order: state card → Copy plan → Show diagnostics", + }; + + if (hasBlockerCode(preflight, "missing-host")) { + playerState.title = "Product state: Ready for setup"; + playerState.body = "Select a host and game to continue."; + playerState.actionLabel = "Add a backend host before previewing a launch plan."; + return playerState; + } + if (hasBlockerCode(preflight, "lab-gate-disabled") || hasBlockerCode(preflight, "fixture-only")) { + playerState.title = "Product state: Lab gate locked"; + playerState.body = "Launch blocked by lab gate."; + playerState.actionLabel = "Ask an operator to open the lab gate before any start path."; + playerState.safetyLabel = "Backend power, launch, stream, discovery, and media stay off."; + return playerState; + } + if (hasBlockerCode(preflight, "library-unavailable") || hasBlockerCode(preflight, "app-not-found")) { + playerState.title = "Product state: Library unavailable"; + playerState.body = "Library unavailable. Try again when the read-only snapshot is back."; + playerState.actionLabel = "Wait for the read-only library snapshot to return."; + playerState.safetyLabel = "No Polaris API call or fallback app-list fetch runs from this shell."; + return playerState; + } + if (hasBlockerCode(preflight, "host-unreachable")) { + playerState.title = "Product state: Host offline"; + playerState.body = "Host offline. Reconnect or pick another host."; + playerState.actionLabel = "Reconnect the host or choose another backend-owned snapshot."; + playerState.safetyLabel = "Backend power stays off; no retry or network probe runs."; + return playerState; + } + if (hasBlockerCode(preflight, "pairing-required")) { + playerState.title = "Product state: Pair host"; + playerState.body = "Pair this host before launch preview."; + playerState.actionLabel = "Pair this host in an approved flow before preview launch."; + playerState.safetyLabel = "Credentials stay unread until an approved pair flow exists."; + return playerState; + } + if (preflight.statusCode == "backend-read-only-preflight-approved-dry-run") { + playerState.title = "Product state: Dry-run ready"; + playerState.body = "Dry-run preview ready. Stream stays off."; + } + return playerState; +} + +} // namespace + +DeckLabGate DeckLabGate::forMode(const DeckLabGateMode mode) { + return DeckLabGate(mode); +} + +DeckLabGate::DeckLabGate(const DeckLabGateMode mode) + : mode_(mode) {} + +DeckLabGateMode DeckLabGate::mode() const { + return mode_; +} + +std::string DeckLabGate::modeLabel() const { + switch (mode_) { + case DeckLabGateMode::Disabled: + return "disabled"; + case DeckLabGateMode::ReadOnlyNetwork: + return "read-only-network"; + case DeckLabGateMode::PairingLab: + return "pairing-lab"; + case DeckLabGateMode::LaunchDryRun: + return "launch-dry-run"; + case DeckLabGateMode::LaunchLab: + return "launch-lab"; + case DeckLabGateMode::StreamLab: + return "stream-lab"; + } + return "disabled"; +} + +bool DeckLabGate::networkReadAllowed() const { + return mode_ != DeckLabGateMode::Disabled; +} + +bool DeckLabGate::pairingAllowed() const { + return mode_ == DeckLabGateMode::PairingLab + || mode_ == DeckLabGateMode::LaunchLab + || mode_ == DeckLabGateMode::StreamLab; +} + +bool DeckLabGate::launchDryRunAllowed() const { + return atLeastLaunchDryRun(mode_); +} + +bool DeckLabGate::hostLaunchAllowed() const { + return mode_ == DeckLabGateMode::LaunchLab || mode_ == DeckLabGateMode::StreamLab; +} + +bool DeckLabGate::streamStartAllowed() const { + return mode_ == DeckLabGateMode::StreamLab; +} + +void DeckFakeHostRepository::upsertFixtureHost(std::string id, std::string displayName) { + hosts_.push_back(DeckHostSummary{ + .id = std::move(id), + .displayName = std::move(displayName), + .state = DeckHostState::Fixture, + .endpointClass = DeckEndpointClass::Unknown, + .fixtureOnly = true, + .hasEndpointCandidate = false, + .polarisAvailable = false, + .standardAppListAvailable = false, + .publicStatusLabel = "Backend-owned fixture summary · read-only", + .publicSubtitle = "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read.", + .publicProvenanceLabel = "fixture/read-only/backend-owned", + }); +} + +void DeckFakeHostRepository::upsertSanitizedHostSummary(DeckHostSummary host) { + host.rawEndpointForBackendOnly.clear(); + hosts_.push_back(std::move(host)); +} + +void DeckFakeHostRepository::upsertManualHostForTest(std::string id, std::string displayName, std::string rawEndpoint) { + hosts_.push_back(DeckHostSummary{ + .id = std::move(id), + .displayName = std::move(displayName), + .state = DeckHostState::Manual, + .endpointClass = DeckEndpointClass::Manual, + .fixtureOnly = false, + .hasEndpointCandidate = true, + .polarisAvailable = false, + .standardAppListAvailable = false, + .rawEndpointForBackendOnly = std::move(rawEndpoint), + }); +} + +std::vector DeckFakeHostRepository::listHosts() const { + std::vector sanitized; + sanitized.reserve(hosts_.size()); + for (auto host : hosts_) { + sanitized.push_back(sanitizedHost(std::move(host))); + } + return sanitized; +} + +std::optional DeckFakeHostRepository::hostById(const std::string_view hostId) const { + const auto found = std::find_if(hosts_.begin(), hosts_.end(), [hostId](const auto& host) { + return host.id == hostId; + }); + if (found == hosts_.end()) { + return std::nullopt; + } + return sanitizedHost(*found); +} + +std::string DeckFakeHostRepository::backendEndpointForTest(const std::string_view hostId) const { + const auto found = std::find_if(hosts_.begin(), hosts_.end(), [hostId](const auto& host) { + return host.id == hostId; + }); + if (found == hosts_.end()) { + return {}; + } + return found->rawEndpointForBackendOnly; +} + +void DeckCredentialStore::upsertMetadata(DeckCredentialMetadata metadata) { + const auto found = std::find_if(metadata_.begin(), metadata_.end(), [&metadata](const auto& existing) { + return existing.hostId == metadata.hostId; + }); + if (found == metadata_.end()) { + metadata_.push_back(std::move(metadata)); + return; + } + *found = std::move(metadata); +} + +std::optional DeckCredentialStore::metadataForHost(const std::string_view hostId) const { + const auto found = std::find_if(metadata_.begin(), metadata_.end(), [hostId](const auto& metadata) { + return metadata.hostId == hostId; + }); + if (found == metadata_.end()) { + return std::nullopt; + } + return sanitizedCredentialMetadata(*found); +} + +DeckPreflightReport DeckLaunchPreflightService::evaluate(const DeckLaunchPreflightInput& input) const { + std::vector blockers; + + if (!input.host.has_value()) { + appendBlocker(blockers, DeckPreflightBlockerCategory::MissingHost); + } else { + const auto& host = input.host.value(); + if (host.fixtureOnly) { + appendBlocker(blockers, DeckPreflightBlockerCategory::FixtureOnly); + } + if (!host.hasEndpointCandidate) { + appendBlocker(blockers, DeckPreflightBlockerCategory::HostUnreachable); + } + if (host.state == DeckHostState::CertMismatch) { + appendBlocker(blockers, DeckPreflightBlockerCategory::CertMismatch); + } + if (host.state == DeckHostState::AuthRejected) { + appendBlocker(blockers, DeckPreflightBlockerCategory::AuthRejected); + } + } + + if (input.labGate.mode() == DeckLabGateMode::Disabled || !input.labGate.launchDryRunAllowed()) { + appendBlocker(blockers, DeckPreflightBlockerCategory::LabGateDisabled); + } + if (!input.labGate.networkReadAllowed()) { + appendBlocker(blockers, DeckPreflightBlockerCategory::NetworkDisabled); + } + if (!input.credentials.paired) { + appendBlocker(blockers, DeckPreflightBlockerCategory::PairingRequired); + } + if (input.credentials.certMismatch) { + appendBlocker(blockers, DeckPreflightBlockerCategory::CertMismatch); + } + if (input.credentials.authRejected) { + appendBlocker(blockers, DeckPreflightBlockerCategory::AuthRejected); + } + if (!input.library.available) { + appendBlocker(blockers, DeckPreflightBlockerCategory::LibraryUnavailable); + } else if (!input.library.gameAvailable) { + appendBlocker(blockers, DeckPreflightBlockerCategory::AppNotFound); + } + if (input.session.ownedByAnotherClient) { + appendBlocker(blockers, DeckPreflightBlockerCategory::SessionOwnedByAnotherClient); + if (!input.session.watchAvailable) { + appendBlocker(blockers, DeckPreflightBlockerCategory::WatchNotAvailable); + } + } + if (!input.backendReadiness.rendererAvailable) { + appendBlocker(blockers, DeckPreflightBlockerCategory::RendererUnavailable); + } + if (!input.backendReadiness.audioAvailable) { + appendBlocker(blockers, DeckPreflightBlockerCategory::AudioUnavailable); + } + if (!input.backendReadiness.inputAvailable) { + appendBlocker(blockers, DeckPreflightBlockerCategory::InputUnavailable); + } + + const bool approved = blockers.empty(); + DeckCoordinatorRequest coordinatorRequest; + if (input.host.has_value()) { + coordinatorRequest.hostId = input.host->id; + } + coordinatorRequest.gameId = input.requestedGameId; + coordinatorRequest.profileId = input.requestedProfileId; + coordinatorRequest.launchAllowed = approved && input.labGate.launchDryRunAllowed(); + coordinatorRequest.streamAllowed = false; + coordinatorRequest.publicPlan = approved + ? "launch dry-run approved; host launch and stream start remain disabled" + : "launch preflight blocked before backend power"; + + std::ostringstream copy; + copy << "Deck launch preflight: " << (approved ? "approved-dry-run" : "blocked") + << " blockers=" << blockers.size() + << " lab=" << input.labGate.modeLabel(); + if (input.host.has_value()) { + copy << " host=" << hostStateLabel(input.host->state) + << " endpoint=" << endpointClassLabel(input.host->endpointClass); + } else { + copy << " host=missing"; + } + copy << " library=" << (input.library.available ? "available" : "unavailable") + << " renderer=" << (input.backendReadiness.rendererAvailable ? "ready" : "blocked") + << " audio=" << (input.backendReadiness.audioAvailable ? "ready" : "blocked") + << " input=" << (input.backendReadiness.inputAvailable ? "ready" : "blocked"); + if (!blockers.empty()) { + copy << " reasons="; + for (std::size_t index = 0; index < blockers.size(); ++index) { + if (index != 0) { + copy << " | "; + } + copy << blockers[index].code << ": " << blockers[index].publicReason; + } + } + + return DeckPreflightReport{ + .approved = approved, + .blockers = std::move(blockers), + .coordinatorRequest = std::move(coordinatorRequest), + .publicCopy = copy.str(), + }; +} + +DeckCoordinatorResult DeckStreamSessionCoordinator::dryRun(const DeckCoordinatorRequest& request) const { + if (!request.launchAllowed) { + return DeckCoordinatorResult{ + .accepted = false, + .networkStarted = false, + .rawStartCalled = false, + .statusCode = "coordinator-dry-run-blocked", + .publicCopy = "Coordinator refused blocked request; no network or host mutation occurred.", + }; + } + return DeckCoordinatorResult{ + .accepted = true, + .networkStarted = false, + .rawStartCalled = false, + .statusCode = "coordinator-dry-run-ready", + .publicCopy = "Coordinator accepted sanitized dry-run request for host category; no launch, stream, discovery, pairing, credential read, or network call occurred.", + }; +} + +void DeckDiagnosticsModel::updateHost(const DeckHostSummary& host) { + hostCategory_ = "host=" + hostStateLabel(host.state) + ";endpoint=" + endpointClassLabel(host.endpointClass); +} + +void DeckDiagnosticsModel::updateCredentials(const DeckCredentialMetadata& credentials) { + trustCategory_ = credentials.certMismatch ? "trust=cert-mismatch" + : credentials.authRejected ? "trust=auth-rejected" + : credentials.paired ? "trust=paired-metadata" + : "trust=pairing-required"; +} + +void DeckDiagnosticsModel::updateBackendReadiness(const DeckBackendReadiness& readiness) { + backendCategory_ = "renderer=" + std::string(readiness.rendererAvailable ? "ready" : "blocked") + + ";audio=" + std::string(readiness.audioAvailable ? "ready" : "blocked") + + ";input=" + std::string(readiness.inputAvailable ? "ready" : "blocked"); +} + +void DeckDiagnosticsModel::updatePreflight(const DeckPreflightReport& report) { + preflightCategory_ = "preflight=" + std::string(report.approved ? "approved-dry-run" : "blocked") + + ";blockers=" + std::to_string(report.blockers.size()); +} + +void DeckDiagnosticsModel::updateCoordinatorStatus(std::string statusCode) { + coordinatorStatus_ = "coordinator=" + std::move(statusCode); +} + +std::string DeckDiagnosticsModel::copyText() const { + return "Nova Deck diagnostics: " + hostCategory_ + + "; " + trustCategory_ + + "; " + backendCategory_ + + "; " + preflightCategory_ + + "; " + coordinatorStatus_ + + "; privacy=redacted"; +} + +DeckPublicPreflightPreview requestDeckBackendPreflightPreview( + const DeckHostRepository& repository, + const DeckCredentialStore& credentialStore, + const DeckLaunchPreflightService& preflightService, + const DeckStreamSessionCoordinator& coordinator, + const DeckLabGate& labGate, + const DeckPublicBackendPreviewRequest& request) { + const auto input = publicPreviewInputFor(repository, credentialStore, labGate, request); + const auto report = preflightService.evaluate(input); + const auto coordinatorResult = coordinator.dryRun(report.coordinatorRequest); + return publicPreviewFor(report, coordinatorResult); +} + +DeckPublicDiagnosticsPreview requestDeckBackendDiagnosticsPreview( + const DeckHostRepository& repository, + const DeckCredentialStore& credentialStore, + const DeckLaunchPreflightService& preflightService, + const DeckStreamSessionCoordinator& coordinator, + DeckDiagnosticsModel& diagnostics, + const DeckLabGate& labGate, + const DeckPublicBackendPreviewRequest& request) { + const auto input = publicPreviewInputFor(repository, credentialStore, labGate, request); + const auto report = preflightService.evaluate(input); + const auto coordinatorResult = coordinator.dryRun(report.coordinatorRequest); + + if (input.host.has_value()) { + diagnostics.updateHost(input.host.value()); + } + diagnostics.updateCredentials(input.credentials); + diagnostics.updateBackendReadiness(input.backendReadiness); + diagnostics.updatePreflight(report); + diagnostics.updateCoordinatorStatus(coordinatorResult.statusCode); + + return DeckPublicDiagnosticsPreview{ + .statusCode = "backend-diagnostics-ready", + .privacyCode = "redacted-public-dto", + .copyText = diagnostics.copyText(), + }; +} + +DeckPublicReadOnlyHostLibraryState buildReadOnlyHostLibraryState( + const DeckHostRepository& repository, + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService, + const DeckLabGate& labGate) { + DeckPublicReadOnlyHostLibraryState state; + state.sourceLabel = library.sourceLabel; + state.readOnly = library.readOnly; + + const auto hosts = repository.listHosts(); + state.hosts.reserve(hosts.size()); + int hostRow = 0; + for (const auto& host : hosts) { + state.hosts.push_back(DeckPublicReadOnlyHostItem{ + .id = host.id.empty() ? "host-empty-state" : host.id, + .displayName = host.displayName.empty() ? "Read-only backend host" : host.displayName, + .statusLabel = defaultHostStatusLabelFor(host), + .subtitle = defaultHostSubtitleFor(host), + .provenanceLabel = defaultHostProvenanceFor(host), + .initialFocus = hostRow == 0, + }); + ++hostRow; + } + + state.games.reserve(library.games.size()); + int gameRow = 0; + for (const auto& game : library.games) { + state.games.push_back(DeckPublicReadOnlyGameItem{ + .id = game.id.empty() ? "library-game-" + std::to_string(gameRow) : game.id, + .title = game.name.empty() ? "Untitled game" : game.name, + .sourceRuntimeLabel = sourceRuntimeLabelFor(game), + .launchModeLabel = launchModeLabelFor(game), + .installedLabel = game.installed ? "Installed" : "Not installed", + .initialFocus = gameRow == 0, + }); + ++gameRow; + } + + DeckCredentialMetadata credentials; + DeckLaunchPreflightInput input; + input.host = hosts.empty() ? std::optional{} : std::optional{hosts.front()}; + input.credentials = credentials; + if (input.host.has_value()) { + input.credentials.hostId = input.host->id; + } + input.library = DeckLibraryAvailability{ + .available = library.readOnly && !library.games.empty(), + .gameAvailable = !library.games.empty(), + .sourceLabel = "backend-owned-read-only-library-summary", + }; + input.session = DeckSessionSummary{ + .ownedByAnotherClient = false, + .watchAvailable = false, + }; + input.backendReadiness = DeckBackendReadiness{ + .rendererAvailable = true, + .audioAvailable = true, + .inputAvailable = true, + }; + input.labGate = labGate; + input.requestedGameId = library.games.empty() ? std::string{} : library.games.front().id; + input.requestedProfileId = "read-only-preview"; + + const auto report = preflightService.evaluate(input); + state.preflight.statusCode = report.approved ? "backend-read-only-preflight-approved-dry-run" : "backend-read-only-preflight-blocked"; + state.preflight.launchDryRunAllowed = report.coordinatorRequest.launchAllowed; + state.preflight.streamAllowed = report.coordinatorRequest.streamAllowed; + state.preflight.backendPowerStarted = false; + state.preflight.publicCopy = report.publicCopy + "; source=backend-owned-read-only-model; backendPowerStarted=false"; + state.preflight.blockerCodes.reserve(report.blockers.size()); + for (const auto& blocker : report.blockers) { + state.preflight.blockerCodes.push_back(blocker.code); + } + state.playerState = playerStateFor(state.preflight, state.scenarioLabel); + state.dtoParity = readOnlyDtoParityFor(state.preflight, state.scenarioId, state.scenarioLabel); + return state; +} + +std::vector buildReadOnlyHostLibraryStateMatrix( + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService) { + auto withMatrixLabels = [](DeckPublicReadOnlyHostLibraryState state, std::string scenarioId, std::string scenarioLabel) { + state.scenarioId = std::move(scenarioId); + state.scenarioLabel = std::move(scenarioLabel); + state.sourceLabel = "backend-owned read-only matrix · " + state.scenarioLabel; + state.preflight.publicCopy += "; source=backend-owned-read-only-matrix; scenario=" + state.scenarioId; + state.playerState = playerStateFor(state.preflight, state.scenarioLabel); + state.dtoParity = readOnlyDtoParityFor(state.preflight, state.scenarioId, state.scenarioLabel); + return state; + }; + + auto firstGameLibrary = library; + if (firstGameLibrary.games.empty()) { + firstGameLibrary.sourceLabel = "Backend read-only matrix fixture"; + } + + std::vector matrix; + matrix.reserve(5); + + { + PolarisGameLibraryFixture emptyLibrary = library; + emptyLibrary.hosts.clear(); + emptyLibrary.games.clear(); + emptyLibrary.sourceLabel = "Backend read-only matrix empty fixture"; + DeckFakeHostRepository emptyRepository; + matrix.push_back(withMatrixLabels( + buildReadOnlyHostLibraryState( + emptyRepository, + emptyLibrary, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::Disabled)), + "empty", + "Ready for setup · no host or game selected")); + } + + { + DeckFakeHostRepository offlineRepository; + offlineRepository.upsertSanitizedHostSummary(DeckHostSummary{ + .id = "matrix-offline-host", + .displayName = "Offline backend host", + .state = DeckHostState::Offline, + .endpointClass = DeckEndpointClass::Unknown, + .fixtureOnly = false, + .hasEndpointCandidate = false, + .polarisAvailable = true, + .standardAppListAvailable = true, + .publicStatusLabel = "Backend-owned offline summary · read-only", + .publicSubtitle = "Offline sanitized host state — no discovery, ping, endpoint, cert, or credential material was read.", + .publicProvenanceLabel = "offline/read-only/backend-owned", + }); + matrix.push_back(withMatrixLabels( + buildReadOnlyHostLibraryState( + offlineRepository, + firstGameLibrary, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::LaunchDryRun)), + "offline", + "Host offline · reconnect before preview")); + } + + { + DeckFakeHostRepository unpairedRepository; + unpairedRepository.upsertSanitizedHostSummary(DeckHostSummary{ + .id = "matrix-unpaired-host", + .displayName = "Unpaired backend host", + .state = DeckHostState::PairingNeeded, + .endpointClass = DeckEndpointClass::Manual, + .fixtureOnly = false, + .hasEndpointCandidate = true, + .polarisAvailable = true, + .standardAppListAvailable = true, + .publicStatusLabel = "Backend-owned unpaired summary · read-only", + .publicSubtitle = "Unpaired sanitized host state — pairing, token, certificate, and private-key reads stay disabled.", + .publicProvenanceLabel = "unpaired/read-only/backend-owned", + }); + matrix.push_back(withMatrixLabels( + buildReadOnlyHostLibraryState( + unpairedRepository, + firstGameLibrary, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::LaunchDryRun)), + "unpaired", + "Pair host · approved pairing required")); + } + + { + PolarisGameLibraryFixture unavailableLibrary = library; + unavailableLibrary.games.clear(); + unavailableLibrary.sourceLabel = "Backend read-only matrix unavailable library fixture"; + DeckFakeHostRepository libraryUnavailableRepository; + libraryUnavailableRepository.upsertSanitizedHostSummary(DeckHostSummary{ + .id = "matrix-library-unavailable-host", + .displayName = "Library unavailable host", + .state = DeckHostState::PairingNeeded, + .endpointClass = DeckEndpointClass::Manual, + .fixtureOnly = false, + .hasEndpointCandidate = true, + .polarisAvailable = false, + .standardAppListAvailable = false, + .publicStatusLabel = "Backend-owned library unavailable summary · read-only", + .publicSubtitle = "Library summary unavailable — no Polaris API request, credential read, or network retry was made.", + .publicProvenanceLabel = "library-unavailable/read-only/backend-owned", + }); + matrix.push_back(withMatrixLabels( + buildReadOnlyHostLibraryState( + libraryUnavailableRepository, + unavailableLibrary, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::LaunchDryRun)), + "library-unavailable", + "Library unavailable · read-only snapshot missing")); + } + + { + DeckFakeHostRepository labGatedRepository; + labGatedRepository.upsertSanitizedHostSummary(DeckHostSummary{ + .id = "matrix-lab-gated-host", + .displayName = "Lab-gated backend host", + .state = DeckHostState::Fixture, + .endpointClass = DeckEndpointClass::Unknown, + .fixtureOnly = true, + .hasEndpointCandidate = false, + .polarisAvailable = true, + .standardAppListAvailable = true, + .publicStatusLabel = "Backend-owned lab-gated summary · read-only", + .publicSubtitle = "Lab gate disabled — dry-run launch, stream start, discovery, pairing, and backend power remain off.", + .publicProvenanceLabel = "lab-gated/read-only/backend-owned", + }); + matrix.push_back(withMatrixLabels( + buildReadOnlyHostLibraryState( + labGatedRepository, + firstGameLibrary, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::Disabled)), + "lab-gated", + "Lab gate locked · start paths disabled")); + } + + return matrix; +} + +DeckFixtureReadOnlyStateProvider::DeckFixtureReadOnlyStateProvider( + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService) + : matrix_(buildReadOnlyHostLibraryStateMatrix(library, preflightService)) {} + +std::vector DeckFixtureReadOnlyStateProvider::stateMatrix() const { + std::vector matrix; + matrix.reserve(matrix_.size()); + for (auto state : matrix_) { + matrix.push_back(withDefaultPlayerState(std::move(state))); + } + return matrix; +} + +DeckPublicReadOnlyHostLibraryState DeckFixtureReadOnlyStateProvider::stateForScenario(const std::string_view scenarioId) const { + const auto found = std::find_if(matrix_.begin(), matrix_.end(), [scenarioId](const auto& state) { + return !scenarioId.empty() && state.scenarioId == scenarioId; + }); + if (found != matrix_.end()) { + return withDefaultPlayerState(*found); + } + + const auto fallback = std::find_if(matrix_.begin(), matrix_.end(), [](const auto& state) { + return state.scenarioId == "lab-gated"; + }); + if (fallback != matrix_.end()) { + return withDefaultPlayerState(*fallback); + } + return matrix_.empty() ? DeckPublicReadOnlyHostLibraryState{} : withDefaultPlayerState(matrix_.front()); +} + +DeckPublicReadOnlyHostLibraryState DeckFixtureReadOnlyStateProvider::withDefaultPlayerState(DeckPublicReadOnlyHostLibraryState state) const { + const auto defaults = playerStateFor(state.preflight, state.scenarioLabel); + if (state.playerState.title.empty()) { + state.playerState.title = defaults.title; + } + if (state.playerState.body.empty()) { + state.playerState.body = defaults.body; + } + if (state.playerState.actionLabel.empty()) { + state.playerState.actionLabel = defaults.actionLabel; + } + if (state.playerState.safetyLabel.empty()) { + state.playerState.safetyLabel = defaults.safetyLabel; + } + if (state.playerState.provenanceLabel.empty()) { + state.playerState.provenanceLabel = defaults.provenanceLabel; + } + if (state.playerState.focusOrder.empty()) { + state.playerState.focusOrder = defaults.focusOrder; + } + if (state.playerState.focusOrderCopy.empty()) { + state.playerState.focusOrderCopy = defaults.focusOrderCopy; + } + return state; +} + +std::string toPublicCode(const DeckPreflightBlockerCategory category) { + switch (category) { + case DeckPreflightBlockerCategory::FixtureOnly: + return "fixture-only"; + case DeckPreflightBlockerCategory::NetworkDisabled: + return "network-disabled"; + case DeckPreflightBlockerCategory::MissingHost: + return "missing-host"; + case DeckPreflightBlockerCategory::HostUnreachable: + return "host-unreachable"; + case DeckPreflightBlockerCategory::PairingRequired: + return "pairing-required"; + case DeckPreflightBlockerCategory::CertMismatch: + return "cert-mismatch"; + case DeckPreflightBlockerCategory::AuthRejected: + return "auth-rejected"; + case DeckPreflightBlockerCategory::LibraryUnavailable: + return "library-unavailable"; + case DeckPreflightBlockerCategory::AppNotFound: + return "app-not-found"; + case DeckPreflightBlockerCategory::SessionOwnedByAnotherClient: + return "session-owned-by-another-client"; + case DeckPreflightBlockerCategory::WatchNotAvailable: + return "watch-not-available"; + case DeckPreflightBlockerCategory::LaunchNotAllowed: + return "launch-not-allowed"; + case DeckPreflightBlockerCategory::LabGateDisabled: + return "lab-gate-disabled"; + case DeckPreflightBlockerCategory::RendererUnavailable: + return "renderer-unavailable"; + case DeckPreflightBlockerCategory::AudioUnavailable: + return "audio-unavailable"; + case DeckPreflightBlockerCategory::InputUnavailable: + return "input-unavailable"; + } + return "unknown-blocker"; +} + +} // namespace nova::deck::backend diff --git a/clients/deck/src/backend/deck_backend_interfaces.h b/clients/deck/src/backend/deck_backend_interfaces.h new file mode 100644 index 00000000..3637a9f7 --- /dev/null +++ b/clients/deck/src/backend/deck_backend_interfaces.h @@ -0,0 +1,360 @@ +#pragma once + +#include +#include +#include +#include + +namespace nova::deck { +struct PolarisGameLibraryFixture; +} // namespace nova::deck + +namespace nova::deck::backend { + +enum class DeckEndpointClass { + Unknown, + Local, + Manual, + Discovered, +}; + +enum class DeckHostState { + Fixture, + Manual, + Discovered, + Offline, + Online, + PairingNeeded, + Paired, + CertMismatch, + AuthRejected, + Unsupported, +}; + +struct DeckHostSummary { + std::string id; + std::string displayName; + DeckHostState state = DeckHostState::Fixture; + DeckEndpointClass endpointClass = DeckEndpointClass::Unknown; + bool fixtureOnly = true; + bool hasEndpointCandidate = false; + bool polarisAvailable = false; + bool standardAppListAvailable = false; + std::string publicStatusLabel; + std::string publicSubtitle; + std::string publicProvenanceLabel; + + // Backend-only test seam. Public models returned from DeckHostRepository clear this field. + std::string rawEndpointForBackendOnly; +}; + +struct DeckCredentialMetadata { + std::string hostId; + bool paired = false; + std::string pinnedCertFingerprint; + bool certMismatch = false; + bool authRejected = false; + + // Backend-only test seams. Public metadata facade clears these fields. + std::string rawTokenForBackendOnly; + std::string rawCertificateForBackendOnly; + std::string rawPrivateKeyForBackendOnly; +}; + +struct DeckLibraryAvailability { + bool available = false; + bool gameAvailable = false; + std::string sourceLabel; +}; + +struct DeckSessionSummary { + bool ownedByAnotherClient = false; + bool watchAvailable = false; +}; + +struct DeckBackendReadiness { + bool rendererAvailable = false; + bool audioAvailable = false; + bool inputAvailable = false; +}; + +enum class DeckLabGateMode { + Disabled, + ReadOnlyNetwork, + PairingLab, + LaunchDryRun, + LaunchLab, + StreamLab, +}; + +class DeckLabGate { +public: + static DeckLabGate forMode(DeckLabGateMode mode); + + [[nodiscard]] DeckLabGateMode mode() const; + [[nodiscard]] std::string modeLabel() const; + [[nodiscard]] bool networkReadAllowed() const; + [[nodiscard]] bool pairingAllowed() const; + [[nodiscard]] bool launchDryRunAllowed() const; + [[nodiscard]] bool hostLaunchAllowed() const; + [[nodiscard]] bool streamStartAllowed() const; + +private: + explicit DeckLabGate(DeckLabGateMode mode); + + DeckLabGateMode mode_ = DeckLabGateMode::Disabled; +}; + +enum class DeckPreflightBlockerCategory { + FixtureOnly, + NetworkDisabled, + MissingHost, + HostUnreachable, + PairingRequired, + CertMismatch, + AuthRejected, + LibraryUnavailable, + AppNotFound, + SessionOwnedByAnotherClient, + WatchNotAvailable, + LaunchNotAllowed, + LabGateDisabled, + RendererUnavailable, + AudioUnavailable, + InputUnavailable, +}; + +struct DeckPreflightBlocker { + DeckPreflightBlockerCategory category = DeckPreflightBlockerCategory::MissingHost; + std::string code; + std::string publicReason; +}; + +struct DeckCoordinatorRequest { + std::string hostId; + std::string gameId; + std::string profileId; + bool launchAllowed = false; + bool streamAllowed = false; + std::string publicPlan; +}; + +struct DeckPreflightReport { + bool approved = false; + std::vector blockers; + DeckCoordinatorRequest coordinatorRequest; + std::string publicCopy; +}; + +struct DeckLaunchPreflightInput { + std::optional host; + DeckCredentialMetadata credentials; + DeckLibraryAvailability library; + DeckSessionSummary session; + DeckBackendReadiness backendReadiness; + DeckLabGate labGate = DeckLabGate::forMode(DeckLabGateMode::Disabled); + std::string requestedGameId; + std::string requestedProfileId; + + // Backend-only test seam; never copied into public reports. + std::string requestUrlForBackendOnly; +}; + +class DeckHostRepository { +public: + virtual ~DeckHostRepository() = default; + [[nodiscard]] virtual std::vector listHosts() const = 0; + [[nodiscard]] virtual std::optional hostById(std::string_view hostId) const = 0; +}; + +class DeckFakeHostRepository final : public DeckHostRepository { +public: + void upsertFixtureHost(std::string id, std::string displayName); + void upsertSanitizedHostSummary(DeckHostSummary host); + void upsertManualHostForTest(std::string id, std::string displayName, std::string rawEndpoint); + + [[nodiscard]] std::vector listHosts() const override; + [[nodiscard]] std::optional hostById(std::string_view hostId) const override; + [[nodiscard]] std::string backendEndpointForTest(std::string_view hostId) const; + +private: + std::vector hosts_; +}; + +class DeckCredentialStore { +public: + void upsertMetadata(DeckCredentialMetadata metadata); + [[nodiscard]] std::optional metadataForHost(std::string_view hostId) const; + +private: + std::vector metadata_; +}; + +class DeckLaunchPreflightService { +public: + [[nodiscard]] DeckPreflightReport evaluate(const DeckLaunchPreflightInput& input) const; +}; + +struct DeckCoordinatorResult { + bool accepted = false; + bool networkStarted = false; + bool rawStartCalled = false; + std::string statusCode; + std::string publicCopy; +}; + +class DeckStreamSessionCoordinator { +public: + [[nodiscard]] DeckCoordinatorResult dryRun(const DeckCoordinatorRequest& request) const; +}; + +class DeckDiagnosticsModel { +public: + void updateHost(const DeckHostSummary& host); + void updateCredentials(const DeckCredentialMetadata& credentials); + void updateBackendReadiness(const DeckBackendReadiness& readiness); + void updatePreflight(const DeckPreflightReport& report); + void updateCoordinatorStatus(std::string statusCode); + + [[nodiscard]] std::string copyText() const; + +private: + std::string hostCategory_ = "host=unknown"; + std::string trustCategory_ = "trust=unknown"; + std::string backendCategory_ = "backend=unknown"; + std::string preflightCategory_ = "preflight=unknown"; + std::string coordinatorStatus_ = "coordinator=idle"; +}; + +struct DeckPublicBackendPreviewRequest { + std::string hostId; + std::string gameId; + std::string profileId; +}; + +struct DeckPublicPreflightPreview { + std::string statusCode; + bool approved = false; + std::vector blockerCodes; + bool launchDryRunAllowed = false; + bool streamAllowed = false; + bool backendPowerStarted = false; + std::string publicCopy; +}; + +struct DeckPublicDiagnosticsPreview { + std::string statusCode; + std::string privacyCode; + std::string copyText; +}; + +struct DeckPublicReadOnlyHostItem { + std::string id; + std::string displayName; + std::string statusLabel; + std::string subtitle; + std::string provenanceLabel; + bool initialFocus = false; +}; + +struct DeckPublicReadOnlyGameItem { + std::string id; + std::string title; + std::string sourceRuntimeLabel; + std::string launchModeLabel; + std::string installedLabel; + bool initialFocus = false; +}; + +struct DeckPublicReadOnlyPreflightState { + std::string statusCode; + std::vector blockerCodes; + bool launchDryRunAllowed = false; + bool streamAllowed = false; + bool backendPowerStarted = false; + std::string publicCopy; +}; + +struct DeckPublicReadOnlyDtoParity { + std::string contractId; + std::string ownerCode; + std::string privacyCode; + std::string readinessCode; + std::string collapsedSummary; + std::string expandedDiagnostics; + std::string artifactSummary; +}; + +struct DeckPublicReadOnlyPlayerState { + std::string title; + std::string body; + std::string actionLabel; + std::string safetyLabel; + std::string provenanceLabel; + std::string focusOrder; + std::string focusOrderCopy; +}; + +struct DeckPublicReadOnlyHostLibraryState { + std::string scenarioId; + std::string scenarioLabel; + std::string sourceLabel; + bool readOnly = true; + std::vector hosts; + std::vector games; + DeckPublicReadOnlyPreflightState preflight; + DeckPublicReadOnlyPlayerState playerState; + DeckPublicReadOnlyDtoParity dtoParity; +}; + +class DeckReadOnlyStateProvider { +public: + virtual ~DeckReadOnlyStateProvider() = default; + [[nodiscard]] virtual std::vector stateMatrix() const = 0; + [[nodiscard]] virtual DeckPublicReadOnlyHostLibraryState stateForScenario(std::string_view scenarioId) const = 0; +}; + +class DeckFixtureReadOnlyStateProvider final : public DeckReadOnlyStateProvider { +public: + DeckFixtureReadOnlyStateProvider( + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService); + + [[nodiscard]] std::vector stateMatrix() const override; + [[nodiscard]] DeckPublicReadOnlyHostLibraryState stateForScenario(std::string_view scenarioId) const override; + [[nodiscard]] DeckPublicReadOnlyHostLibraryState withDefaultPlayerState(DeckPublicReadOnlyHostLibraryState state) const; + +private: + std::vector matrix_; +}; + +[[nodiscard]] DeckPublicPreflightPreview requestDeckBackendPreflightPreview( + const DeckHostRepository& repository, + const DeckCredentialStore& credentialStore, + const DeckLaunchPreflightService& preflightService, + const DeckStreamSessionCoordinator& coordinator, + const DeckLabGate& labGate, + const DeckPublicBackendPreviewRequest& request); + +[[nodiscard]] DeckPublicDiagnosticsPreview requestDeckBackendDiagnosticsPreview( + const DeckHostRepository& repository, + const DeckCredentialStore& credentialStore, + const DeckLaunchPreflightService& preflightService, + const DeckStreamSessionCoordinator& coordinator, + DeckDiagnosticsModel& diagnostics, + const DeckLabGate& labGate, + const DeckPublicBackendPreviewRequest& request); + +[[nodiscard]] DeckPublicReadOnlyHostLibraryState buildReadOnlyHostLibraryState( + const DeckHostRepository& repository, + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService, + const DeckLabGate& labGate); + +[[nodiscard]] std::vector buildReadOnlyHostLibraryStateMatrix( + const PolarisGameLibraryFixture& library, + const DeckLaunchPreflightService& preflightService); + +[[nodiscard]] std::string toPublicCode(DeckPreflightBlockerCategory category); + +} // namespace nova::deck::backend diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 7e2d5bd5..96282bcc 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -1,24 +1,36 @@ #include "deck_layout.h" #include "deck_gamepad.h" #include "polaris_game_fixture.h" -#include "stream/deck_moonlight_handoff_preflight.h" +#include "backend/deck_backend_interfaces.h" +#include "stream/deck_stream_media_adapters.h" #include #include #include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include #include +#include +#include #include #include #include +#include #include #include #include #include +#include #ifdef __linux__ #include @@ -132,6 +144,350 @@ class QtLocalClipboardBridge final : public QObject { } }; + +class QtBackendPreviewBridge final : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap lastPreflightPreview READ lastPreflightPreview NOTIFY lastPreflightPreviewChanged) + Q_PROPERTY(QVariantMap lastDiagnosticsPreview READ lastDiagnosticsPreview NOTIFY lastDiagnosticsPreviewChanged) +public: + explicit QtBackendPreviewBridge(QObject* parent = nullptr) + : QObject(parent), labGate_(nova::deck::backend::DeckLabGate::forMode(nova::deck::backend::DeckLabGateMode::Disabled)) { + lastPreflightPreview_ = emptyPreflightModel(); + lastDiagnosticsPreview_ = emptyDiagnosticsModel(); + } + + void seedFixtureHost(std::string id, std::string displayName) { + if (id.empty()) { + return; + } + credentialStore_.upsertMetadata(nova::deck::backend::DeckCredentialMetadata{ + .hostId = id, + .paired = false, + }); + hostRepository_.upsertFixtureHost(std::move(id), std::move(displayName)); + } + + void seedReadOnlyHostSummary(nova::deck::backend::DeckHostSummary host) { + if (host.id.empty()) { + return; + } + credentialStore_.upsertMetadata(nova::deck::backend::DeckCredentialMetadata{ + .hostId = host.id, + .paired = false, + }); + hostRepository_.upsertSanitizedHostSummary(std::move(host)); + } + + [[nodiscard]] QVariantMap lastPreflightPreview() const { + return lastPreflightPreview_; + } + + [[nodiscard]] QVariantMap lastDiagnosticsPreview() const { + return lastDiagnosticsPreview_; + } + + Q_INVOKABLE QVariantMap requestBackendPreflightPreview(const QVariantMap& launchIntentPreview) { + const auto preview = nova::deck::backend::requestDeckBackendPreflightPreview( + hostRepository_, + credentialStore_, + preflightService_, + coordinator_, + labGate_, + requestFrom(launchIntentPreview)); + lastPreflightPreview_ = toPreflightModel(preview); + emit lastPreflightPreviewChanged(); + return lastPreflightPreview_; + } + + Q_INVOKABLE QVariantMap requestBackendDiagnosticsPreview(const QVariantMap& launchIntentPreview) { + const auto preview = nova::deck::backend::requestDeckBackendDiagnosticsPreview( + hostRepository_, + credentialStore_, + preflightService_, + coordinator_, + diagnostics_, + labGate_, + requestFrom(launchIntentPreview)); + lastDiagnosticsPreview_ = toDiagnosticsModel(preview); + emit lastDiagnosticsPreviewChanged(); + return lastDiagnosticsPreview_; + } + +signals: + void lastPreflightPreviewChanged(); + void lastDiagnosticsPreviewChanged(); + +private: + static nova::deck::backend::DeckPublicBackendPreviewRequest requestFrom(const QVariantMap& launchIntentPreview) { + return nova::deck::backend::DeckPublicBackendPreviewRequest{ + .hostId = launchIntentPreview.value(QStringLiteral("hostId")).toString().toStdString(), + .gameId = launchIntentPreview.value(QStringLiteral("gameId")).toString().toStdString(), + .profileId = launchIntentPreview.value(QStringLiteral("streamProfileId")).toString().toStdString(), + }; + } + + static QVariantMap emptyPreflightModel() { + QVariantMap model; + model.insert("statusCode", QStringLiteral("backend-preflight-not-requested")); + model.insert("approved", false); + model.insert("blockerCodes", QVariantList{}); + model.insert("launchDryRunAllowed", false); + model.insert("streamAllowed", false); + model.insert("backendPowerStarted", false); + model.insert("publicCopy", QStringLiteral("Backend preflight preview has not been requested.")); + return model; + } + + static QVariantMap emptyDiagnosticsModel() { + QVariantMap model; + model.insert("statusCode", QStringLiteral("backend-diagnostics-not-requested")); + model.insert("privacyCode", QStringLiteral("redacted-public-dto")); + model.insert("copyText", QStringLiteral("Backend diagnostics preview has not been requested.")); + return model; + } + + static QVariantMap toPreflightModel(const nova::deck::backend::DeckPublicPreflightPreview& preview) { + QVariantList blockerCodes; + for (const auto& code : preview.blockerCodes) { + blockerCodes.append(QString::fromStdString(code)); + } + + QVariantMap model; + model.insert("statusCode", QString::fromStdString(preview.statusCode)); + model.insert("approved", preview.approved); + model.insert("blockerCodes", blockerCodes); + model.insert("launchDryRunAllowed", preview.launchDryRunAllowed); + model.insert("streamAllowed", preview.streamAllowed); + model.insert("backendPowerStarted", preview.backendPowerStarted); + model.insert("publicCopy", QString::fromStdString(preview.publicCopy)); + return model; + } + + static QVariantMap toDiagnosticsModel(const nova::deck::backend::DeckPublicDiagnosticsPreview& preview) { + QVariantMap model; + model.insert("statusCode", QString::fromStdString(preview.statusCode)); + model.insert("privacyCode", QString::fromStdString(preview.privacyCode)); + model.insert("copyText", QString::fromStdString(preview.copyText)); + return model; + } + + nova::deck::backend::DeckFakeHostRepository hostRepository_; + nova::deck::backend::DeckCredentialStore credentialStore_; + nova::deck::backend::DeckLaunchPreflightService preflightService_; + nova::deck::backend::DeckStreamSessionCoordinator coordinator_; + nova::deck::backend::DeckDiagnosticsModel diagnostics_; + nova::deck::backend::DeckLabGate labGate_; + QVariantMap lastPreflightPreview_{}; + QVariantMap lastDiagnosticsPreview_{}; +}; + +class QtPreviewLifecycleBridge final : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap lastReport READ lastReport NOTIFY lastReportChanged) + Q_PROPERTY(QVariantMap operatorAuthorization READ operatorAuthorization NOTIFY operatorAuthorizationChanged) +public: + explicit QtPreviewLifecycleBridge( + nova::deck::stream::DeckGuardedPreviewLifecycleGate& lifecycleGate, + QObject* parent = nullptr) + : QObject(parent), lifecycleGate_(lifecycleGate) { + lastReportModel_ = toLifecycleReportModel(lifecycleGate_.lastReport()); + } + + [[nodiscard]] QVariantMap lastReport() const { + return lastReportModel_; + } + + [[nodiscard]] QVariantMap operatorAuthorization() const { + return toOperatorAuthorizationModel(operatorPolicy_.snapshot()); + } + + Q_INVOKABLE QVariantMap authorizeOperatorDryRun() { + operatorPolicy_.authorizeDryRun("deck-local-dry-run-operator-approved"); + emit operatorAuthorizationChanged(); + return operatorAuthorization(); + } + + Q_INVOKABLE QVariantMap authorizeOperatorStart() { + operatorPolicy_.authorizeStart("deck-local-start-operator-approved"); + emit operatorAuthorizationChanged(); + return operatorAuthorization(); + } + + Q_INVOKABLE QVariantMap armNoNetworkPreview(const QVariantMap& launchIntentPreview) { + const auto report = lifecycleGate_.armNoNetwork(nova::deck::stream::DeckStreamRequest{ + .hostId = launchIntentPreview.value(QStringLiteral("hostId")).toString().toStdString(), + .gameId = launchIntentPreview.value(QStringLiteral("gameId")).toString().toStdString(), + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + return updateLastReport(report, launchIntentPreview); + } + + Q_INVOKABLE QVariantMap requestGuardedHostNetworkStart(const QVariantMap& launchIntentPreview) { + return updateLastReport(lifecycleGate_.requestGuardedHostNetworkStart(), launchIntentPreview); + } + + Q_INVOKABLE QVariantMap requestOperatorAuthorizedDryRun(const QVariantMap& launchIntentPreview) { + return updateLastReport( + lifecycleGate_.requestOperatorAuthorizedDryRun(operatorPolicy_.snapshot()), + launchIntentPreview); + } + + Q_INVOKABLE QVariantMap requestHostStartDryRunPreflight(const QVariantMap& launchIntentPreview) { + const auto report = lifecycleGate_.requestHostStartDryRunPreflight( + operatorPolicy_.snapshot(), + nova::deck::stream::DeckStreamRequest{ + .hostId = launchIntentPreview.value(QStringLiteral("hostId")).toString().toStdString(), + .gameId = launchIntentPreview.value(QStringLiteral("gameId")).toString().toStdString(), + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + return updateLastReport(report, launchIntentPreview); + } + + Q_INVOKABLE QVariantMap requestOperatorAuthorizedHostNetworkStart(const QVariantMap& launchIntentPreview) { + return updateLastReport( + lifecycleGate_.requestOperatorAuthorizedHostNetworkStart(operatorPolicy_.snapshot()), + launchIntentPreview); + } + + Q_INVOKABLE QVariantMap stopPreview() { + return updateLastReport(lifecycleGate_.stop()); + } + +signals: + void lastReportChanged(); + void operatorAuthorizationChanged(); + +private: + static QString lifecycleStateLabel(const nova::deck::stream::DeckStreamSessionState state) { + using nova::deck::stream::DeckStreamSessionState; + switch (state) { + case DeckStreamSessionState::Idle: + return QStringLiteral("idle"); + case DeckStreamSessionState::Preparing: + return QStringLiteral("preparing"); + case DeckStreamSessionState::Starting: + return QStringLiteral("starting"); + case DeckStreamSessionState::Active: + return QStringLiteral("active"); + case DeckStreamSessionState::Stopping: + return QStringLiteral("stopping"); + case DeckStreamSessionState::Stopped: + return QStringLiteral("stopped"); + case DeckStreamSessionState::Cancelled: + return QStringLiteral("cancelled"); + case DeckStreamSessionState::Failed: + return QStringLiteral("failed"); + } + return QStringLiteral("unknown"); + } + + static QString requestSummaryForReport(const nova::deck::stream::DeckGuardedPreviewLifecycleReport& report) { + if (report.hostId.empty() && report.gameId.empty()) { + return QStringLiteral("No selected request has been armed yet."); + } + return QStringLiteral("host=%1 · game=%2 · %3x%4@%5 · %6 kbps · no-network") + .arg(QString::fromStdString(report.hostId), QString::fromStdString(report.gameId)) + .arg(report.width) + .arg(report.height) + .arg(report.fps) + .arg(report.bitrateKbps); + } + + static QString operatorAuthorizationStateLabel( + const nova::deck::stream::DeckOperatorStartAuthorizationMode mode) { + using nova::deck::stream::DeckOperatorStartAuthorizationMode; + switch (mode) { + case DeckOperatorStartAuthorizationMode::Blocked: + return QStringLiteral("blocked"); + case DeckOperatorStartAuthorizationMode::DryRunAuthorized: + return QStringLiteral("dry-run-authorized"); + case DeckOperatorStartAuthorizationMode::StartAuthorized: + return QStringLiteral("start-authorized"); + } + return QStringLiteral("blocked"); + } + + static QVariantMap toOperatorAuthorizationModel( + const nova::deck::stream::DeckOperatorStartAuthorizationSnapshot& authorization) { + QVariantMap model; + model.insert("state", operatorAuthorizationStateLabel(authorization.mode)); + model.insert("statusCode", QString::fromStdString(authorization.statusCode)); + model.insert("reason", QString::fromStdString(authorization.reason)); + model.insert("dryRunAuthorized", authorization.dryRunAuthorized); + model.insert("startAuthorized", authorization.startAuthorized); + model.insert("tokenless", authorization.tokenless); + model.insert("networkStarted", authorization.networkStarted); + return model; + } + + QString displayNameForReport( + const QVariantMap& launchIntentPreview, + const QString& reportId, + const QString& displayNameKey, + const QString& fallbackKey) const { + if (!reportId.isEmpty() && launchIntentPreview.value(fallbackKey).toString() == reportId) { + return launchIntentPreview.value(displayNameKey).toString(); + } + return lastReportModel_.value(displayNameKey).toString(); + } + + QVariantMap toLifecycleReportModel( + const nova::deck::stream::DeckGuardedPreviewLifecycleReport& report, + const QVariantMap& launchIntentPreview = QVariantMap{}) const { + const QString hostId = QString::fromStdString(report.hostId); + const QString gameId = QString::fromStdString(report.gameId); + QVariantMap model; + model.insert("state", lifecycleStateLabel(report.state)); + model.insert("statusCode", QString::fromStdString(report.statusCode)); + model.insert("reason", QString::fromStdString(report.reason)); + model.insert("hostId", hostId); + model.insert("hostDisplayName", displayNameForReport( + launchIntentPreview, + hostId, + QStringLiteral("hostDisplayName"), + QStringLiteral("hostId"))); + model.insert("gameId", gameId); + model.insert("gameTitle", displayNameForReport( + launchIntentPreview, + gameId, + QStringLiteral("gameTitle"), + QStringLiteral("gameId"))); + model.insert("width", report.width); + model.insert("height", report.height); + model.insert("fps", report.fps); + model.insert("bitrateKbps", report.bitrateKbps); + model.insert("requestSummary", requestSummaryForReport(report)); + model.insert("prepared", report.prepared); + model.insert("armed", report.armed); + model.insert("dryRunPreflightRequested", report.dryRunPreflightRequested); + model.insert("hostStartBoundaryExplicit", report.hostStartBoundaryExplicit); + model.insert("hostStartContractAuthorized", report.hostStartContractAuthorized); + model.insert("operatorAuthorizationState", QString::fromStdString(report.operatorAuthorizationState)); + model.insert("networkStartAllowed", report.networkStartAllowed); + model.insert("networkStarted", report.networkStarted); + model.insert("transitionCount", static_cast(report.transitionCount)); + return model; + } + + QVariantMap updateLastReport( + const nova::deck::stream::DeckGuardedPreviewLifecycleReport& report, + const QVariantMap& launchIntentPreview = QVariantMap{}) { + lastReportModel_ = toLifecycleReportModel(report, launchIntentPreview); + emit lastReportChanged(); + return lastReportModel_; + } + + nova::deck::stream::DeckGuardedPreviewLifecycleGate& lifecycleGate_; + nova::deck::stream::DeckOperatorStartAuthorizationPolicy operatorPolicy_{}; + QVariantMap lastReportModel_{}; +}; + QString toQString(const std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -147,6 +503,23 @@ QVariantList toHostModel(const std::vector& hosts) item.insert("id", toQString(host.id)); item.insert("displayName", toQString(host.displayName)); item.insert("statusLabel", toQString(host.statusLabel)); + item.insert("subtitle", QStringLiteral("Read-only snapshot host — backend-owned sanitized summary.")); + item.insert("provenanceLabel", QStringLiteral("legacy-layout-adapter")); + item.insert("initialFocus", host.initialFocus); + model.append(item); + } + return model; +} + +QVariantList toHostModel(const std::vector& hosts) { + QVariantList model; + for (const auto& host : hosts) { + QVariantMap item; + item.insert("id", toQString(host.id)); + item.insert("displayName", toQString(host.displayName)); + item.insert("statusLabel", toQString(host.statusLabel)); + item.insert("subtitle", toQString(host.subtitle)); + item.insert("provenanceLabel", toQString(host.provenanceLabel)); item.insert("initialFocus", host.initialFocus); model.append(item); } @@ -159,6 +532,17 @@ QVariantMap toHostDetailModel(const nova::deck::DeckHostDetail& detail) { model.insert("displayName", toQString(detail.displayName)); model.insert("statusLabel", toQString(detail.statusLabel)); model.insert("subtitle", toQString(detail.subtitle)); + model.insert("provenanceLabel", QStringLiteral("legacy-layout-adapter")); + return model; +} + +QVariantMap toHostDetailModel(const nova::deck::backend::DeckPublicReadOnlyHostItem& detail) { + QVariantMap model; + model.insert("id", toQString(detail.id)); + model.insert("displayName", toQString(detail.displayName)); + model.insert("statusLabel", toQString(detail.statusLabel)); + model.insert("subtitle", toQString(detail.subtitle)); + model.insert("provenanceLabel", toQString(detail.provenanceLabel)); return model; } @@ -181,6 +565,82 @@ QVariantList toLibraryGameModel(const std::vector& games) { + QVariantList model; + for (const auto& game : games) { + QVariantMap item; + item.insert("id", toQString(game.id)); + item.insert("title", toQString(game.title)); + item.insert("sourceRuntimeLabel", toQString(game.sourceRuntimeLabel)); + item.insert("launchModeLabel", toQString(game.launchModeLabel)); + item.insert("installedLabel", toQString(game.installedLabel)); + item.insert("initialFocus", game.initialFocus); + model.append(item); + } + return model; +} + +QVariantMap toReadOnlyPreflightModel(const nova::deck::backend::DeckPublicReadOnlyPreflightState& preflight) { + QVariantList blockerCodes; + for (const auto& code : preflight.blockerCodes) { + blockerCodes.append(toQString(code)); + } + QVariantMap model; + model.insert("statusCode", toQString(preflight.statusCode)); + model.insert("blockerCodes", blockerCodes); + model.insert("launchDryRunAllowed", preflight.launchDryRunAllowed); + model.insert("streamAllowed", preflight.streamAllowed); + model.insert("backendPowerStarted", preflight.backendPowerStarted); + model.insert("publicCopy", toQString(preflight.publicCopy)); + return model; +} + +QVariantMap toReadOnlyDtoParityModel(const nova::deck::backend::DeckPublicReadOnlyDtoParity& dtoParity) { + QVariantMap model; + model.insert("contractId", toQString(dtoParity.contractId)); + model.insert("ownerCode", toQString(dtoParity.ownerCode)); + model.insert("privacyCode", toQString(dtoParity.privacyCode)); + model.insert("readinessCode", toQString(dtoParity.readinessCode)); + model.insert("collapsedSummary", toQString(dtoParity.collapsedSummary)); + model.insert("expandedDiagnostics", toQString(dtoParity.expandedDiagnostics)); + model.insert("artifactSummary", toQString(dtoParity.artifactSummary)); + return model; +} + +QVariantMap toReadOnlyPlayerStateModel(const nova::deck::backend::DeckPublicReadOnlyPlayerState& playerState) { + QVariantMap model; + model.insert("title", toQString(playerState.title)); + model.insert("body", toQString(playerState.body)); + model.insert("actionLabel", toQString(playerState.actionLabel)); + model.insert("safetyLabel", toQString(playerState.safetyLabel)); + model.insert("provenanceLabel", toQString(playerState.provenanceLabel)); + model.insert("focusOrder", toQString(playerState.focusOrder)); + model.insert("focusOrderCopy", toQString(playerState.focusOrderCopy)); + return model; +} + +QVariantMap toReadOnlyStateModel(const nova::deck::backend::DeckPublicReadOnlyHostLibraryState& state) { + QVariantMap model; + model.insert("scenarioId", toQString(state.scenarioId)); + model.insert("scenarioLabel", toQString(state.scenarioLabel)); + model.insert("sourceLabel", toQString(state.sourceLabel)); + model.insert("readOnly", state.readOnly); + model.insert("hosts", toHostModel(state.hosts)); + model.insert("games", toLibraryGameModel(state.games)); + model.insert("preflight", toReadOnlyPreflightModel(state.preflight)); + model.insert("playerState", toReadOnlyPlayerStateModel(state.playerState)); + model.insert("dtoParity", toReadOnlyDtoParityModel(state.dtoParity)); + return model; +} + +QVariantList toReadOnlyStateMatrixModel(const std::vector& matrix) { + QVariantList model; + for (const auto& state : matrix) { + model.append(toReadOnlyStateModel(state)); + } + return model; +} + QVariantMap toLaunchCtaModel(const nova::deck::DeckLaunchCta& launchCta) { QVariantMap model; model.insert("id", toQString(launchCta.id)); @@ -248,198 +708,341 @@ QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyActi return model; } -QString moonlightHandoffVerdictLabel(const nova::deck::stream::DeckMoonlightHandoffVerdict verdict) { - using nova::deck::stream::DeckMoonlightHandoffVerdict; - switch (verdict) { - case DeckMoonlightHandoffVerdict::ReadyForReview: - return QStringLiteral("ready_for_review"); - case DeckMoonlightHandoffVerdict::BlockedStatic: - return QStringLiteral("blocked_static"); - case DeckMoonlightHandoffVerdict::ForbiddenRuntimeBoundary: - return QStringLiteral("forbidden_runtime_boundary"); - } - return QStringLiteral("unknown"); +QVariantMap toPresenterReadinessModel(const nova::deck::stream::DeckVaapiPresenterReadinessReport& report) { + QVariantMap model; + model.insert("statusCode", toQString(report.statusCode)); + model.insert("label", toQString(report.label)); + model.insert("detail", toQString(report.detail)); + model.insert("ready", report.ready); + model.insert("hardwarePresenterPlanned", report.hardwarePresenterPlanned); + model.insert("drmPrimeObjectCount", report.importPlan.drmPrimeObjectCount); + model.insert("drmPrimeLayerCount", report.importPlan.drmPrimeLayerCount); + return model; } -QString moonlightHandoffSurfaceLabel(const nova::deck::stream::DeckMoonlightHandoffSurface surface) { - using nova::deck::stream::DeckMoonlightHandoffSurface; - switch (surface) { - case DeckMoonlightHandoffSurface::MoonlightQtCli: - return QStringLiteral("moonlight_qt_cli"); - case DeckMoonlightHandoffSurface::HostAppSnapshot: - return QStringLiteral("host_app_snapshot"); - case DeckMoonlightHandoffSurface::DesktopEntry: - return QStringLiteral("desktop_entry"); - case DeckMoonlightHandoffSurface::FlatpakIdentity: - return QStringLiteral("flatpak_identity"); - case DeckMoonlightHandoffSurface::SteamShortcut: - return QStringLiteral("steam_shortcut"); - case DeckMoonlightHandoffSurface::CustomUri: - return QStringLiteral("custom_uri"); - case DeckMoonlightHandoffSurface::NovaOwnedCommonCFuture: - return QStringLiteral("nova_owned_common_c_future"); - case DeckMoonlightHandoffSurface::Unsupported: - return QStringLiteral("unsupported"); +int intArgumentAfter(const QStringList& arguments, const QString& flag, const int fallback) { + const int index = arguments.indexOf(flag); + if (index < 0 || index + 1 >= arguments.size()) { + return fallback; } - return QStringLiteral("unknown"); + bool ok = false; + const int value = arguments.at(index + 1).toInt(&ok); + return ok ? value : fallback; } -QVariantList toStringListModel(const std::vector& values) { - QVariantList model; - for (const auto& value : values) { - model.append(toQString(value)); +QString stringArgumentAfter(const QStringList& arguments, const QString& flag) { + const int index = arguments.indexOf(flag); + if (index < 0 || index + 1 >= arguments.size()) { + return QString{}; } - return model; + return arguments.at(index + 1); } -QString moonlightReadinessStatusLabel( - const nova::deck::stream::DeckMoonlightReadinessCheckStatus status) { - using nova::deck::stream::DeckMoonlightReadinessCheckStatus; - switch (status) { - case DeckMoonlightReadinessCheckStatus::Passed: - return QStringLiteral("passed"); - case DeckMoonlightReadinessCheckStatus::Blocked: - return QStringLiteral("blocked"); - case DeckMoonlightReadinessCheckStatus::ReviewOnly: - return QStringLiteral("review_only"); +void captureFrontendSmokeFrame(QQuickWindow* window, const QString& frontendSmokeCapturePath) { + if (window == nullptr || frontendSmokeCapturePath.isEmpty()) { + return; } - return QStringLiteral("unknown"); + QFileInfo captureInfo(frontendSmokeCapturePath); + if (!captureInfo.absoluteDir().exists()) { + QDir().mkpath(captureInfo.absolutePath()); + } + const QImage frame = window->grabWindow(); + if (!frame.isNull() && frame.save(frontendSmokeCapturePath)) { + qInfo().noquote() << "Nova Deck frontend smoke capture" + << frontendSmokeCapturePath + << QStringLiteral("size=%1x%2").arg(frame.width()).arg(frame.height()); + return; + } + qWarning().noquote() << "Nova Deck frontend smoke capture failed" << frontendSmokeCapturePath; } -QVariantList toMoonlightReadinessCheckModel( - const std::vector& checks) { - QVariantList model; - for (const auto& check : checks) { - QVariantMap item; - item.insert("id", toQString(check.id)); - item.insert("label", toQString(check.label)); - item.insert("detail", toQString(check.detail)); - item.insert("status", moonlightReadinessStatusLabel(check.status)); - model.append(item); - } - return model; +QString boolLabel(const QVariant& value) { + return value.toBool() ? QStringLiteral("true") : QStringLiteral("false"); } -QString argvPreviewFor(const std::vector& tokens) { - if (tokens.size() == 4) { - return QStringLiteral("Typed argv plan: app token + stream action + redacted host selector + ") - + toQString(tokens[3]); +void writeBackendDtoInteractionSmokeArtifact(QObject* rootObject, const QString& artifactPath) { + if (rootObject == nullptr || artifactPath.isEmpty()) { + return; } - return QStringLiteral("Typed argv plan unavailable until the preflight is ready for review."); -} -QVariantMap toMoonlightHandoffPreflightModel( - const nova::deck::stream::DeckMoonlightHandoffPreflightResult& result) { - QVariantMap model; - model.insert("verdict", moonlightHandoffVerdictLabel(result.verdict)); - model.insert("candidateSurface", moonlightHandoffSurfaceLabel(result.candidatePlan.surface)); - model.insert("publicPreviewCopy", toQString(result.publicPreviewCopy)); - model.insert("publicSummary", toQString(result.candidatePlan.publicSummary)); - model.insert("argvTokens", toStringListModel(result.candidatePlan.argvTokens)); - model.insert("argvTokenCount", static_cast(result.candidatePlan.argvTokens.size())); - model.insert("argvPreview", argvPreviewFor(result.candidatePlan.argvTokens)); - model.insert("readinessChecks", toMoonlightReadinessCheckModel(result.readinessChecks)); - model.insert("sourceSurface", toQString(result.focusReturnPlan.sourceSurface)); - model.insert("intendedReturnTarget", toQString(result.focusReturnPlan.intendedReturnTarget)); - model.insert("focusFallbackCopy", toQString(result.focusReturnPlan.fallbackCopy)); - model.insert("focusConfidence", toQString(result.focusReturnPlan.confidence)); - model.insert("safeToRender", result.safeToRender); - model.insert("executable", result.executable); - model.insert("allowsNetwork", result.allowsNetwork); - model.insert("allowsProcessExecution", result.allowsProcessExecution); - model.insert("allowsMoonlight", result.allowsMoonlight); - model.insert("allowsHostMutation", result.allowsHostMutation); - return model; + QVariant returned; + const bool invoked = QMetaObject::invokeMethod( + rootObject, + "runBackendDtoPreviewInteractionSmoke", + Q_RETURN_ARG(QVariant, returned)); + + QFileInfo artifactInfo(artifactPath); + if (!artifactInfo.absoluteDir().exists()) { + QDir().mkpath(artifactInfo.absolutePath()); + } + + QFile artifact(artifactPath); + if (!artifact.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + qWarning().noquote() << "Nova Deck backend-dto-interaction-smoke artifact failed" << artifactPath; + return; + } + + const QVariantMap report = returned.toMap(); + QTextStream stream(&artifact); + stream << "Nova Deck backend DTO interaction smoke\n"; + stream << "invoked=" << (invoked ? "true" : "false") << "\n"; + stream << "preflight_button=" << report.value(QStringLiteral("preflightButton")).toString() << "\n"; + stream << "diagnostics_button=" << report.value(QStringLiteral("diagnosticsButton")).toString() << "\n"; + stream << "preflight_status=" << report.value(QStringLiteral("preflightStatus")).toString() << "\n"; + stream << "preflight_blockers=" << report.value(QStringLiteral("preflightBlockerCodes")).toString() << "\n"; + stream << "preflight_launch_dry_run_allowed=" << boolLabel(report.value(QStringLiteral("preflightLaunchDryRunAllowed"))) << "\n"; + stream << "preflight_stream_allowed=" << boolLabel(report.value(QStringLiteral("preflightStreamAllowed"))) << "\n"; + stream << "preflight_backend_power_started=" << boolLabel(report.value(QStringLiteral("preflightBackendPowerStarted"))) << "\n"; + stream << "preflight_public_copy=" << report.value(QStringLiteral("preflightPublicCopy")).toString() << "\n"; + stream << "dto_contract=" << report.value(QStringLiteral("dtoContractId")).toString() << "\n"; + stream << "dto_owner=" << report.value(QStringLiteral("dtoOwnerCode")).toString() << "\n"; + stream << "dto_privacy=" << report.value(QStringLiteral("dtoPrivacyCode")).toString() << "\n"; + stream << "dto_readiness=" << report.value(QStringLiteral("dtoReadinessCode")).toString() << "\n"; + stream << "dto_collapsed_summary=" << report.value(QStringLiteral("dtoCollapsedSummary")).toString() << "\n"; + stream << "dto_player_state_provenance=" << report.value(QStringLiteral("playerStateProvenance")).toString() << "\n"; + stream << "dto_player_state_focus_order=" << report.value(QStringLiteral("playerStateFocusOrder")).toString() << "\n"; + stream << "dto_player_state_focus_order_copy=" << report.value(QStringLiteral("playerStateFocusOrderCopy")).toString() << "\n"; + stream << "diagnostics_status=" << report.value(QStringLiteral("diagnosticsStatus")).toString() << "\n"; + stream << "diagnostics_privacy=" << report.value(QStringLiteral("diagnosticsPrivacyCode")).toString() << "\n"; + stream << "diagnostics_copy=" << report.value(QStringLiteral("diagnosticsCopyText")).toString() << "\n"; + artifact.close(); + + qInfo().noquote() << "Nova Deck backend-dto-interaction-smoke artifact" << artifactPath; } -nova::deck::stream::DeckMoonlightHandoffPreflightResult resolveMoonlightHandoffPreflightFor( - const QString& hostDisplayNamePublic, - const QString& gameTitlePublic, - const bool hasSafeSnapshot, - const bool appPresentInSnapshot) { - return nova::deck::stream::resolveDeckMoonlightHandoffPreflight( - nova::deck::stream::DeckMoonlightHandoffPreflightRequest{ - .hostDisplayNamePublic = hostDisplayNamePublic.toStdString(), - .gameTitlePublic = gameTitlePublic.toStdString(), - .privateHostSelectorRedactedForDebug = "redacted-host-selector", - .requestedSurface = nova::deck::stream::DeckMoonlightHandoffSurface::MoonlightQtCli, - .hasSafeSnapshot = hasSafeSnapshot, - .appPresentInSnapshot = appPresentInSnapshot, - }); +void writeBackendReadOnlyStateMatrixSmokeArtifact(QObject* rootObject, const QString& artifactPath) { + if (rootObject == nullptr || artifactPath.isEmpty()) { + return; + } + + QVariant returned; + const bool invoked = QMetaObject::invokeMethod( + rootObject, + "runBackendReadOnlyStateMatrixSmoke", + Q_RETURN_ARG(QVariant, returned)); + + QFileInfo artifactInfo(artifactPath); + if (!artifactInfo.absoluteDir().exists()) { + QDir().mkpath(artifactInfo.absolutePath()); + } + + QFile artifact(artifactPath); + if (!artifact.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + qWarning().noquote() << "Nova Deck backend-readonly-state-matrix-smoke artifact failed" << artifactPath; + return; + } + + QTextStream stream(&artifact); + stream << "Nova Deck backend read-only state matrix smoke\n"; + stream << "invoked=" << (invoked ? "true" : "false") << "\n"; + const QVariantList states = returned.toList(); + for (const auto& row : states) { + const QVariantMap state = row.toMap(); + stream << "matrix_scenario=" << state.value(QStringLiteral("scenarioId")).toString() + << " label=" << state.value(QStringLiteral("scenarioLabel")).toString() + << " hosts=" << state.value(QStringLiteral("hostCount")).toInt() + << " games=" << state.value(QStringLiteral("gameCount")).toInt() + << " status=" << state.value(QStringLiteral("preflightStatus")).toString() + << " blockers=" << state.value(QStringLiteral("blockerCodes")).toString() + << " backendPowerStarted=" << boolLabel(state.value(QStringLiteral("backendPowerStarted"))) + << " dtoContract=" << state.value(QStringLiteral("dtoContractId")).toString() + << " dtoPrivacy=" << state.value(QStringLiteral("dtoPrivacyCode")).toString() + << " dtoReadiness=" << state.value(QStringLiteral("dtoReadinessCode")).toString() + << " primary=" << state.value(QStringLiteral("primaryBlockerCopy")).toString() + << " stateHeadline=" << state.value(QStringLiteral("productStateHeadline")).toString() + << " stateAction=" << state.value(QStringLiteral("productStateAction")).toString() + << " stateSafety=" << state.value(QStringLiteral("productStateSafety")).toString() + << " stateProvenance=" << state.value(QStringLiteral("productStateProvenance")).toString() + << " stateFocusOrder=" << state.value(QStringLiteral("productStateFocusOrder")).toString() + << " diagnostics=" << state.value(QStringLiteral("secondaryDiagnosticsCopy")).toString() + << " dtoParity=" << state.value(QStringLiteral("dtoParityDiagnostics")).toString() + << " collapsedFirstPaint=" << boolLabel(state.value(QStringLiteral("collapsedFirstPaint"))) + << " expansionToggle=" << state.value(QStringLiteral("expansionToggleObject")).toString() + << " controllerReachable=" << boolLabel(state.value(QStringLiteral("expansionToggleControllerReachable"))) + << " expandedVisible=" << boolLabel(state.value(QStringLiteral("expandedDiagnosticsVisible"))) + << " expandedDiagnosticsCopy=" << state.value(QStringLiteral("expandedDiagnosticsCopy")).toString() + << " expandedDtoParityCopy=" << state.value(QStringLiteral("expandedDtoParityCopy")).toString() + << "\n"; + } + artifact.close(); + + qInfo().noquote() << "Nova Deck backend-readonly-state-matrix-smoke artifact" << artifactPath; } -class QtMoonlightHandoffPreflightBridge final : public QObject { - Q_OBJECT -public: - using QObject::QObject; +void writeExpandedDiagnosticsFrameSmokeArtifact(QObject* rootObject, const QString& artifactPath) { + if (rootObject == nullptr || artifactPath.isEmpty()) { + return; + } + + QVariant returned; + const bool invoked = QMetaObject::invokeMethod( + rootObject, + "runExpandedDiagnosticsFrameSmoke", + Q_RETURN_ARG(QVariant, returned)); - Q_INVOKABLE QVariantMap resolve( - const QString& hostDisplayNamePublic, - const QString& gameTitlePublic, - const bool hasSafeSnapshot, - const bool appPresentInSnapshot) const { - return toMoonlightHandoffPreflightModel(resolveMoonlightHandoffPreflightFor( - hostDisplayNamePublic, - gameTitlePublic, - hasSafeSnapshot, - appPresentInSnapshot)); + QFileInfo artifactInfo(artifactPath); + if (!artifactInfo.absoluteDir().exists()) { + QDir().mkpath(artifactInfo.absolutePath()); } -}; + + QFile artifact(artifactPath); + if (!artifact.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + qWarning().noquote() << "Nova Deck expanded-diagnostics-frame-smoke artifact failed" << artifactPath; + return; + } + + const QVariantMap report = returned.toMap(); + QTextStream stream(&artifact); + stream << "Nova Deck expanded diagnostics frame smoke\n"; + stream << "invoked=" << (invoked ? "true" : "false") << "\n"; + stream << "liveExpandedBy=" << report.value(QStringLiteral("liveExpandedBy")).toString() << "\n"; + stream << "expandedFrameFocusTarget=" << report.value(QStringLiteral("expandedFrameFocusTarget")).toString() << "\n"; + stream << "expandedDiagnosticsLaneFocusTarget=" << report.value(QStringLiteral("expandedDiagnosticsLaneFocusTarget")).toString() << "\n"; + stream << "expandedDiagnosticsLaneReadable=" << boolLabel(report.value(QStringLiteral("expandedDiagnosticsLaneReadable"))) << "\n"; + stream << "expandedDensityRowsPaged=" << boolLabel(report.value(QStringLiteral("expandedDensityRowsPaged"))) << "\n"; + stream << "expandedDiagnosticsPageAffordanceVisible=" << boolLabel(report.value(QStringLiteral("expandedDiagnosticsPageAffordanceVisible"))) << "\n"; + stream << "expandedDiagnosticsPageAffordancePosition=" << report.value(QStringLiteral("expandedDiagnosticsPageAffordancePosition")).toString() << "\n"; + stream << "expandedDiagnosticsPageAffordanceText=" << report.value(QStringLiteral("expandedDiagnosticsPageAffordanceText")).toString() << "\n"; + stream << "expandedDiagnosticsScrollNavigationMoved=" << boolLabel(report.value(QStringLiteral("expandedDiagnosticsScrollNavigationMoved"))) << "\n"; + stream << "expandedDiagnosticsPostScrollCue=" << report.value(QStringLiteral("expandedDiagnosticsPostScrollCue")).toString() << "\n"; + stream << "expandedDiagnosticsPostScrollCueContrast=" << report.value(QStringLiteral("expandedDiagnosticsPostScrollCueContrast")).toString() << "\n"; + stream << "expandedDiagnosticsPostScrollCueSpacing=" << report.value(QStringLiteral("expandedDiagnosticsPostScrollCueSpacing")).toString() << "\n"; + stream << "expandedDiagnosticsPostScrollCueOverlapsBlocker=" << boolLabel(report.value(QStringLiteral("expandedDiagnosticsPostScrollCueOverlapsBlocker"))) << "\n"; + stream << "expandedDiagnosticsPostScrollTarget=" << report.value(QStringLiteral("expandedDiagnosticsPostScrollTarget")).toString() << "\n"; + stream << "expandedDiagnosticsFocusAffordance=" << report.value(QStringLiteral("expandedDiagnosticsFocusAffordance")).toString() << "\n"; + stream << "expandedDiagnosticsPage2Readable=" << boolLabel(report.value(QStringLiteral("expandedDiagnosticsPage2Readable"))) << "\n"; + stream << "expandedDiagnosticsLaneHeight=" << report.value(QStringLiteral("expandedDiagnosticsLaneHeight")).toInt() << "\n"; + stream << "expandedFrameReadable=" << boolLabel(report.value(QStringLiteral("expandedFrameReadable"))) << "\n"; + stream << "expandedFrameSanitized=" << boolLabel(report.value(QStringLiteral("expandedFrameSanitized"))) << "\n"; + stream << "expandedFrameFirstPaintCrowding=" << boolLabel(report.value(QStringLiteral("expandedFrameFirstPaintCrowding"))) << "\n"; + stream << "expandedDiagnosticsCopy=" << report.value(QStringLiteral("expandedDiagnosticsCopy")).toString() << "\n"; + stream << "expandedDtoParityCopy=" << report.value(QStringLiteral("expandedDtoParityCopy")).toString() << "\n"; + stream << "expandedPublicCopy=" << report.value(QStringLiteral("expandedPublicCopy")).toString() << "\n"; + stream << "expandedBlockersCopy=" << report.value(QStringLiteral("expandedBlockersCopy")).toString() << "\n"; + artifact.close(); + + qInfo().noquote() << "Nova Deck expanded-diagnostics-frame-smoke artifact" << artifactPath; +} } // namespace int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); + const QStringList appArguments = QCoreApplication::arguments(); const auto profile = nova::deck::defaultWindowProfile(); const auto sampleLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); - const auto libraryGames = nova::deck::libraryGameCardsFor(sampleLibrary); - const auto libraryHosts = nova::deck::libraryHostListStateFor(sampleLibrary); - const std::string initialGameId = sampleLibrary.games.empty() ? std::string{} : sampleLibrary.games.front().id; - const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( - libraryHosts, + const nova::deck::backend::DeckLaunchPreflightService readOnlyPreflightService; + const nova::deck::backend::DeckFixtureReadOnlyStateProvider readOnlyStateProvider( sampleLibrary, - nova::deck::initialHostFocusTarget(libraryHosts), + readOnlyPreflightService); + const auto backendReadOnlyStateMatrix = readOnlyStateProvider.stateMatrix(); + const QString selectedMatrixScenario = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-readonly-state")); + const auto backendReadOnlyState = readOnlyStateProvider.stateForScenario(selectedMatrixScenario.toStdString()); + std::vector launchPreviewHosts; + launchPreviewHosts.reserve(backendReadOnlyState.hosts.size()); + int launchPreviewHostRow = 0; + for (const auto& host : backendReadOnlyState.hosts) { + launchPreviewHosts.push_back(nova::deck::DeckHostListItem{ + .id = host.id, + .displayName = host.displayName, + .statusLabel = host.statusLabel, + .row = launchPreviewHostRow, + .initialFocus = host.initialFocus, + }); + ++launchPreviewHostRow; + } + auto selectedLaunchLibrary = sampleLibrary; + if (backendReadOnlyState.games.empty()) { + selectedLaunchLibrary.games.clear(); + } + const std::string initialGameId = selectedLaunchLibrary.games.empty() ? std::string{} : selectedLaunchLibrary.games.front().id; + const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( + launchPreviewHosts, + selectedLaunchLibrary, + nova::deck::initialHostFocusTarget(launchPreviewHosts), initialGameId); const auto& selectedHostDetail = selectedBinding.hostDetail; + const auto selectedHostDetailModel = backendReadOnlyState.hosts.empty() + ? toHostDetailModel(selectedHostDetail) + : toHostDetailModel(backendReadOnlyState.hosts.front()); const auto& launchIntent = selectedBinding.intent; const auto streamIntent = nova::deck::resolveStreamIntent(launchIntent); const auto& launchCta = selectedBinding.launchCta; const auto& launchPreviewCopyAction = selectedBinding.copyAction; QtLocalClipboardBridge localClipboard; - QtMoonlightHandoffPreflightBridge moonlightHandoffBridge; QtDeckGamepadBridge gamepadBridge; + QtBackendPreviewBridge backendPreview; + for (const auto& host : sampleLibrary.hosts) { + backendPreview.seedReadOnlyHostSummary(nova::deck::backend::DeckHostSummary{ + .id = host.id, + .displayName = host.displayName, + .state = nova::deck::backend::DeckHostState::Fixture, + .endpointClass = nova::deck::backend::DeckEndpointClass::Unknown, + .fixtureOnly = true, + .hasEndpointCandidate = false, + .polarisAvailable = true, + .standardAppListAvailable = true, + .publicStatusLabel = host.statusLabel.empty() ? "Backend-owned fixture summary · read-only" : host.statusLabel, + .publicSubtitle = "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read.", + .publicProvenanceLabel = "fixture/read-only/backend-owned", + }); + } + using nova::deck::stream::DeckGuardedPreviewLifecycleGate; + nova::deck::stream::DeckProductPreviewPipeline productPreviewPipeline; + nova::deck::stream::DeckGuardedStreamSessionPreviewProducer productPreviewProducer; + DeckGuardedPreviewLifecycleGate productPreviewLifecycleGate(productPreviewProducer); + productPreviewLifecycleGate.attachProductPreviewPipeline(productPreviewPipeline); + QtPreviewLifecycleBridge previewLifecycle(productPreviewLifecycleGate); + const auto mediaProbe = nova::deck::stream::DeckLinuxMediaProbe::detect(); + const auto presenterReadiness = nova::deck::stream::DeckVaapiEglImagePresenter::readinessReportForPlan( + nova::deck::stream::DeckQrhiVaapiImportPlan{ + .status = mediaProbe.runtimeVaapiDeviceAvailable + ? nova::deck::stream::DeckQrhiVaapiImportStatus::DeckTargetUnavailable + : nova::deck::stream::DeckQrhiVaapiImportStatus::NotAttempted, + .detail = mediaProbe.runtimeVaapiDeviceAvailable + ? std::string("Deck shell loaded; EGLImage presenter waits for a VAAPI frame and Qt Quick render target before host streaming starts") + : mediaProbe.runtimeStatus, + }); + qInfo().noquote() << "Nova Deck VAAPI/EGL presenter readiness" + << QString::fromStdString(presenterReadiness.statusCode) + << QString::fromStdString(presenterReadiness.detail); - const auto initialMoonlightHandoffPreflight = resolveMoonlightHandoffPreflightFor( - toQString(selectedBinding.selectedHostName), - toQString(selectedBinding.selectedGameTitle), - sampleLibrary.readOnly, - !sampleLibrary.games.empty()); + qmlRegisterType("Nova.Deck.Stream", 0, 1, "DeckVaapiPreviewSurface"); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); engine.rootContext()->setContextProperty("novaDeckWidth", profile.width); engine.rootContext()->setContextProperty("novaDeckHeight", profile.height); engine.rootContext()->setContextProperty("novaDeckFullscreenPreferred", profile.fullscreenPreferred); - engine.rootContext()->setContextProperty("novaLibraryFixtureSource", toQString(sampleLibrary.sourceLabel)); - engine.rootContext()->setContextProperty("novaLibraryReadOnly", sampleLibrary.readOnly); - engine.rootContext()->setContextProperty("novaLibraryGames", toLibraryGameModel(libraryGames)); - engine.rootContext()->setContextProperty("novaLibraryHosts", toHostModel(libraryHosts)); - engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); + engine.rootContext()->setContextProperty("novaBackendReadOnlyStateMatrix", toReadOnlyStateMatrixModel(backendReadOnlyStateMatrix)); + engine.rootContext()->setContextProperty("novaBackendReadOnlyState", toReadOnlyStateModel(backendReadOnlyState)); + engine.rootContext()->setContextProperty("novaLibraryFixtureSource", toQString(backendReadOnlyState.sourceLabel)); + engine.rootContext()->setContextProperty("novaLibraryReadOnly", backendReadOnlyState.readOnly); + engine.rootContext()->setContextProperty("novaLibraryGames", toLibraryGameModel(backendReadOnlyState.games)); + engine.rootContext()->setContextProperty("novaLibraryHosts", toHostModel(backendReadOnlyState.hosts)); + engine.rootContext()->setContextProperty("novaSelectedHostDetail", selectedHostDetailModel); engine.rootContext()->setContextProperty("novaSelectedGameCard", toLibraryGameCardModel(selectedBinding.gameCard)); engine.rootContext()->setContextProperty("novaSelectedLaunchPreviewText", toQString(selectedBinding.preview.text)); engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary)); engine.rootContext()->setContextProperty("novaLaunchIntentPreview", toLaunchIntentPreviewModel(launchIntent, streamIntent)); engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); - engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflight", toMoonlightHandoffPreflightModel(initialMoonlightHandoffPreflight)); - engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflightBridge", &moonlightHandoffBridge); + engine.rootContext()->setContextProperty("novaPresenterReadiness", toPresenterReadinessModel(presenterReadiness)); + engine.rootContext()->setContextProperty("novaPreviewLifecycle", &previewLifecycle); + engine.rootContext()->setContextProperty("novaBackendPreview", &backendPreview); engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard); engine.rootContext()->setContextProperty("novaGamepad", &gamepadBridge); - engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(libraryHosts))); + engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(launchPreviewHosts))); engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); - const bool smokeExit = QCoreApplication::arguments().contains("--smoke-exit"); + const bool smokeExit = appArguments.contains("--smoke-exit"); + const int frontendSmokeExitAfterMs = intArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-exit-after-ms"), 0); + const QString frontendSmokeCapturePath = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-capture")); + const QString backendDtoInteractionSmokePath = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-backend-dto-interactions")); + const QString backendReadOnlyStateMatrixSmokePath = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-readonly-state-matrix")); + const QString expandedDiagnosticsFrameSmokePath = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-expanded-diagnostics-frame")); + const QString expandedDiagnosticsCapturePath = stringArgumentAfter(appArguments, QStringLiteral("--frontend-smoke-expanded-diagnostics-capture")); QObject::connect( &engine, @@ -452,7 +1055,60 @@ int main(int argc, char *argv[]) { &engine, &QQmlApplicationEngine::objectCreated, &app, - [smokeExit, &app](QObject *object) { + [smokeExit, frontendSmokeExitAfterMs, frontendSmokeCapturePath, backendDtoInteractionSmokePath, backendReadOnlyStateMatrixSmokePath, expandedDiagnosticsFrameSmokePath, expandedDiagnosticsCapturePath, &app, &productPreviewPipeline](QObject *object) { + if (object != nullptr) { + QObject* previewSurfaceObject = object->findChild("nova-product-preview-surface"); + if (auto* previewSurface = dynamic_cast(previewSurfaceObject)) { + productPreviewPipeline.attachBorrowedSink(previewSurface); + QObject::connect(previewSurface, &QObject::destroyed, &app, [&productPreviewPipeline]() { + productPreviewPipeline.attachBorrowedSink(nullptr); + }); + qInfo().noquote() << "Nova Deck product preview fixture pump" + << "decoded-frame-sink-attached" + << "product preview surface is attached; waiting for a real decoded hardware frame from the Deck media adapter before reporting render readiness"; + } else { + qInfo().noquote() << "Nova Deck product preview fixture pump" + << "deck-target-unavailable" + << "product preview surface object was not created by QML"; + } + } + if (!frontendSmokeCapturePath.isEmpty() && object != nullptr) { + if (auto* window = qobject_cast(object)) { + QTimer::singleShot(1000, &app, [window, frontendSmokeCapturePath]() { + captureFrontendSmokeFrame(window, frontendSmokeCapturePath); + }); + } else { + qWarning().noquote() << "Nova Deck frontend smoke capture failed: root object is not a QQuickWindow"; + } + } + if (!backendDtoInteractionSmokePath.isEmpty() && object != nullptr) { + QTimer::singleShot(500, &app, [object, backendDtoInteractionSmokePath]() { + writeBackendDtoInteractionSmokeArtifact(object, backendDtoInteractionSmokePath); + }); + } + if (!backendReadOnlyStateMatrixSmokePath.isEmpty() && object != nullptr) { + QTimer::singleShot(650, &app, [object, backendReadOnlyStateMatrixSmokePath]() { + writeBackendReadOnlyStateMatrixSmokeArtifact(object, backendReadOnlyStateMatrixSmokePath); + }); + } + if (!expandedDiagnosticsFrameSmokePath.isEmpty() && object != nullptr) { + QTimer::singleShot(1300, &app, [object, expandedDiagnosticsFrameSmokePath]() { + writeExpandedDiagnosticsFrameSmokeArtifact(object, expandedDiagnosticsFrameSmokePath); + }); + } + if (!expandedDiagnosticsCapturePath.isEmpty() && object != nullptr) { + if (auto* window = qobject_cast(object)) { + QTimer::singleShot(1700, &app, [window, expandedDiagnosticsCapturePath]() { + captureFrontendSmokeFrame(window, expandedDiagnosticsCapturePath); + }); + } else { + qWarning().noquote() << "Nova Deck expanded diagnostics capture failed: root object is not a QQuickWindow"; + } + } + if (frontendSmokeExitAfterMs > 0 && object != nullptr) { + QTimer::singleShot(frontendSmokeExitAfterMs, &app, &QCoreApplication::quit); + return; + } if (smokeExit && object != nullptr) { QTimer::singleShot(0, &app, &QCoreApplication::quit); } diff --git a/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp b/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp deleted file mode 100644 index 1b6e2833..00000000 --- a/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp +++ /dev/null @@ -1,310 +0,0 @@ -#include "stream/deck_moonlight_handoff_preflight.h" - -#include -#include -#include -#include -#include -#include - -namespace nova::deck::stream { - -namespace { - -bool isBlank(const std::string& value) { - return std::all_of(value.begin(), value.end(), [](const unsigned char ch) { - return std::isspace(ch) != 0; - }); -} - -std::string lowerCopy(const std::string& value) { - std::string lowered; - lowered.reserve(value.size()); - for (const unsigned char ch : value) { - lowered.push_back(static_cast(std::tolower(ch))); - } - return lowered; -} - -bool containsAny(const std::string& value, const std::initializer_list needles) { - return std::any_of(needles.begin(), needles.end(), [&](const std::string_view needle) { - return value.find(needle) != std::string::npos; - }); -} - -bool containsShellSyntax(const std::string& value) { - return containsAny(value, {";", "&&", "||", "`", "$(", "\n", "\r"}); -} - -bool containsPrivateEndpointLikeValue(const std::string& value) { - static const std::regex privateIpv4Pattern( - R"(\b(?:10|127|169\.254|172\.(?:1[6-9]|2[0-9]|3[0-1])|192\.168)\.\d{1,3}\.\d{1,3}\b)"); - static const std::regex macLikePattern(R"(\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b)"); - return std::regex_search(value, privateIpv4Pattern) || std::regex_search(value, macLikePattern); -} - -bool containsUnsafeSecretLikeText(const std::string& lowered) { - for (const std::string_view label : {"token", "password", "client_secret", "api_key"}) { - if (lowered.find(std::string{label} + "=") != std::string::npos - || lowered.find(std::string{label} + ":") != std::string::npos) { - return true; - } - } - return false; -} - -bool containsUnsafeSchemeOrPath(const std::string& value) { - const auto lowered = lowerCopy(value); - const auto unixHomePathMarker = std::string{"/ho"} + "me/"; - return containsAny(lowered, { - "://", - "ssh ", - "file:", - "/users/", - ".ssh/", - "begin ", - " private key", - ":matrix", - }) || lowered.find(unixHomePathMarker) != std::string::npos - || (!value.empty() && value.front() == '!'); -} - -bool isUnsafePublicText(const std::string& value) { - const auto lowered = lowerCopy(value); - return containsShellSyntax(value) - || containsPrivateEndpointLikeValue(value) - || containsUnsafeSecretLikeText(lowered) - || containsUnsafeSchemeOrPath(value); -} - -bool isUnsafeArgvToken(const std::string& value) { - return isBlank(value) - || containsShellSyntax(value) - || containsPrivateEndpointLikeValue(value) - || containsUnsafeSchemeOrPath(value) - || containsUnsafeSecretLikeText(lowerCopy(value)); -} - -DeckMoonlightReadinessCheck readinessCheck( - std::string id, - std::string label, - DeckMoonlightReadinessCheckStatus status, - std::string detail) { - return DeckMoonlightReadinessCheck{ - .id = std::move(id), - .label = std::move(label), - .detail = std::move(detail), - .status = status, - }; -} - -std::vector readinessChecksFor( - const DeckMoonlightHandoffPreflightRequest& request) { - std::vector checks; - checks.reserve(4); - - checks.push_back(readinessCheck( - "safe-snapshot", - "Safe snapshot", - request.hasSafeSnapshot ? DeckMoonlightReadinessCheckStatus::Passed : DeckMoonlightReadinessCheckStatus::Blocked, - request.hasSafeSnapshot - ? "Read-only host snapshot is available for local review." - : "Needs safe host snapshot before typed handoff review.")); - - checks.push_back(readinessCheck( - "app-snapshot", - "App in snapshot", - request.appPresentInSnapshot ? DeckMoonlightReadinessCheckStatus::Passed : DeckMoonlightReadinessCheckStatus::Blocked, - request.appPresentInSnapshot - ? "Game appears in snapshot for local review." - : "Game missing from snapshot; review stays blocked.")); - - const auto privateHostSelector = isBlank(request.privateHostSelectorRedactedForDebug) - ? std::string{"redacted-host-selector"} - : request.privateHostSelectorRedactedForDebug; - DeckMoonlightReadinessCheckStatus argvStatus = DeckMoonlightReadinessCheckStatus::Passed; - std::string argvDetail = "Typed argv preview is redacted and copy-only."; - if (!request.hasSafeSnapshot) { - argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; - argvDetail = "Snapshot gate must pass first; no typed handoff review yet."; - } else if (!request.appPresentInSnapshot) { - argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; - argvDetail = "App snapshot gate must pass first; no typed handoff review yet."; - } else if (request.requestedSurface != DeckMoonlightHandoffSurface::MoonlightQtCli) { - argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; - argvDetail = "Moonlight Qt CLI surface is required for typed handoff review."; - } else if (isUnsafePublicText(request.hostDisplayNamePublic) || isUnsafePublicText(request.gameTitlePublic) - || isUnsafeArgvToken(privateHostSelector)) { - argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; - argvDetail = "Typed argv preview is not public-safe; review stays blocked."; - } - checks.push_back(readinessCheck( - "typed-argv", - "Typed argv", - argvStatus, - argvDetail)); - - checks.push_back(readinessCheck( - "focus-return", - "Focus return", - DeckMoonlightReadinessCheckStatus::ReviewOnly, - "Focus return remains unproven_static until a later approved runtime check.")); - - return checks; -} - -DeckMoonlightFocusReturnPlan focusReturnPlanFor(const DeckMoonlightHandoffPreflightRequest& request) { - const auto target = (!isBlank(request.hostDisplayNamePublic) && !isBlank(request.gameTitlePublic)) - ? request.hostDisplayNamePublic + " / " + request.gameTitlePublic - : std::string{"selected Nova Deck review target"}; - return DeckMoonlightFocusReturnPlan{ - .sourceSurface = "Nova Deck preview review", - .intendedReturnTarget = target, - .fallbackCopy = "Return to Nova and keep this preview available after a later approved launch exits or fails.", - .confidence = "unproven_static", - }; -} - -DeckMoonlightHandoffPreflightResult baseResult(const DeckMoonlightHandoffPreflightRequest& request) { - DeckMoonlightHandoffPreflightResult result; - result.focusReturnPlan = focusReturnPlanFor(request); - result.readinessChecks = readinessChecksFor(request); - return result; -} - -DeckMoonlightHandoffPreflightResult blocked( - const DeckMoonlightHandoffPreflightRequest& request, - std::vector reasons, - std::string publicCopy, - const DeckMoonlightHandoffVerdict verdict = DeckMoonlightHandoffVerdict::BlockedStatic) { - auto result = baseResult(request); - result.verdict = verdict; - result.blockedReasons = std::move(reasons); - result.publicPreviewCopy = std::move(publicCopy); - result.candidatePlan.surface = request.requestedSurface; - result.candidatePlan.publicSummary = result.publicPreviewCopy; - result.safeToRender = !isUnsafePublicText(result.publicPreviewCopy); - return result; -} - -} // namespace - -DeckMoonlightHandoffPreflightResult resolveDeckMoonlightHandoffPreflight( - const DeckMoonlightHandoffPreflightRequest& request) { - if (isBlank(request.hostDisplayNamePublic)) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::MissingHost}, - "Nova needs a public host label before reviewing a Moonlight handoff. Nothing will launch yet."); - } - - if (isBlank(request.gameTitlePublic)) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::MissingGame}, - "Nova needs a public game title before reviewing a Moonlight handoff. Nothing will launch yet."); - } - - if (isUnsafePublicText(request.hostDisplayNamePublic) || isUnsafePublicText(request.gameTitlePublic)) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::UnsafePublicCopy}, - "Nova blocked this Moonlight handoff preview because public copy contains unsafe private or shell-like text. Nothing will launch yet."); - } - - if (request.requestedSurface == DeckMoonlightHandoffSurface::NovaOwnedCommonCFuture) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::ForbiddenRuntimeBoundary}, - "Nova-owned streaming belongs behind a later approved runtime lane. Nothing will launch yet.", - DeckMoonlightHandoffVerdict::ForbiddenRuntimeBoundary); - } - - if (request.requestedSurface == DeckMoonlightHandoffSurface::CustomUri) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::CustomUriNotStreamHandler}, - "Custom URI handoff is blocked: research has not proven a stream-launch handler. Nothing will launch yet."); - } - - if (request.requestedSurface == DeckMoonlightHandoffSurface::DesktopEntry) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::DesktopEntryNotStreamContract, DeckMoonlightHandoffBlockReason::ResearchNeeded}, - "Desktop entry handoff is research-only: it identifies the app shell, not a host/game stream. Nothing will launch yet."); - } - - if (request.requestedSurface == DeckMoonlightHandoffSurface::FlatpakIdentity) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::FlatpakContractUnproven, DeckMoonlightHandoffBlockReason::ResearchNeeded}, - "Flatpak identity handoff is research-only: package identity is known, but argument forwarding is unproven. Nothing will launch yet."); - } - - if (request.requestedSurface == DeckMoonlightHandoffSurface::SteamShortcut) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::SteamShortcutRuntimeOnly, DeckMoonlightHandoffBlockReason::ResearchNeeded}, - "Steam shortcut handoff is research-only: Game Mode launch behavior needs a later approved runtime check. Nothing will launch yet."); - } - - if (request.requestedSurface != DeckMoonlightHandoffSurface::MoonlightQtCli) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::UnsupportedSurface}, - "This handoff surface is not supported by the local-only Moonlight preflight. Nothing will launch yet."); - } - - if (!request.hasSafeSnapshot) { - return blocked( - request, - { - DeckMoonlightHandoffBlockReason::HostSnapshotMissing, - DeckMoonlightHandoffBlockReason::HostPairingUnprovenStatic, - DeckMoonlightHandoffBlockReason::FocusReturnUnprovenStatic, - }, - "Nova cannot verify Moonlight readiness without a prior safe snapshot or a later approved runtime check. Nothing will launch yet."); - } - - if (!request.appPresentInSnapshot) { - return blocked( - request, - { - DeckMoonlightHandoffBlockReason::AppNotInSnapshot, - DeckMoonlightHandoffBlockReason::HostPairingUnprovenStatic, - }, - "Nova cannot verify that this game exists in the safe host snapshot. Nothing will launch yet."); - } - - const auto privateHostSelector = isBlank(request.privateHostSelectorRedactedForDebug) - ? std::string{"redacted-host-selector"} - : request.privateHostSelectorRedactedForDebug; - if (isUnsafeArgvToken(privateHostSelector)) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::UnsafeArgvToken}, - "Nova blocked this Moonlight handoff preview because the private host selector is not safe as typed argv data. Nothing will launch yet."); - } - - auto result = baseResult(request); - result.verdict = DeckMoonlightHandoffVerdict::ReadyForReview; - result.safeToRender = true; - result.candidatePlan.surface = DeckMoonlightHandoffSurface::MoonlightQtCli; - result.candidatePlan.argvTokens = {"moonlight", "stream", privateHostSelector, request.gameTitlePublic}; - result.publicPreviewCopy = "Ready to review Moonlight handoff for " - + request.hostDisplayNamePublic - + " / " - + request.gameTitlePublic - + ". Nothing will launch yet."; - result.candidatePlan.publicSummary = result.publicPreviewCopy; - result.safeToRender = !isUnsafePublicText(result.publicPreviewCopy); - if (!result.safeToRender) { - return blocked( - request, - {DeckMoonlightHandoffBlockReason::UnsafePublicCopy}, - "Nova blocked this Moonlight handoff preview because the public review copy is not safe to render. Nothing will launch yet."); - } - return result; -} - -} // namespace nova::deck::stream diff --git a/clients/deck/src/stream/deck_moonlight_handoff_preflight.h b/clients/deck/src/stream/deck_moonlight_handoff_preflight.h deleted file mode 100644 index 7f5690ea..00000000 --- a/clients/deck/src/stream/deck_moonlight_handoff_preflight.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include -#include - -namespace nova::deck::stream { - -enum class DeckMoonlightHandoffSurface { - MoonlightQtCli, - HostAppSnapshot, - DesktopEntry, - FlatpakIdentity, - SteamShortcut, - CustomUri, - NovaOwnedCommonCFuture, - Unsupported, -}; - -enum class DeckMoonlightHandoffVerdict { - ReadyForReview, - BlockedStatic, - ForbiddenRuntimeBoundary, -}; - -enum class DeckMoonlightHandoffBlockReason { - MissingHost, - MissingGame, - UnsupportedSurface, - HostSnapshotMissing, - HostPairingUnprovenStatic, - AppNotInSnapshot, - UnsafePublicCopy, - UnsafeArgvToken, - CustomUriNotStreamHandler, - DesktopEntryNotStreamContract, - FlatpakContractUnproven, - SteamShortcutRuntimeOnly, - FocusReturnUnprovenStatic, - ResearchNeeded, - ForbiddenRuntimeBoundary, -}; - -struct DeckMoonlightHandoffPreflightRequest { - std::string hostDisplayNamePublic; - std::string gameTitlePublic; - std::string privateHostSelectorRedactedForDebug; - DeckMoonlightHandoffSurface requestedSurface = DeckMoonlightHandoffSurface::Unsupported; - bool hasSafeSnapshot = false; - bool appPresentInSnapshot = false; -}; - -struct DeckMoonlightHandoffCandidatePlan { - DeckMoonlightHandoffSurface surface = DeckMoonlightHandoffSurface::Unsupported; - std::vector argvTokens; - std::string publicSummary; -}; - -struct DeckMoonlightFocusReturnPlan { - std::string sourceSurface; - std::string intendedReturnTarget; - std::string fallbackCopy; - std::string confidence; -}; - -enum class DeckMoonlightReadinessCheckStatus { - Passed, - Blocked, - ReviewOnly, -}; - -struct DeckMoonlightReadinessCheck { - std::string id; - std::string label; - std::string detail; - DeckMoonlightReadinessCheckStatus status = DeckMoonlightReadinessCheckStatus::ReviewOnly; -}; - -struct DeckMoonlightHandoffPreflightResult { - DeckMoonlightHandoffVerdict verdict = DeckMoonlightHandoffVerdict::BlockedStatic; - bool executable = false; - bool allowsNetwork = false; - bool allowsProcessExecution = false; - bool allowsMoonlight = false; - bool allowsHostMutation = false; - bool safeToRender = false; - DeckMoonlightHandoffCandidatePlan candidatePlan; - DeckMoonlightFocusReturnPlan focusReturnPlan; - std::vector readinessChecks; - std::string publicPreviewCopy; - std::vector blockedReasons; -}; - -DeckMoonlightHandoffPreflightResult resolveDeckMoonlightHandoffPreflight( - const DeckMoonlightHandoffPreflightRequest& request); - -} // namespace nova::deck::stream diff --git a/clients/deck/src/stream/deck_stream_core.cpp b/clients/deck/src/stream/deck_stream_core.cpp index 7ebcf7c6..60f5ab7a 100644 --- a/clients/deck/src/stream/deck_stream_core.cpp +++ b/clients/deck/src/stream/deck_stream_core.cpp @@ -823,7 +823,7 @@ DeckStreamTransition DeckStreamSession::startNoNetwork() { return fail("start requested before prepare"); } - transitionTo(DeckStreamSessionState::Starting, "start requested; no-network skeleton keeps LiStartConnection disabled"); + transitionTo(DeckStreamSessionState::Starting, "start requested; no-network skeleton keeps raw stream start disabled"); return transitionTo(DeckStreamSessionState::Active, "active skeleton session; no sockets or host connection opened"); } diff --git a/clients/deck/src/stream/deck_stream_media_adapters.cpp b/clients/deck/src/stream/deck_stream_media_adapters.cpp index b678e29c..c09a05a4 100644 --- a/clients/deck/src/stream/deck_stream_media_adapters.cpp +++ b/clients/deck/src/stream/deck_stream_media_adapters.cpp @@ -4,6 +4,7 @@ extern "C" { #include #include #include +#include #include #include #include @@ -13,11 +14,28 @@ extern "C" { } #include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include +#include +#include #include namespace nova::deck::stream { @@ -58,8 +76,920 @@ std::vector copyDecodeUnitBytes(PDECODE_UNIT decodeUnit) { return bytes; } + +bool extensionListContains(const char* extensionList, const char* extension) { + if (extensionList == nullptr || extension == nullptr || *extension == '\0') { + return false; + } + const char* cursor = extensionList; + const std::size_t extensionLength = std::strlen(extension); + while ((cursor = std::strstr(cursor, extension)) != nullptr) { + const bool startsAtBoundary = cursor == extensionList || *(cursor - 1) == ' '; + const char after = cursor[extensionLength]; + if (startsAtBoundary && (after == '\0' || after == ' ')) { + return true; + } + cursor += extensionLength; + } + return false; +} + +bool drmPrimeDescriptorHasExplicitModifier(const DeckQrhiVaapiDrmPrimeDescriptor& descriptor) { + for (int objectIndex = 0; objectIndex < descriptor.objectCount; ++objectIndex) { + if (descriptor.objects[objectIndex].formatModifier != DRM_FORMAT_MOD_INVALID) { + return true; + } + } + return false; +} + +using EglCreateImageKhr = EGLImageKHR (*)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*); +using EglDestroyImageKhr = EGLBoolean (*)(EGLDisplay, EGLImageKHR); +using GlEglImageTargetTexture2DOes = void (*)(GLenum, GLeglImageOES); + +std::string glErrorCodeDetail(const GLenum error) { + std::ostringstream stream; + stream << "GL error 0x" << std::hex << static_cast(error); + return stream.str(); +} + +bool currentContextUsesOpenGles() { + const auto* version = reinterpret_cast(glGetString(GL_VERSION)); + return version != nullptr && std::string_view(version).find("OpenGL ES") != std::string_view::npos; +} + +GLuint compilePresenterShader(const GLenum shaderType, const char* source) { + const GLuint shader = glCreateShader(shaderType); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled != GL_TRUE) { + glDeleteShader(shader); + return 0; + } + return shader; +} + +GLuint createPresenterProgram(const char* fragmentShaderSource) { + static constexpr const char* esVertexShaderSource = + "attribute vec2 a_position;\n" + "attribute vec2 a_texCoord;\n" + "uniform mat4 u_projection;\n" + "varying vec2 v_texCoord;\n" + "void main() {\n" + " gl_Position = u_projection * vec4(a_position, 0.0, 1.0);\n" + " v_texCoord = a_texCoord;\n" + "}\n"; + static constexpr const char* desktopVertexShaderSource = + "#version 150\n" + "in vec2 a_position;\n" + "in vec2 a_texCoord;\n" + "uniform mat4 u_projection;\n" + "out vec2 v_texCoord;\n" + "void main() {\n" + " gl_Position = u_projection * vec4(a_position, 0.0, 1.0);\n" + " v_texCoord = a_texCoord;\n" + "}\n"; + const char* vertexShaderSource = currentContextUsesOpenGles() ? esVertexShaderSource : desktopVertexShaderSource; + const GLuint vertexShader = compilePresenterShader(GL_VERTEX_SHADER, vertexShaderSource); + const GLuint fragmentShader = compilePresenterShader(GL_FRAGMENT_SHADER, fragmentShaderSource); + if (vertexShader == 0 || fragmentShader == 0) { + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vertexShader); + glAttachShader(program, fragmentShader); + glBindAttribLocation(program, 0, "a_position"); + glBindAttribLocation(program, 1, "a_texCoord"); + glLinkProgram(program); + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked != GL_TRUE) { + glDeleteProgram(program); + return 0; + } + return program; +} + +GLuint createExternalOesPresenterProgram() { + if (!currentContextUsesOpenGles()) { + return 0; + } + static constexpr const char* fragmentShaderSource = + "#extension GL_OES_EGL_image_external : require\n" + "precision mediump float;\n" + "uniform samplerExternalOES u_texture;\n" + "varying vec2 v_texCoord;\n" + "void main() {\n" + " gl_FragColor = texture2D(u_texture, v_texCoord);\n" + "}\n"; + return createPresenterProgram(fragmentShaderSource); +} + +GLuint createTwoLayerYuvPresenterProgram() { + static constexpr const char* esFragmentShaderSource = + "precision mediump float;\n" + "uniform sampler2D u_yTexture;\n" + "uniform sampler2D u_uvTexture;\n" + "varying vec2 v_texCoord;\n" + "void main() {\n" + " float y = texture2D(u_yTexture, v_texCoord).r;\n" + " vec2 uv = texture2D(u_uvTexture, v_texCoord).rg - vec2(0.5, 0.5);\n" + " float r = y + 1.5748 * uv.y;\n" + " float g = y - 0.1873 * uv.x - 0.4681 * uv.y;\n" + " float b = y + 1.8556 * uv.x;\n" + " gl_FragColor = vec4(clamp(vec3(r, g, b), 0.0, 1.0), 1.0);\n" + "}\n"; + static constexpr const char* desktopFragmentShaderSource = + "#version 150\n" + "uniform sampler2D u_yTexture;\n" + "uniform sampler2D u_uvTexture;\n" + "in vec2 v_texCoord;\n" + "out vec4 fragColor;\n" + "void main() {\n" + " float y = texture(u_yTexture, v_texCoord).r;\n" + " vec2 uv = texture(u_uvTexture, v_texCoord).rg - vec2(0.5, 0.5);\n" + " float r = y + 1.5748 * uv.y;\n" + " float g = y - 0.1873 * uv.x - 0.4681 * uv.y;\n" + " float b = y + 1.8556 * uv.x;\n" + " fragColor = vec4(clamp(vec3(r, g, b), 0.0, 1.0), 1.0);\n" + "}\n"; + const char* fragmentShaderSource = currentContextUsesOpenGles() ? esFragmentShaderSource : desktopFragmentShaderSource; + return createPresenterProgram(fragmentShaderSource); +} + +bool renderPresenterTexture(DeckVaapiEglImagePresenter::Resource& resource, const QRectF& rect, const QMatrix4x4* projectionMatrix) { + resource.shaderCompositionProved = false; + resource.shaderCompositionDetail.clear(); + if (!resource.hasTexture() || projectionMatrix == nullptr) { + resource.shaderCompositionDetail = !resource.hasTexture() + ? "shader composition skipped because no imported EGLImage/GL texture resource is available" + : "shader composition skipped because QSGRenderNode render state did not provide a projection matrix"; + return false; + } + if (resource.glProgram == 0) { + resource.glProgram = resource.importedLayerCount == 2 ? createTwoLayerYuvPresenterProgram() : createExternalOesPresenterProgram(); + if (resource.glProgram == 0) { + resource.shaderCompositionDetail = "shader composition program creation failed"; + return false; + } + } + const GLfloat left = static_cast(rect.left()); + const GLfloat right = static_cast(rect.right()); + const GLfloat top = static_cast(rect.top()); + const GLfloat bottom = static_cast(rect.bottom()); + const GLfloat vertices[] = { + left, top, 0.0f, 0.0f, + right, top, 1.0f, 0.0f, + left, bottom, 0.0f, 1.0f, + right, bottom, 1.0f, 1.0f, + }; + glUseProgram(resource.glProgram); + glUniformMatrix4fv(glGetUniformLocation(resource.glProgram, "u_projection"), 1, GL_FALSE, projectionMatrix->constData()); + if (resource.importedLayerCount == 2) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, resource.glTextures[0]); + glUniform1i(glGetUniformLocation(resource.glProgram, "u_yTexture"), 0); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, resource.glTextures[1]); + glUniform1i(glGetUniformLocation(resource.glProgram, "u_uvTexture"), 1); + } else { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_EXTERNAL_OES, resource.glTexture); + glUniform1i(glGetUniformLocation(resource.glProgram, "u_texture"), 0); + } + glEnableVertexAttribArray(0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), vertices); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), vertices + 2); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glDisableVertexAttribArray(0); + glDisableVertexAttribArray(1); + if (resource.importedLayerCount == 2) { + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + } else { + glBindTexture(GL_TEXTURE_EXTERNAL_OES, 0); + } + const GLenum drawError = glGetError(); + resource.shaderCompositionProved = drawError == GL_NO_ERROR; + resource.shaderCompositionDetail = resource.shaderCompositionProved + ? "shader composition draw completed without GL errors" + : "shader composition draw failed with " + glErrorCodeDetail(drawError); + return resource.shaderCompositionProved; +} + +void destroyPresenterResource(QSGTexture*& qtTexture, void*& eglDisplay, void*& eglImage, unsigned int& glTexture, unsigned int& glProgram) { + delete qtTexture; + qtTexture = nullptr; + if (glProgram != 0) { + glDeleteProgram(glProgram); + glProgram = 0; + } + if (glTexture != 0) { + const GLuint texture = glTexture; + glDeleteTextures(1, &texture); + glTexture = 0; + } + if (eglDisplay != nullptr && eglImage != nullptr) { + auto destroyImage = reinterpret_cast(eglGetProcAddress("eglDestroyImageKHR")); + if (destroyImage != nullptr) { + destroyImage(static_cast(eglDisplay), static_cast(eglImage)); + } + eglImage = nullptr; + } + eglDisplay = nullptr; +} + +void destroyPresenterResource(DeckVaapiEglImagePresenter::Resource& resource) { + EGLDisplay layerDisplay = static_cast(resource.eglDisplay); + destroyPresenterResource(resource.qtTexture, resource.eglDisplay, resource.eglImage, resource.glTexture, resource.glProgram); + if (layerDisplay == EGL_NO_DISPLAY) { + layerDisplay = eglGetCurrentDisplay(); + } + auto destroyImage = reinterpret_cast(eglGetProcAddress("eglDestroyImageKHR")); + for (int layerIndex = 0; layerIndex < static_cast(resource.glTextures.size()); ++layerIndex) { + if (resource.glTextures[layerIndex] != 0) { + const GLuint texture = resource.glTextures[layerIndex]; + glDeleteTextures(1, &texture); + resource.glTextures[layerIndex] = 0; + } + if (resource.eglImages[layerIndex] != nullptr && layerDisplay != EGL_NO_DISPLAY && destroyImage != nullptr) { + destroyImage(layerDisplay, static_cast(resource.eglImages[layerIndex])); + } + resource.eglImages[layerIndex] = nullptr; + } + resource.importedLayerCount = 0; + resource.shaderCompositionProved = false; + resource.shaderCompositionDetail.clear(); + resource.eglDisplay = nullptr; +} + +void appendPlaneAttributes(std::vector& attributes, const int attributePlane, const int fd, const std::int64_t offset, const std::int64_t pitch, const std::uint64_t modifier, const bool includeModifier) { + static constexpr std::array fdAttributes = { EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE1_FD_EXT, EGL_DMA_BUF_PLANE2_FD_EXT, EGL_DMA_BUF_PLANE3_FD_EXT }; + static constexpr std::array offsetAttributes = { EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE1_OFFSET_EXT, EGL_DMA_BUF_PLANE2_OFFSET_EXT, EGL_DMA_BUF_PLANE3_OFFSET_EXT }; + static constexpr std::array pitchAttributes = { EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE1_PITCH_EXT, EGL_DMA_BUF_PLANE2_PITCH_EXT, EGL_DMA_BUF_PLANE3_PITCH_EXT }; + static constexpr std::array modifierLoAttributes = { EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT }; + static constexpr std::array modifierHiAttributes = { EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT }; + attributes.push_back(fdAttributes[attributePlane]); + attributes.push_back(fd); + attributes.push_back(offsetAttributes[attributePlane]); + attributes.push_back(static_cast(offset)); + attributes.push_back(pitchAttributes[attributePlane]); + attributes.push_back(static_cast(pitch)); + if (includeModifier) { + attributes.push_back(modifierLoAttributes[attributePlane]); + attributes.push_back(static_cast(modifier & 0xffffffffu)); + attributes.push_back(modifierHiAttributes[attributePlane]); + attributes.push_back(static_cast(modifier >> 32u)); + } +} + +DeckVaapiPresenterReadinessState readinessStateForImportStatus(const DeckQrhiVaapiImportStatus status) { + switch (status) { + case DeckQrhiVaapiImportStatus::NotAttempted: + return DeckVaapiPresenterReadinessState::NotAttempted; + case DeckQrhiVaapiImportStatus::DrmPrimeExported: + return DeckVaapiPresenterReadinessState::HardwarePresenterPlanned; + case DeckQrhiVaapiImportStatus::MissingFrameLease: + return DeckVaapiPresenterReadinessState::MissingFrameLease; + case DeckQrhiVaapiImportStatus::InvalidVaapiFrame: + return DeckVaapiPresenterReadinessState::InvalidVaapiFrame; + case DeckQrhiVaapiImportStatus::MissingHardwareFramesContext: + return DeckVaapiPresenterReadinessState::MissingHardwareFramesContext; + case DeckQrhiVaapiImportStatus::DrmPrimeMapFailed: + return DeckVaapiPresenterReadinessState::DrmPrimeMapFailed; + case DeckQrhiVaapiImportStatus::MissingRenderState: + return DeckVaapiPresenterReadinessState::MissingRenderState; + case DeckQrhiVaapiImportStatus::MissingQrhiCommandBuffer: + return DeckVaapiPresenterReadinessState::MissingQrhiCommandBuffer; + case DeckQrhiVaapiImportStatus::MissingRenderContext: + return DeckVaapiPresenterReadinessState::MissingRenderContext; + case DeckQrhiVaapiImportStatus::DeckTargetUnavailable: + return DeckVaapiPresenterReadinessState::DeckTargetUnavailable; + case DeckQrhiVaapiImportStatus::UnsupportedNonOpenGlSceneGraph: + return DeckVaapiPresenterReadinessState::UnsupportedNonOpenGlSceneGraph; + case DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions: + return DeckVaapiPresenterReadinessState::MissingEglDmabufExtensions; + case DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata: + return DeckVaapiPresenterReadinessState::IncompleteDrmPrimeMetadata; + case DeckQrhiVaapiImportStatus::UnsupportedMultiLayerDrmPrimeImport: + return DeckVaapiPresenterReadinessState::UnsupportedMultiLayerDrmPrimeImport; + case DeckQrhiVaapiImportStatus::UnsupportedDrmPrimeFormat: + return DeckVaapiPresenterReadinessState::UnsupportedDrmPrimeFormat; + case DeckQrhiVaapiImportStatus::EglImageCreationFailed: + return DeckVaapiPresenterReadinessState::EglImageCreationFailed; + case DeckQrhiVaapiImportStatus::GlTextureBindFailed: + return DeckVaapiPresenterReadinessState::GlTextureBindFailed; + case DeckQrhiVaapiImportStatus::EglImageShaderCompositionFailed: + return DeckVaapiPresenterReadinessState::EglImageShaderCompositionFailed; + case DeckQrhiVaapiImportStatus::UnsupportedPublicQtLinuxDmabufImport: + return DeckVaapiPresenterReadinessState::UnsupportedPublicQtLinuxDmabufImport; + } + return DeckVaapiPresenterReadinessState::NotAttempted; +} + +std::string_view readinessStatusCode(const DeckVaapiPresenterReadinessState state) { + switch (state) { + case DeckVaapiPresenterReadinessState::NotAttempted: + return "not-attempted"; + case DeckVaapiPresenterReadinessState::Ready: + return "ready"; + case DeckVaapiPresenterReadinessState::HardwarePresenterPlanned: + return "hardware-presenter-planned"; + case DeckVaapiPresenterReadinessState::HardwareFrameReady: + return "hardware-frame-ready"; + case DeckVaapiPresenterReadinessState::MissingFrameLease: + return "missing-frame-lease"; + case DeckVaapiPresenterReadinessState::InvalidVaapiFrame: + return "invalid-vaapi-frame"; + case DeckVaapiPresenterReadinessState::MissingHardwareFramesContext: + return "missing-hardware-frames-context"; + case DeckVaapiPresenterReadinessState::DrmPrimeMapFailed: + return "drm-prime-map-failed"; + case DeckVaapiPresenterReadinessState::MissingRenderState: + return "missing-render-state"; + case DeckVaapiPresenterReadinessState::MissingQrhiCommandBuffer: + return "missing-qrhi-command-buffer"; + case DeckVaapiPresenterReadinessState::MissingRenderContext: + return "missing-render-context"; + case DeckVaapiPresenterReadinessState::DeckTargetUnavailable: + return "deck-target-unavailable"; + case DeckVaapiPresenterReadinessState::UnsupportedNonOpenGlSceneGraph: + return "unsupported-non-opengl-scene-graph"; + case DeckVaapiPresenterReadinessState::MissingEglDmabufExtensions: + return "missing-egl-dmabuf-extensions"; + case DeckVaapiPresenterReadinessState::IncompleteDrmPrimeMetadata: + return "incomplete-drm-prime-metadata"; + case DeckVaapiPresenterReadinessState::UnsupportedMultiLayerDrmPrimeImport: + return "unsupported-multilayer-drm-prime-import"; + case DeckVaapiPresenterReadinessState::UnsupportedDrmPrimeFormat: + return "unsupported-drm-prime-format"; + case DeckVaapiPresenterReadinessState::EglImageCreationFailed: + return "eglimage-creation-failed"; + case DeckVaapiPresenterReadinessState::GlTextureBindFailed: + return "gl-texture-bind-failed"; + case DeckVaapiPresenterReadinessState::EglImageShaderCompositionFailed: + return "eglimage-shader-composition-failed"; + case DeckVaapiPresenterReadinessState::UnsupportedPublicQtLinuxDmabufImport: + return "unsupported-public-qt-linux-dmabuf-import"; + } + return "not-attempted"; +} + +std::string_view readinessLabel(const DeckVaapiPresenterReadinessState state) { + switch (state) { + case DeckVaapiPresenterReadinessState::NotAttempted: + return "Presenter check not attempted"; + case DeckVaapiPresenterReadinessState::Ready: + return "Ready: EGLImage GL presenter has a Qt texture"; + case DeckVaapiPresenterReadinessState::HardwarePresenterPlanned: + return "Hardware presenter planned: DRM_PRIME metadata can be imported"; + case DeckVaapiPresenterReadinessState::HardwareFrameReady: + return "Hardware frame ready: VAAPI decoded DRM_PRIME metadata"; + case DeckVaapiPresenterReadinessState::MissingFrameLease: + return "Missing VAAPI frame lease"; + case DeckVaapiPresenterReadinessState::InvalidVaapiFrame: + return "Invalid VAAPI frame"; + case DeckVaapiPresenterReadinessState::MissingHardwareFramesContext: + return "Missing VAAPI hardware frames context"; + case DeckVaapiPresenterReadinessState::DrmPrimeMapFailed: + return "DRM_PRIME export failed"; + case DeckVaapiPresenterReadinessState::MissingRenderState: + return "Missing Qt Quick render state"; + case DeckVaapiPresenterReadinessState::MissingQrhiCommandBuffer: + return "Missing QRhi command buffer"; + case DeckVaapiPresenterReadinessState::MissingRenderContext: + return "Missing current render-thread OpenGL/EGL context"; + case DeckVaapiPresenterReadinessState::DeckTargetUnavailable: + return "Missing Deck Qt Quick render target"; + case DeckVaapiPresenterReadinessState::UnsupportedNonOpenGlSceneGraph: + return "Unsupported scene graph: OpenGLRhi required"; + case DeckVaapiPresenterReadinessState::MissingEglDmabufExtensions: + return "Missing EGL dmabuf import extensions"; + case DeckVaapiPresenterReadinessState::IncompleteDrmPrimeMetadata: + return "Incomplete DRM_PRIME metadata"; + case DeckVaapiPresenterReadinessState::UnsupportedMultiLayerDrmPrimeImport: + return "Unsupported multi-layer DRM_PRIME import"; + case DeckVaapiPresenterReadinessState::UnsupportedDrmPrimeFormat: + return "Unsupported DRM_PRIME layer format"; + case DeckVaapiPresenterReadinessState::EglImageCreationFailed: + return "EGLImage creation failed"; + case DeckVaapiPresenterReadinessState::GlTextureBindFailed: + return "GL texture bind failed"; + case DeckVaapiPresenterReadinessState::EglImageShaderCompositionFailed: + return "EGLImage shader composition failed"; + case DeckVaapiPresenterReadinessState::UnsupportedPublicQtLinuxDmabufImport: + return "Unsupported public Qt Linux dmabuf import"; + } + return "Presenter check not attempted"; +} + } // namespace +DeckQrhiVaapiDrmPrimeDescriptor::DeckQrhiVaapiDrmPrimeDescriptor(AVFrame* mappedFrame) + : mappedFrame_(mappedFrame) { + if (mappedFrame_ == nullptr || mappedFrame_->format != AV_PIX_FMT_DRM_PRIME || mappedFrame_->data[0] == nullptr) { + status = DeckQrhiVaapiImportStatus::DrmPrimeMapFailed; + detail = "VAAPI frame did not map to an AV_PIX_FMT_DRM_PRIME descriptor"; + return; + } + + const auto* drmDescriptor = reinterpret_cast(mappedFrame_->data[0]); + if (drmDescriptor->nb_objects <= 0 || drmDescriptor->nb_layers <= 0 || drmDescriptor->nb_objects > static_cast(objects.size()) || + drmDescriptor->nb_layers > static_cast(layers.size())) { + objectCount = std::clamp(drmDescriptor->nb_objects, 0, static_cast(objects.size())); + layerCount = std::clamp(drmDescriptor->nb_layers, 0, static_cast(layers.size())); + status = DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata; + detail = "DRM_PRIME descriptor object/layer count is missing or exceeds EGL import limits"; + return; + } + objectCount = std::clamp(drmDescriptor->nb_objects, 0, static_cast(objects.size())); + layerCount = std::clamp(drmDescriptor->nb_layers, 0, static_cast(layers.size())); + for (int objectIndex = 0; objectIndex < objectCount; ++objectIndex) { + objects[objectIndex] = DeckVaapiDrmPrimeObject{ + .fd = drmDescriptor->objects[objectIndex].fd, + .formatModifier = drmDescriptor->objects[objectIndex].format_modifier, + }; + } + for (int layerIndex = 0; layerIndex < layerCount; ++layerIndex) { + DeckVaapiDrmPrimeLayer layer{}; + layer.format = drmDescriptor->layers[layerIndex].format; + if (drmDescriptor->layers[layerIndex].nb_planes <= 0 || drmDescriptor->layers[layerIndex].nb_planes > static_cast(layer.planes.size())) { + status = DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata; + detail = "DRM_PRIME descriptor plane count is missing or exceeds EGL import limits"; + return; + } + layer.planeCount = drmDescriptor->layers[layerIndex].nb_planes; + for (int planeIndex = 0; planeIndex < layer.planeCount; ++planeIndex) { + layer.planes[planeIndex] = DeckVaapiDrmPrimePlane{ + .objectIndex = drmDescriptor->layers[layerIndex].planes[planeIndex].object_index, + .offset = static_cast(drmDescriptor->layers[layerIndex].planes[planeIndex].offset), + .pitch = static_cast(drmDescriptor->layers[layerIndex].planes[planeIndex].pitch), + }; + } + layers[layerIndex] = layer; + } + status = DeckQrhiVaapiImportStatus::DrmPrimeExported; + detail = "FFmpeg exported DRM_PRIME dmabuf metadata for public Qt Quick OpenGL/EGLImage import"; +} + +DeckQrhiVaapiDrmPrimeDescriptor::~DeckQrhiVaapiDrmPrimeDescriptor() { + reset(); +} + +DeckQrhiVaapiDrmPrimeDescriptor::DeckQrhiVaapiDrmPrimeDescriptor(DeckQrhiVaapiDrmPrimeDescriptor&& other) noexcept + : status(other.status) + , objectCount(other.objectCount) + , layerCount(other.layerCount) + , objects(other.objects) + , layers(other.layers) + , detail(std::move(other.detail)) + , mappedFrame_(other.mappedFrame_) { + other.status = DeckQrhiVaapiImportStatus::NotAttempted; + other.objectCount = 0; + other.layerCount = 0; + other.objects = {}; + other.layers = {}; + other.mappedFrame_ = nullptr; +} + +DeckQrhiVaapiDrmPrimeDescriptor& DeckQrhiVaapiDrmPrimeDescriptor::operator=(DeckQrhiVaapiDrmPrimeDescriptor&& other) noexcept { + if (this != &other) { + reset(); + status = other.status; + objectCount = other.objectCount; + layerCount = other.layerCount; + objects = other.objects; + layers = other.layers; + detail = std::move(other.detail); + mappedFrame_ = other.mappedFrame_; + other.status = DeckQrhiVaapiImportStatus::NotAttempted; + other.objectCount = 0; + other.layerCount = 0; + other.objects = {}; + other.layers = {}; + other.mappedFrame_ = nullptr; + } + return *this; +} + +void DeckQrhiVaapiDrmPrimeDescriptor::reset() { + if (mappedFrame_ != nullptr) { + av_frame_free(&mappedFrame_); + } +} + + +DeckVaapiEglImagePresenter::Resource::~Resource() { + destroyPresenterResource(*this); +} + +DeckVaapiEglImagePresenter::Resource::Resource(Resource&& other) noexcept + : qtTexture(other.qtTexture), eglDisplay(other.eglDisplay), eglImage(other.eglImage), glTexture(other.glTexture), glProgram(other.glProgram), eglImages(other.eglImages), glTextures(other.glTextures), importedLayerCount(other.importedLayerCount), shaderCompositionProved(other.shaderCompositionProved), shaderCompositionDetail(std::move(other.shaderCompositionDetail)) { + other.qtTexture = nullptr; + other.eglDisplay = nullptr; + other.eglImage = nullptr; + other.glTexture = 0; + other.glProgram = 0; + other.eglImages = {}; + other.glTextures = {}; + other.importedLayerCount = 0; + other.shaderCompositionProved = false; + other.shaderCompositionDetail.clear(); +} + +DeckVaapiEglImagePresenter::Resource& DeckVaapiEglImagePresenter::Resource::operator=(Resource&& other) noexcept { + if (this != &other) { + destroyPresenterResource(*this); + qtTexture = other.qtTexture; + eglDisplay = other.eglDisplay; + eglImage = other.eglImage; + glTexture = other.glTexture; + glProgram = other.glProgram; + eglImages = other.eglImages; + glTextures = other.glTextures; + importedLayerCount = other.importedLayerCount; + shaderCompositionProved = other.shaderCompositionProved; + shaderCompositionDetail = std::move(other.shaderCompositionDetail); + other.qtTexture = nullptr; + other.eglDisplay = nullptr; + other.eglImage = nullptr; + other.glTexture = 0; + other.glProgram = 0; + other.eglImages = {}; + other.glTextures = {}; + other.importedLayerCount = 0; + other.shaderCompositionProved = false; + other.shaderCompositionDetail.clear(); + } + return *this; +} + +bool DeckVaapiEglImagePresenter::Resource::hasTexture() const { + if (importedLayerCount > 0) { + if (importedLayerCount > static_cast(eglImages.size())) { + return false; + } + for (int layerIndex = 0; layerIndex < importedLayerCount; ++layerIndex) { + if (eglImages[layerIndex] == nullptr || glTextures[layerIndex] == 0) { + return false; + } + } + return true; + } + return qtTexture != nullptr && glTexture != 0 && eglImage != nullptr; +} + +DeckQrhiVaapiImportPlan DeckVaapiEglImagePresenter::validateDrmPrimeMetadata(const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor) { + if (drmPrimeDescriptor.status != DeckQrhiVaapiImportStatus::DrmPrimeExported) { + return DeckQrhiVaapiImportPlan{ .status = drmPrimeDescriptor.status, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = drmPrimeDescriptor.detail.empty() ? "DRM_PRIME export did not produce importable metadata" : drmPrimeDescriptor.detail }; + } + if (drmPrimeDescriptor.objectCount <= 0 || drmPrimeDescriptor.layerCount <= 0) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "DRM_PRIME descriptor has no dmabuf objects or layers" }; + } + if (drmPrimeDescriptor.layerCount > 2) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::UnsupportedMultiLayerDrmPrimeImport, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "DRM_PRIME descriptor has more than the supported Deck two-layer Y/UV shape; no layer is truncated or silently ignored" }; + } + if (drmPrimeDescriptor.layerCount == 2 && + (drmPrimeDescriptor.layers[0].format != DRM_FORMAT_R8 || drmPrimeDescriptor.layers[1].format != DRM_FORMAT_GR88)) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::UnsupportedDrmPrimeFormat, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "Only the real Deck two-layer DRM_PRIME Y/UV shape is supported for shader composition: layer 0 DRM_FORMAT_R8 luma and layer 1 DRM_FORMAT_GR88 chroma" }; + } + int importedPlaneCount = 0; + for (int layerIndex = 0; layerIndex < drmPrimeDescriptor.layerCount; ++layerIndex) { + const DeckVaapiDrmPrimeLayer& layer = drmPrimeDescriptor.layers[layerIndex]; + if (layer.format == 0 || layer.planeCount <= 0) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "DRM_PRIME layer is missing format or plane metadata" }; + } + for (int planeIndex = 0; planeIndex < layer.planeCount; ++planeIndex) { + const DeckVaapiDrmPrimePlane& plane = layer.planes[planeIndex]; + if (plane.objectIndex < 0 || plane.objectIndex >= drmPrimeDescriptor.objectCount || drmPrimeDescriptor.objects[plane.objectIndex].fd < 0 || plane.pitch <= 0 || plane.offset < 0 || importedPlaneCount >= 4) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "DRM_PRIME plane is missing fd, object index, pitch, offset, or exceeds EGL plane limits" }; + } + ++importedPlaneCount; + } + } + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DrmPrimeExported, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = drmPrimeDescriptor.layerCount == 2 ? "2-layer DRM_PRIME YUV dmabuf metadata is complete for separate EGLImage imports and explicit YUV-to-RGB shader composition" : "DRM_PRIME dmabuf metadata is complete for EGLImage import" }; +} + +DeckQrhiVaapiImportPlan DeckVaapiEglImagePresenter::planOpenGlTextureImport(QQuickWindow* targetWindow, const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, const QSize& size) { + const DeckQrhiVaapiImportPlan metadataPlan = validateDrmPrimeMetadata(drmPrimeDescriptor); + if (metadataPlan.status != DeckQrhiVaapiImportStatus::DrmPrimeExported) { + return metadataPlan; + } + if (targetWindow == nullptr || size.width() <= 0 || size.height() <= 0) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DeckTargetUnavailable, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "Qt Quick target window and positive texture size are required for EGLImage presentation" }; + } + QSGRendererInterface* rendererInterface = targetWindow->rendererInterface(); + if (rendererInterface == nullptr) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DeckTargetUnavailable, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "Qt Quick target has no renderer interface available on the render thread" }; + } + if (rendererInterface->graphicsApi() != QSGRendererInterface::OpenGLRhi) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::UnsupportedNonOpenGlSceneGraph, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "VAAPI EGLImage dmabuf import is gated to Qt Quick OpenGLRhi scene graph; current Qt Quick graphicsApi=" + std::to_string(static_cast(rendererInterface->graphicsApi())) + " so render-thread EGLImage import is not attempted" }; + } + const EGLDisplay eglDisplay = eglGetCurrentDisplay(); + if (eglDisplay == EGL_NO_DISPLAY || eglGetCurrentContext() == EGL_NO_CONTEXT) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingRenderContext, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "No current EGL display/context is bound for the Deck Qt Quick render thread" }; + } + const char* eglExtensions = eglQueryString(eglDisplay, EGL_EXTENSIONS); + const bool hasDmabufImport = extensionListContains(eglExtensions, "EGL_EXT_image_dma_buf_import"); + const bool needsModifierImport = drmPrimeDescriptorHasExplicitModifier(drmPrimeDescriptor); + const bool hasModifierImport = extensionListContains(eglExtensions, "EGL_EXT_image_dma_buf_import_modifiers"); + if (!hasDmabufImport || (needsModifierImport && !hasModifierImport)) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "EGL display lacks required dmabuf import extension(s) for DRM_PRIME modifiers" }; + } + if (eglGetProcAddress("eglCreateImageKHR") == nullptr || eglGetProcAddress("eglDestroyImageKHR") == nullptr || eglGetProcAddress("glEGLImageTargetTexture2DOES") == nullptr) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "EGL/GL image import entry points are unavailable" }; + } + return metadataPlan; +} + +DeckQrhiVaapiImportPlan DeckVaapiEglImagePresenter::importOpenGlTextureForCurrentContext(const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, const QSize& size, Resource& resource) { + DeckQrhiVaapiImportPlan plan = validateDrmPrimeMetadata(drmPrimeDescriptor); + if (plan.status != DeckQrhiVaapiImportStatus::DrmPrimeExported) { + return plan; + } + if (size.width() <= 0 || size.height() <= 0) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DeckTargetUnavailable, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "Positive texture size is required for current-context EGLImage presentation" }; + } + const EGLDisplay eglDisplay = eglGetCurrentDisplay(); + if (eglDisplay == EGL_NO_DISPLAY || eglGetCurrentContext() == EGL_NO_CONTEXT) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingRenderContext, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "No current EGL display/context is bound for live DRM_PRIME composition smoke" }; + } + const char* eglExtensions = eglQueryString(eglDisplay, EGL_EXTENSIONS); + const bool hasDmabufImport = extensionListContains(eglExtensions, "EGL_EXT_image_dma_buf_import"); + const bool needsModifierImport = drmPrimeDescriptorHasExplicitModifier(drmPrimeDescriptor); + const bool hasModifierImport = extensionListContains(eglExtensions, "EGL_EXT_image_dma_buf_import_modifiers"); + if (!hasDmabufImport || (needsModifierImport && !hasModifierImport)) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "EGL display lacks required dmabuf import extension(s) for live DRM_PRIME composition smoke" }; + } + auto createImage = reinterpret_cast(eglGetProcAddress("eglCreateImageKHR")); + auto imageTargetTexture = reinterpret_cast(eglGetProcAddress("glEGLImageTargetTexture2DOES")); + if (createImage == nullptr || eglGetProcAddress("eglDestroyImageKHR") == nullptr || imageTargetTexture == nullptr) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "EGL/GL image import entry points are unavailable for live DRM_PRIME composition smoke" }; + } + + const bool includeModifiers = drmPrimeDescriptorHasExplicitModifier(drmPrimeDescriptor); + const GLenum textureTarget = drmPrimeDescriptor.layerCount == 2 ? GL_TEXTURE_2D : GL_TEXTURE_EXTERNAL_OES; + Resource pendingResource; + pendingResource.eglDisplay = static_cast(eglDisplay); + + for (int layerIndex = 0; layerIndex < drmPrimeDescriptor.layerCount; ++layerIndex) { + const DeckVaapiDrmPrimeLayer& layer = drmPrimeDescriptor.layers[layerIndex]; + const int layerWidth = drmPrimeDescriptor.layerCount == 2 && layerIndex == 1 ? (size.width() + 1) / 2 : size.width(); + const int layerHeight = drmPrimeDescriptor.layerCount == 2 && layerIndex == 1 ? (size.height() + 1) / 2 : size.height(); + std::vector attributes{ EGL_WIDTH, layerWidth, EGL_HEIGHT, layerHeight, EGL_LINUX_DRM_FOURCC_EXT, static_cast(layer.format) }; + for (int planeIndex = 0; planeIndex < layer.planeCount; ++planeIndex) { + const DeckVaapiDrmPrimePlane& plane = layer.planes[planeIndex]; + const DeckVaapiDrmPrimeObject& object = drmPrimeDescriptor.objects[plane.objectIndex]; + appendPlaneAttributes(attributes, planeIndex, object.fd, plane.offset, plane.pitch, object.formatModifier, includeModifiers); + } + attributes.push_back(EGL_NONE); + + const EGLImageKHR eglImage = createImage(eglDisplay, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attributes.data()); + if (eglImage == EGL_NO_IMAGE_KHR) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::EglImageCreationFailed, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "eglCreateImageKHR(EGL_LINUX_DMA_BUF_EXT) failed for live DRM_PRIME layer " + std::to_string(layerIndex) }; + } + + GLuint glTexture = 0; + glGenTextures(1, &glTexture); + glBindTexture(textureTarget, glTexture); + glTexParameteri(textureTarget, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(textureTarget, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(textureTarget, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(textureTarget, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + imageTargetTexture(textureTarget, static_cast(eglImage)); + if (glGetError() != GL_NO_ERROR) { + void* ownedDisplay = static_cast(eglDisplay); + void* ownedImage = static_cast(eglImage); + unsigned int ownedTexture = glTexture; + QSGTexture* noTexture = nullptr; + unsigned int noProgram = 0; + destroyPresenterResource(noTexture, ownedDisplay, ownedImage, ownedTexture, noProgram); + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::GlTextureBindFailed, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "glEGLImageTargetTexture2DOES failed while binding live DRM_PRIME layer " + std::to_string(layerIndex) }; + } + glBindTexture(textureTarget, 0); + pendingResource.eglImages[layerIndex] = static_cast(eglImage); + pendingResource.glTextures[layerIndex] = glTexture; + pendingResource.importedLayerCount = layerIndex + 1; + } + + destroyPresenterResource(resource); + resource = std::move(pendingResource); + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DrmPrimeExported, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = drmPrimeDescriptor.layerCount == 2 ? "2-layer DRM_PRIME Y/UV dmabuf imported as separate EGLImages and GL textures in a live EGL context; awaiting shader composition proof" : "DRM_PRIME dmabuf imported into an EGLImage and GL texture in a live EGL context; awaiting shader composition proof" }; +} + +bool DeckVaapiEglImagePresenter::proveOpenGlShaderCompositionForCurrentContext(Resource& resource, const QSize& size) { + resource.shaderCompositionProved = false; + if (eglGetCurrentDisplay() == EGL_NO_DISPLAY || eglGetCurrentContext() == EGL_NO_CONTEXT || !resource.hasTexture() || size.width() <= 0 || size.height() <= 0) { + return false; + } + + GLint priorFramebuffer = 0; + GLint priorViewport[4] = {}; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &priorFramebuffer); + glGetIntegerv(GL_VIEWPORT, priorViewport); + + GLuint framebuffer = 0; + GLuint colorRenderbuffer = 0; + const int renderWidth = std::max(1, size.width()); + const int renderHeight = std::max(1, size.height()); + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glGenRenderbuffers(1, &colorRenderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, colorRenderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA4, renderWidth, renderHeight); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorRenderbuffer); + const bool framebufferComplete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + glViewport(0, 0, renderWidth, renderHeight); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + QMatrix4x4 projection; + projection.ortho(0.0f, static_cast(renderWidth), static_cast(renderHeight), 0.0f, -1.0f, 1.0f); + const bool rendered = framebufferComplete && renderPresenterTexture( + resource, + QRectF(0.0, 0.0, static_cast(renderWidth), static_cast(renderHeight)), + &projection); + glFinish(); + const bool proved = rendered && glGetError() == GL_NO_ERROR; + resource.shaderCompositionProved = proved; + + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glBindFramebuffer(GL_FRAMEBUFFER, static_cast(priorFramebuffer)); + glViewport(priorViewport[0], priorViewport[1], priorViewport[2], priorViewport[3]); + if (colorRenderbuffer != 0) { + glDeleteRenderbuffers(1, &colorRenderbuffer); + } + if (framebuffer != 0) { + glDeleteFramebuffers(1, &framebuffer); + } + return proved; +} + +DeckQrhiVaapiImportPlan DeckVaapiEglImagePresenter::importOpenGlTexture(QQuickWindow* targetWindow, const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, const QSize& size, Resource& resource) { + DeckQrhiVaapiImportPlan plan = planOpenGlTextureImport(targetWindow, drmPrimeDescriptor, size); + if (plan.status != DeckQrhiVaapiImportStatus::DrmPrimeExported) { + return plan; + } + + const EGLDisplay eglDisplay = eglGetCurrentDisplay(); + auto createImage = reinterpret_cast(eglGetProcAddress("eglCreateImageKHR")); + auto imageTargetTexture = reinterpret_cast(eglGetProcAddress("glEGLImageTargetTexture2DOES")); + const bool includeModifiers = drmPrimeDescriptorHasExplicitModifier(drmPrimeDescriptor); + const GLenum textureTarget = drmPrimeDescriptor.layerCount == 2 ? GL_TEXTURE_2D : GL_TEXTURE_EXTERNAL_OES; + Resource pendingResource; + pendingResource.eglDisplay = static_cast(eglDisplay); + + for (int layerIndex = 0; layerIndex < drmPrimeDescriptor.layerCount; ++layerIndex) { + const DeckVaapiDrmPrimeLayer& layer = drmPrimeDescriptor.layers[layerIndex]; + const int layerWidth = drmPrimeDescriptor.layerCount == 2 && layerIndex == 1 ? (size.width() + 1) / 2 : size.width(); + const int layerHeight = drmPrimeDescriptor.layerCount == 2 && layerIndex == 1 ? (size.height() + 1) / 2 : size.height(); + std::vector attributes{ EGL_WIDTH, layerWidth, EGL_HEIGHT, layerHeight, EGL_LINUX_DRM_FOURCC_EXT, static_cast(layer.format) }; + for (int planeIndex = 0; planeIndex < layer.planeCount; ++planeIndex) { + const DeckVaapiDrmPrimePlane& plane = layer.planes[planeIndex]; + const DeckVaapiDrmPrimeObject& object = drmPrimeDescriptor.objects[plane.objectIndex]; + appendPlaneAttributes(attributes, planeIndex, object.fd, plane.offset, plane.pitch, object.formatModifier, includeModifiers); + } + attributes.push_back(EGL_NONE); + + const EGLImageKHR eglImage = createImage(eglDisplay, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attributes.data()); + if (eglImage == EGL_NO_IMAGE_KHR) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::EglImageCreationFailed, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "eglCreateImageKHR(EGL_LINUX_DMA_BUF_EXT) failed for DRM_PRIME layer " + std::to_string(layerIndex) }; + } + + GLuint glTexture = 0; + glGenTextures(1, &glTexture); + glBindTexture(textureTarget, glTexture); + glTexParameteri(textureTarget, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(textureTarget, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(textureTarget, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(textureTarget, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + imageTargetTexture(textureTarget, static_cast(eglImage)); + if (glGetError() != GL_NO_ERROR) { + void* ownedDisplay = static_cast(eglDisplay); + void* ownedImage = static_cast(eglImage); + unsigned int ownedTexture = glTexture; + QSGTexture* noTexture = nullptr; + unsigned int noProgram = 0; + destroyPresenterResource(noTexture, ownedDisplay, ownedImage, ownedTexture, noProgram); + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::GlTextureBindFailed, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "glEGLImageTargetTexture2DOES failed while binding DRM_PRIME layer " + std::to_string(layerIndex) }; + } + glBindTexture(textureTarget, 0); + pendingResource.eglImages[layerIndex] = static_cast(eglImage); + pendingResource.glTextures[layerIndex] = glTexture; + pendingResource.importedLayerCount = layerIndex + 1; + } + + if (drmPrimeDescriptor.layerCount == 1) { + QSGTexture* qtTexture = QNativeInterface::QSGOpenGLTexture::fromNativeExternalOES(pendingResource.glTextures[0], targetWindow, size); + if (qtTexture == nullptr) { + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::GlTextureBindFailed, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = "Qt Quick public QSGOpenGLTexture wrapper refused the external OES texture" }; + } + pendingResource.qtTexture = qtTexture; + pendingResource.eglImage = pendingResource.eglImages[0]; + pendingResource.glTexture = pendingResource.glTextures[0]; + pendingResource.eglImages[0] = nullptr; + pendingResource.glTextures[0] = 0; + pendingResource.importedLayerCount = 0; + } + + destroyPresenterResource(resource); + resource = std::move(pendingResource); + return DeckQrhiVaapiImportPlan{ .status = DeckQrhiVaapiImportStatus::DrmPrimeExported, .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, .detail = drmPrimeDescriptor.layerCount == 2 ? "2-layer DRM_PRIME Y/UV dmabuf imported as separate EGLImages and GL textures; awaiting shader composition proof" : "DRM_PRIME dmabuf imported into EGLImage, GL external texture, and public QSGOpenGLTexture wrapper" }; +} + +DeckVaapiPresenterReadinessReport DeckVaapiEglImagePresenter::readinessReportForPlan(const DeckQrhiVaapiImportPlan& plan) { + const DeckVaapiPresenterReadinessState state = readinessStateForImportStatus(plan.status); + const bool planned = state == DeckVaapiPresenterReadinessState::HardwarePresenterPlanned; + return DeckVaapiPresenterReadinessReport{ + .state = state, + .importPlan = plan, + .statusCode = std::string(readinessStatusCode(state)), + .label = std::string(readinessLabel(state)), + .detail = plan.detail.empty() ? std::string(readinessLabel(state)) : plan.detail, + .ready = false, + .hardwarePresenterPlanned = planned, + }; +} + +DeckVaapiPresenterReadinessReport DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof( + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor) { + DeckQrhiVaapiImportPlan plan{ + .status = drmPrimeDescriptor.status, + .drmPrimeObjectCount = drmPrimeDescriptor.objectCount, + .drmPrimeLayerCount = drmPrimeDescriptor.layerCount, + .detail = drmPrimeDescriptor.detail, + }; + DeckVaapiPresenterReadinessReport report = readinessReportForPlan(plan); + if (drmPrimeDescriptor.status == DeckQrhiVaapiImportStatus::DrmPrimeExported && + drmPrimeDescriptor.objectCount > 0 && drmPrimeDescriptor.layerCount > 0) { + report.state = DeckVaapiPresenterReadinessState::HardwareFrameReady; + report.statusCode = std::string(readinessStatusCode(report.state)); + report.label = std::string(readinessLabel(report.state)); + report.detail = "Hardware-backed VAAPI frame decoded and exported as DRM_PRIME dmabuf metadata; Qt Quick render target is still required before EGLImage texture import"; + report.ready = false; + report.hardwarePresenterPlanned = true; + } + return report; +} + +DeckVaapiPresenterReadinessReport DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof( + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, + const DeckQrhiVaapiImportPlan& renderTargetPlan) { + DeckVaapiPresenterReadinessReport frameReport = readinessReportForDecodedFrameProof(drmPrimeDescriptor); + if (frameReport.state != DeckVaapiPresenterReadinessState::HardwareFrameReady) { + return frameReport; + } + + DeckQrhiVaapiImportPlan plan = renderTargetPlan; + plan.drmPrimeObjectCount = drmPrimeDescriptor.objectCount; + plan.drmPrimeLayerCount = drmPrimeDescriptor.layerCount; + + DeckVaapiPresenterReadinessReport report = readinessReportForPlan(plan); + report.importPlan = plan; + report.hardwarePresenterPlanned = true; + report.ready = false; + if (plan.status == DeckQrhiVaapiImportStatus::DrmPrimeExported) { + report.detail = "Hardware-backed VAAPI frame decoded and Qt Quick OpenGLRhi render target is ready; EGLImage texture import can be attempted behind the public EGL/GL capability gates"; + } else { + const std::string targetDetail = plan.detail.empty() ? std::string(readinessLabel(report.state)) : plan.detail; + report.detail = "Hardware-backed VAAPI frame decoded and exported as DRM_PRIME dmabuf metadata; Qt Quick render-target readiness is blocked: " + targetDetail; + } + return report; +} + +DeckVaapiPresenterReadinessReport DeckVaapiEglImagePresenter::readinessReportForResource(const DeckQrhiVaapiImportPlan& plan, const Resource& resource) { + DeckVaapiPresenterReadinessReport report = readinessReportForPlan(plan); + if (plan.status == DeckQrhiVaapiImportStatus::DrmPrimeExported && resource.hasTexture()) { + report.hardwarePresenterPlanned = true; + if (resource.shaderCompositionProved) { + report.state = DeckVaapiPresenterReadinessState::Ready; + report.statusCode = std::string(readinessStatusCode(report.state)); + report.label = std::string(readinessLabel(report.state)); + report.detail = plan.drmPrimeLayerCount == 2 ? "2-layer DRM_PRIME Y/UV dmabuf is imported into EGLImages, GL textures, and passed explicit shader composition proof" : "DRM_PRIME dmabuf is imported into an EGLImage, GL external texture, and Qt Quick texture wrapper after shader composition proof"; + report.ready = true; + } else { + report.state = DeckVaapiPresenterReadinessState::HardwarePresenterPlanned; + report.statusCode = std::string(readinessStatusCode(report.state)); + report.label = std::string(readinessLabel(report.state)); + report.detail = "DRM_PRIME dmabuf import produced GL texture resources, but texture-ready is gated until presenter shader composition succeeds"; + if (!resource.shaderCompositionDetail.empty()) { + report.detail += ": " + resource.shaderCompositionDetail; + } + report.ready = false; + } + } + return report; +} + DeckLinuxMediaProbe DeckLinuxMediaProbe::detect() { const AVHWDeviceType vaapiType = av_hwdevice_find_type_by_name("vaapi"); AVBufferRef* hardwareDevice = nullptr; @@ -82,6 +1012,430 @@ DeckLinuxMediaProbe DeckLinuxMediaProbe::detect() { }; } +DeckQrhiVaapiFrameLease::DeckQrhiVaapiFrameLease(AVFrame* frame) + : frame_(frame) {} + +DeckQrhiVaapiFrameLease::~DeckQrhiVaapiFrameLease() { + if (frame_ != nullptr) { + av_frame_free(&frame_); + } +} + +std::shared_ptr DeckQrhiVaapiFrameLease::cloneHardwareFrame(const AVFrame& frame) { + if (frame.format != AV_PIX_FMT_VAAPI) { + return nullptr; + } + AVFrame* clonedFrame = av_frame_clone(&frame); + if (clonedFrame == nullptr) { + return nullptr; + } + return std::shared_ptr(new DeckQrhiVaapiFrameLease(clonedFrame)); +} + +bool DeckQrhiVaapiFrameLease::valid() const { + return frame_ != nullptr && frame_->format == AV_PIX_FMT_VAAPI && + (frame_->data[3] != nullptr || frame_->hw_frames_ctx != nullptr); +} + +std::uintptr_t DeckQrhiVaapiFrameLease::surfaceId() const { + const std::uintptr_t vaSurfaceId = valid() ? reinterpret_cast(frame_->data[3]) : 0; + return vaSurfaceId == 0 && valid() ? 1 : vaSurfaceId; +} + +DeckQrhiVaapiDrmPrimeDescriptor DeckQrhiVaapiFrameLease::exportDrmPrimeDescriptor() const { + if (frame_ == nullptr) { + DeckQrhiVaapiDrmPrimeDescriptor descriptor; + descriptor.status = DeckQrhiVaapiImportStatus::MissingFrameLease; + descriptor.detail = "VAAPI frame lease is empty"; + return descriptor; + } + if (!valid()) { + DeckQrhiVaapiDrmPrimeDescriptor descriptor; + descriptor.status = DeckQrhiVaapiImportStatus::InvalidVaapiFrame; + descriptor.detail = "frame lease does not contain a valid AV_PIX_FMT_VAAPI surface"; + return descriptor; + } + if (frame_->hw_frames_ctx == nullptr) { + DeckQrhiVaapiDrmPrimeDescriptor descriptor; + descriptor.status = DeckQrhiVaapiImportStatus::MissingHardwareFramesContext; + descriptor.detail = "VAAPI frame has no AVHWFramesContext; cannot map to DRM_PRIME"; + return descriptor; + } + + AVFrame* drmPrimeFrame = av_frame_alloc(); + if (drmPrimeFrame == nullptr) { + DeckQrhiVaapiDrmPrimeDescriptor descriptor; + descriptor.status = DeckQrhiVaapiImportStatus::DrmPrimeMapFailed; + descriptor.detail = "av_frame_alloc() failed before DRM_PRIME map"; + return descriptor; + } + drmPrimeFrame->format = AV_PIX_FMT_DRM_PRIME; + const int mapResult = av_hwframe_map(drmPrimeFrame, frame_, AV_HWFRAME_MAP_READ); + if (mapResult < 0) { + av_frame_free(&drmPrimeFrame); + DeckQrhiVaapiDrmPrimeDescriptor descriptor; + descriptor.status = DeckQrhiVaapiImportStatus::DrmPrimeMapFailed; + descriptor.detail = "av_hwframe_map(VAAPI -> DRM_PRIME) failed: " + ffmpegErrorString(mapResult); + return descriptor; + } + + return DeckQrhiVaapiDrmPrimeDescriptor(drmPrimeFrame); +} + +void DeckQrhiVaapiPresentationHandoff::setSink(std::shared_ptr sink) { + sink_ = std::move(sink); + borrowedSink_ = nullptr; +} + +void DeckQrhiVaapiPresentationHandoff::setBorrowedSink(DeckQtQuickRhiPresentationSink* sink) { + sink_.reset(); + borrowedSink_ = sink; +} + +void DeckQrhiVaapiPresentationHandoff::clearSink() { + sink_.reset(); + borrowedSink_ = nullptr; +} + +bool DeckQrhiVaapiPresentationHandoff::presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) { + const std::shared_ptr sink = sink_.lock(); + DeckQtQuickRhiPresentationSink* activeSink = sink != nullptr ? sink.get() : borrowedSink_; + if (activeSink == nullptr) { + return false; + } + if (!descriptor.hardwareBacked || descriptor.surfaceId == 0 || descriptor.width <= 0 || descriptor.height <= 0) { + activeSink->presentVaapiSurface(descriptor); + return false; + } + if (!activeSink->presentVaapiSurface(descriptor)) { + return false; + } + ++presentedFrames_; + return true; +} + +int DeckQrhiVaapiPresentationHandoff::presentedFrames() const { + return presentedFrames_; +} + +DeckVaapiPreviewFramePump::DeckVaapiPreviewFramePump(DeckQrhiVaapiPresentationHandoff& handoff) + : handoff_(handoff) {} + +bool DeckVaapiPreviewFramePump::isValidPreviewFrame(const DeckQrhiVaapiPresentationDescriptor& descriptor) { + return descriptor.hardwareBacked && descriptor.surfaceId != 0 && descriptor.width > 0 && descriptor.height > 0 && + descriptor.frameLease != nullptr && descriptor.frameLease->valid(); +} + +bool DeckVaapiPreviewFramePump::enqueueDecodedFrame(DeckQrhiVaapiPresentationDescriptor descriptor) { + if (!isValidPreviewFrame(descriptor)) { + clearPending(); + ++invalidatedFrames_; + handoff_.presentVaapiSurface(descriptor); + return false; + } + if (hasPendingDescriptor_) { + ++coalescedFrames_; + } + pendingDescriptor_ = std::move(descriptor); + hasPendingDescriptor_ = true; + ++queuedFrames_; + return true; +} + +bool DeckVaapiPreviewFramePump::flushNewest() { + if (!hasPendingDescriptor_) { + return false; + } + DeckQrhiVaapiPresentationDescriptor descriptor = std::move(pendingDescriptor_); + clearPending(); + if (!handoff_.presentVaapiSurface(descriptor)) { + return false; + } + ++flushedFrames_; + return true; +} + +void DeckVaapiPreviewFramePump::clearPending() { + pendingDescriptor_ = {}; + hasPendingDescriptor_ = false; +} + +int DeckVaapiPreviewFramePump::queuedFrames() const { + return queuedFrames_; +} + +int DeckVaapiPreviewFramePump::coalescedFrames() const { + return coalescedFrames_; +} + +int DeckVaapiPreviewFramePump::flushedFrames() const { + return flushedFrames_; +} + +int DeckVaapiPreviewFramePump::invalidatedFrames() const { + return invalidatedFrames_; +} + +int DeckVaapiPreviewFramePump::pendingFrames() const { + return hasPendingDescriptor_ ? 1 : 0; +} + +DeckProductPreviewPipeline::DeckProductPreviewPipeline() = default; + +void DeckProductPreviewPipeline::attachSink(std::shared_ptr sink) { + handoff_.setSink(std::move(sink)); +} + +void DeckProductPreviewPipeline::attachBorrowedSink(DeckQtQuickRhiPresentationSink* sink) { + handoff_.setBorrowedSink(sink); +} + +bool DeckProductPreviewPipeline::presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) { + const bool hasValidHardwareLease = descriptor.hardwareBacked && descriptor.surfaceId != 0 && + descriptor.width > 0 && descriptor.height > 0 && descriptor.frameLease != nullptr && descriptor.frameLease->valid(); + + if (!hasValidHardwareLease) { + previewFramePump_.enqueueDecodedFrame(descriptor); + lastReadinessReport_ = DeckVaapiEglImagePresenter::readinessReportForPlan(DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::MissingFrameLease, + .detail = "product Deck preview pipeline consumed a preview frame but stayed fail-closed because no valid decoded hardware VAAPI frame lease was available", + }); + return false; + } + + const bool queued = previewFramePump_.enqueueDecodedFrame(descriptor); + const bool flushed = queued && previewFramePump_.flushNewest(); + if (!flushed) { + lastReadinessReport_ = DeckVaapiEglImagePresenter::readinessReportForPlan(DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::DeckTargetUnavailable, + .detail = "product Deck preview pipeline could not flush the decoded hardware frame into a Qt Quick VAAPI preview sink", + }); + return false; + } + + lastReadinessReport_ = DeckVaapiPresenterReadinessReport{ + .state = DeckVaapiPresenterReadinessState::HardwareFrameReady, + .importPlan = DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::DrmPrimeExported, + .detail = "product Deck preview pipeline flushed a decoded hardware VAAPI frame through the preview pump into the Qt Quick render-node seam; texture readiness still waits for render-thread DRM_PRIME import proof", + }, + .statusCode = "hardware-frame-ready", + .label = "Hardware frame ready: product Deck preview pipeline reached Qt Quick render seam", + .detail = "product Deck preview pipeline consumed a decoded hardware frame through DeckVaapiPreviewFramePump and handed it to the Qt Quick VAAPI render-node seam; ready remains false until render-thread EGLImage shader composition proves the texture", + .ready = false, + .hardwarePresenterPlanned = true, + }; + return true; +} + +const DeckVaapiPresenterReadinessReport& DeckProductPreviewPipeline::lastReadinessReport() const { + return lastReadinessReport_; +} + +int DeckProductPreviewPipeline::queuedFrames() const { + return previewFramePump_.queuedFrames(); +} + +int DeckProductPreviewPipeline::flushedFrames() const { + return previewFramePump_.flushedFrames(); +} + +int DeckProductPreviewPipeline::invalidatedFrames() const { + return previewFramePump_.invalidatedFrames(); +} + +int DeckProductPreviewPipeline::pendingFrames() const { + return previewFramePump_.pendingFrames(); +} + +int DeckProductPreviewPipeline::presentedFrames() const { + return handoff_.presentedFrames(); +} + +DeckQtQuickRhiVaapiRenderNode::DeckQtQuickRhiVaapiRenderNode(DeckQrhiVaapiPresentationDescriptor descriptor, QQuickWindow* targetWindow) + : descriptor_(std::move(descriptor)) + , targetWindow_(targetWindow) {} + +DeckQtQuickRhiVaapiRenderNode::~DeckQtQuickRhiVaapiRenderNode() { + releaseResources(); +} + +const DeckQrhiVaapiPresentationDescriptor& DeckQtQuickRhiVaapiRenderNode::descriptor() const { + return descriptor_; +} + +void DeckQtQuickRhiVaapiRenderNode::replaceDescriptor(DeckQrhiVaapiPresentationDescriptor descriptor, QQuickWindow* targetWindow) { + presenterResource_ = {}; + lastImportPlan_ = {}; + readinessReport_ = {}; + descriptor_ = std::move(descriptor); + targetWindow_ = targetWindow; + markDirty(QSGNode::DirtyGeometry | QSGNode::DirtyMaterial); +} + +bool DeckQtQuickRhiVaapiRenderNode::hasFrameLease() const { + return descriptor_.frameLease != nullptr && descriptor_.frameLease->valid(); +} + +DeckQrhiVaapiImportPlan DeckQtQuickRhiVaapiRenderNode::planQrhiImport(const RenderState* state) const { + if (descriptor_.frameLease == nullptr) { + return DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::MissingFrameLease, + .detail = "render node has no retained VAAPI frame lease", + }; + } + if (!descriptor_.frameLease->valid()) { + return DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::InvalidVaapiFrame, + .detail = "render node retained frame lease is not a valid VAAPI surface", + }; + } + if (state == nullptr) { + return DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::MissingRenderState, + .detail = "QSGRenderNode render state is required before render-thread QRhi import planning", + }; + } + DeckQrhiVaapiDrmPrimeDescriptor drmPrimeDescriptor = descriptor_.frameLease->exportDrmPrimeDescriptor(); + return DeckVaapiEglImagePresenter::planOpenGlTextureImport( + targetWindow_, + drmPrimeDescriptor, + QSize(descriptor_.width, descriptor_.height)); +} + +const DeckQrhiVaapiImportPlan& DeckQtQuickRhiVaapiRenderNode::lastImportPlan() const { + return lastImportPlan_; +} + +const DeckVaapiPresenterReadinessReport& DeckQtQuickRhiVaapiRenderNode::lastReadinessReport() const { + return readinessReport_; +} + +QSGRenderNode::StateFlags DeckQtQuickRhiVaapiRenderNode::changedStates() const { + return ColorState | BlendState | ViewportState; +} + +void DeckQtQuickRhiVaapiRenderNode::render(const RenderState* state) { + lastImportPlan_ = planQrhiImport(state); + readinessReport_ = DeckVaapiEglImagePresenter::readinessReportForPlan(lastImportPlan_); + if (lastImportPlan_.status != DeckQrhiVaapiImportStatus::DrmPrimeExported || descriptor_.frameLease == nullptr) { + if (descriptor_.frameLease != nullptr && lastImportPlan_.drmPrimeObjectCount > 0 && lastImportPlan_.drmPrimeLayerCount > 0) { + DeckQrhiVaapiDrmPrimeDescriptor drmPrimeDescriptor = descriptor_.frameLease->exportDrmPrimeDescriptor(); + readinessReport_ = DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof(drmPrimeDescriptor, lastImportPlan_); + } + qInfo().noquote() << "Nova Deck QSGRenderNode VAAPI/EGL render path" + << "status=" + QString::fromStdString(readinessReport_.statusCode) + << "objects=" + QString::number(readinessReport_.importPlan.drmPrimeObjectCount) + << "layers=" + QString::number(readinessReport_.importPlan.drmPrimeLayerCount) + << "ready=" + QString::number(readinessReport_.ready ? 1 : 0) + << "planned=" + QString::number(readinessReport_.hardwarePresenterPlanned ? 1 : 0) + << "readiness stayed false until shader composition proof" + << QString::fromStdString(readinessReport_.detail); + qInfo().noquote() << "Nova Deck VAAPI/EGL presenter readiness" + << QString::fromStdString(readinessReport_.statusCode) + << QString::fromStdString(readinessReport_.detail); + return; + } + DeckQrhiVaapiDrmPrimeDescriptor drmPrimeDescriptor = descriptor_.frameLease->exportDrmPrimeDescriptor(); + lastImportPlan_ = DeckVaapiEglImagePresenter::importOpenGlTexture( + targetWindow_, + drmPrimeDescriptor, + QSize(descriptor_.width, descriptor_.height), + presenterResource_); + readinessReport_ = DeckVaapiEglImagePresenter::readinessReportForResource(lastImportPlan_, presenterResource_); + if (lastImportPlan_.status == DeckQrhiVaapiImportStatus::DrmPrimeExported) { + if (renderPresenterTexture(presenterResource_, rect(), projectionMatrix())) { + readinessReport_ = DeckVaapiEglImagePresenter::readinessReportForResource(lastImportPlan_, presenterResource_); + } else { + lastImportPlan_.status = DeckQrhiVaapiImportStatus::EglImageShaderCompositionFailed; + lastImportPlan_.detail = "DRM_PRIME texture layers imported, but GL shader composition proof failed"; + if (!presenterResource_.shaderCompositionDetail.empty()) { + lastImportPlan_.detail += ": " + presenterResource_.shaderCompositionDetail; + } + readinessReport_ = DeckVaapiEglImagePresenter::readinessReportForPlan(lastImportPlan_); + } + } + qInfo().noquote() << "Nova Deck QSGRenderNode VAAPI/EGL render path" + << "status=" + QString::fromStdString(readinessReport_.statusCode) + << "objects=" + QString::number(readinessReport_.importPlan.drmPrimeObjectCount) + << "layers=" + QString::number(readinessReport_.importPlan.drmPrimeLayerCount) + << "ready=" + QString::number(readinessReport_.ready ? 1 : 0) + << "planned=" + QString::number(readinessReport_.hardwarePresenterPlanned ? 1 : 0) + << "readiness stayed false until shader composition proof" + << QString::fromStdString(readinessReport_.detail); + qInfo().noquote() << "Nova Deck VAAPI/EGL presenter readiness" + << QString::fromStdString(readinessReport_.statusCode) + << QString::fromStdString(readinessReport_.detail); +} + +void DeckQtQuickRhiVaapiRenderNode::releaseResources() { + presenterResource_ = {}; + descriptor_.frameLease.reset(); +} + +QSGRenderNode::RenderingFlags DeckQtQuickRhiVaapiRenderNode::flags() const { + return BoundedRectRendering; +} + +QRectF DeckQtQuickRhiVaapiRenderNode::rect() const { + return QRectF(0.0, 0.0, static_cast(descriptor_.width), static_cast(descriptor_.height)); +} + +DeckQtQuickRhiVaapiItem::DeckQtQuickRhiVaapiItem(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents, true); +} + +DeckQtQuickRhiVaapiItem::~DeckQtQuickRhiVaapiItem() = default; + +bool DeckQtQuickRhiVaapiItem::presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) { + if (!descriptor.hardwareBacked || descriptor.surfaceId == 0 || descriptor.width <= 0 || descriptor.height <= 0 || + descriptor.frameLease == nullptr || !descriptor.frameLease->valid()) { + pendingDescriptor_ = {}; + hasPendingDescriptor_ = true; + pendingDescriptorValid_ = false; + update(); + return false; + } + pendingDescriptor_ = descriptor; + hasPendingDescriptor_ = true; + pendingDescriptorValid_ = true; + ++presentedFrames_; + update(); + return true; +} + +int DeckQtQuickRhiVaapiItem::presentedFrames() const { + return presentedFrames_; +} + +QSGNode* DeckQtQuickRhiVaapiItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* updatePaintNodeData) { + (void)updatePaintNodeData; + if (!hasPendingDescriptor_) { + return oldNode; + } + + DeckQrhiVaapiPresentationDescriptor descriptor = std::move(pendingDescriptor_); + const bool descriptorValid = pendingDescriptorValid_; + pendingDescriptor_ = {}; + hasPendingDescriptor_ = false; + pendingDescriptorValid_ = false; + + if (!descriptorValid) { + delete oldNode; + return nullptr; + } + + if (oldNode != nullptr && oldNode->type() == QSGNode::RenderNodeType) { + auto* renderNode = static_cast(oldNode); + renderNode->replaceDescriptor(std::move(descriptor), window()); + return renderNode; + } + + delete oldNode; + return new DeckQtQuickRhiVaapiRenderNode(std::move(descriptor), window()); +} + std::string_view DeckVaapiFfmpegRenderer::adapterName() const { return "ffmpeg-vaapi-h264-qt-rhi-prototype"; } @@ -106,6 +1460,7 @@ int DeckVaapiFfmpegRenderer::setup( lifecycle_.redrawRate = redrawRate; lifecycle_.networkStartAllowed = false; lifecycle_.decodedHardwareFrames = 0; + lifecycle_.presentedHardwareFrames = 0; lifecycle_.lastFrameWasHardwareBacked = false; lifecycle_.lastRuntimeError.clear(); resetDecoder(); @@ -221,11 +1576,25 @@ int DeckVaapiFfmpegRenderer::submitDecodeUnit(PDECODE_UNIT decodeUnit) { while ((result = avcodec_receive_frame(codecContext_, decodedFrame_)) == 0) { const bool hardwareBacked = decodedFrame_->format == AV_PIX_FMT_VAAPI; lifecycle_.lastFrameWasHardwareBacked = hardwareBacked; - av_frame_unref(decodedFrame_); if (hardwareBacked) { ++lifecycle_.decodedHardwareFrames; + std::shared_ptr frameLease = DeckQrhiVaapiFrameLease::cloneHardwareFrame(*decodedFrame_); + const DeckQrhiVaapiPresentationDescriptor descriptor{ + .width = lifecycle_.width, + .height = lifecycle_.height, + .redrawRate = lifecycle_.redrawRate, + .surfaceId = frameLease == nullptr ? 0 : frameLease->surfaceId(), + .hardwareBacked = frameLease != nullptr && frameLease->valid(), + .frameLease = frameLease, + .source = "ffmpeg-vaapi-h264", + }; + if (previewFramePump_.enqueueDecodedFrame(descriptor) && previewFramePump_.flushNewest()) { + ++lifecycle_.presentedHardwareFrames; + } + av_frame_unref(decodedFrame_); return DR_OK; } + av_frame_unref(decodedFrame_); } if (result == AVERROR(EAGAIN)) { @@ -242,6 +1611,14 @@ const DeckRendererLifecycle& DeckVaapiFfmpegRenderer::lifecycle() const { return lifecycle_; } +DeckQrhiVaapiPresentationHandoff& DeckVaapiFfmpegRenderer::presentationHandoff() { + return presentationHandoff_; +} + +const DeckQrhiVaapiPresentationHandoff& DeckVaapiFfmpegRenderer::presentationHandoff() const { + return presentationHandoff_; +} + void DeckVaapiFfmpegRenderer::resetDecoder() { ready_ = false; if (decodedFrame_ != nullptr) { @@ -314,4 +1691,342 @@ const DeckAudioLifecycle& DeckPipeWireAudio::lifecycle() const { return lifecycle_; } +DeckGuardedStreamSessionPreviewProducer::DeckGuardedStreamSessionPreviewProducer() + : session_(renderer_, audio_, input_, *this) {} + +DeckGuardedStreamSessionPreviewProducer::~DeckGuardedStreamSessionPreviewProducer() = default; + +void DeckGuardedStreamSessionPreviewProducer::attachProductPreviewPipeline(DeckProductPreviewPipeline& pipeline) { + renderer_.presentationHandoff().setBorrowedSink(&pipeline); +} + +DeckStreamTransition DeckGuardedStreamSessionPreviewProducer::prepareNoNetwork(const DeckStreamRequest& request) { + return session_.prepare(request); +} + +DeckStreamTransition DeckGuardedStreamSessionPreviewProducer::startNoNetwork() { + return session_.startNoNetwork(); +} + +DeckStreamTransition DeckGuardedStreamSessionPreviewProducer::stop() { + return session_.stop(); +} + +const DeckMoonlightBoundary& DeckGuardedStreamSessionPreviewProducer::moonlightBoundary() const { + return session_.moonlightBoundary(); +} + +DeckVaapiFfmpegRenderer& DeckGuardedStreamSessionPreviewProducer::decodedFrameProducer() { + return renderer_; +} + +const DeckRendererLifecycle& DeckGuardedStreamSessionPreviewProducer::rendererLifecycle() const { + return renderer_.lifecycle(); +} + +const std::vector& DeckGuardedStreamSessionPreviewProducer::transitions() const { + return transitions_; +} + +std::string_view DeckGuardedStreamSessionPreviewProducer::NoopInput::adapterName() const { + return "guarded-preview-noop-input"; +} + +void DeckGuardedStreamSessionPreviewProducer::NoopInput::rumble( + const uint16_t controllerNumber, + const uint16_t lowFreqMotor, + const uint16_t highFreqMotor) { + (void)controllerNumber; + (void)lowFreqMotor; + (void)highFreqMotor; +} + +void DeckGuardedStreamSessionPreviewProducer::NoopInput::setMotionEventState( + const uint16_t controllerNumber, + const uint8_t motionType, + const uint16_t reportRateHz) { + (void)controllerNumber; + (void)motionType; + (void)reportRateHz; +} + +void DeckGuardedStreamSessionPreviewProducer::NoopInput::setControllerLed( + const uint16_t controllerNumber, + const uint8_t r, + const uint8_t g, + const uint8_t b) { + (void)controllerNumber; + (void)r; + (void)g; + (void)b; +} + +void DeckGuardedStreamSessionPreviewProducer::onSessionEvent( + const DeckStreamSessionState state, + const std::string_view reason) { + transitions_.push_back(DeckStreamTransition{ + .state = state, + .reason = std::string(reason), + .networkStarted = false, + }); +} + +const DeckOperatorStartAuthorizationSnapshot& DeckOperatorStartAuthorizationPolicy::snapshot() const { + return snapshot_; +} + +void DeckOperatorStartAuthorizationPolicy::block(std::string reason) { + snapshot_ = DeckOperatorStartAuthorizationSnapshot{ + .mode = DeckOperatorStartAuthorizationMode::Blocked, + .statusCode = "operator-start-blocked", + .reason = std::move(reason), + .dryRunAuthorized = false, + .startAuthorized = false, + .tokenless = true, + .networkStarted = false, + }; +} + +void DeckOperatorStartAuthorizationPolicy::authorizeDryRun(std::string opaqueLocalStateId) { + snapshot_ = DeckOperatorStartAuthorizationSnapshot{ + .mode = DeckOperatorStartAuthorizationMode::DryRunAuthorized, + .statusCode = "operator-dry-run-authorized", + .reason = "operator approved a tokenless dry-run contract; host/network start remains disabled", + .opaqueLocalStateId = std::move(opaqueLocalStateId), + .dryRunAuthorized = true, + .startAuthorized = false, + .tokenless = true, + .networkStarted = false, + }; +} + +void DeckOperatorStartAuthorizationPolicy::authorizeStart(std::string opaqueLocalStateId) { + snapshot_ = DeckOperatorStartAuthorizationSnapshot{ + .mode = DeckOperatorStartAuthorizationMode::StartAuthorized, + .statusCode = "operator-start-authorized", + .reason = "operator approved a tokenless start contract, pending external host readiness checks", + .opaqueLocalStateId = std::move(opaqueLocalStateId), + .dryRunAuthorized = true, + .startAuthorized = true, + .tokenless = true, + .networkStarted = false, + }; +} + +namespace { + +std::string operatorAuthorizationStateLabel(const DeckOperatorStartAuthorizationMode mode) { + switch (mode) { + case DeckOperatorStartAuthorizationMode::Blocked: + return "blocked"; + case DeckOperatorStartAuthorizationMode::DryRunAuthorized: + return "dry-run-authorized"; + case DeckOperatorStartAuthorizationMode::StartAuthorized: + return "start-authorized"; + } + return "blocked"; +} + +} // namespace + +DeckGuardedPreviewLifecycleGate::DeckGuardedPreviewLifecycleGate(DeckGuardedStreamSessionPreviewProducer& producer) + : producer_(producer) {} + +void DeckGuardedPreviewLifecycleGate::attachProductPreviewPipeline(DeckProductPreviewPipeline& pipeline) { + producer_.attachProductPreviewPipeline(pipeline); + lastReport_ = DeckGuardedPreviewLifecycleReport{ + .state = DeckStreamSessionState::Idle, + .statusCode = "idle-no-network", + .reason = "guarded product preview pipeline attached; host/network start remains disabled until explicitly armed", + .prepared = false, + .armed = false, + .operatorAuthorizationState = "blocked", + .networkStartAllowed = producer_.moonlightBoundary().networkStartAllowed, + .networkStarted = false, + .transitionCount = producer_.transitions().size(), + }; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::armNoNetwork(const DeckStreamRequest& request) { + if (lastReport_.state == DeckStreamSessionState::Active && lastReport_.armed) { + lastReport_.statusCode = "already-active-no-network"; + lastReport_.reason = "guarded product preview is already armed no-network; duplicate arm request stayed local and idempotent"; + lastReport_.networkStartAllowed = producer_.moonlightBoundary().networkStartAllowed; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + return lastReport_; + } + + const auto prepared = producer_.prepareNoNetwork(request); + if (prepared.state != DeckStreamSessionState::Preparing) { + lastReport_ = reportForTransition(prepared, "prepare-denied-no-network", false, false, &request); + return lastReport_; + } + + const auto armed = producer_.startNoNetwork(); + lastReport_ = reportForTransition( + armed, + armed.state == DeckStreamSessionState::Active ? "active-no-network" : "arm-denied-no-network", + true, + armed.state == DeckStreamSessionState::Active, + &request); + return lastReport_; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::requestGuardedHostNetworkStart() { + lastReport_.statusCode = "host-network-start-blocked"; + lastReport_.reason = "guarded host/network start boundary is explicit but blocked pending operator authorization; no external host bootstrap or network start was attempted"; + lastReport_.dryRunPreflightRequested = false; + lastReport_.hostStartBoundaryExplicit = true; + lastReport_.hostStartContractAuthorized = false; + lastReport_.operatorAuthorizationState = "blocked"; + lastReport_.networkStartAllowed = false; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + return lastReport_; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::requestOperatorAuthorizedDryRun( + const DeckOperatorStartAuthorizationSnapshot& authorization) { + lastReport_.dryRunPreflightRequested = false; + lastReport_.hostStartBoundaryExplicit = true; + lastReport_.hostStartContractAuthorized = false; + lastReport_.operatorAuthorizationState = operatorAuthorizationStateLabel(authorization.mode); + lastReport_.networkStartAllowed = false; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + + if (!authorization.dryRunAuthorized) { + lastReport_.statusCode = "operator-dry-run-blocked"; + lastReport_.reason = "operator dry-run contract is blocked; no producer setup or host/network start was attempted"; + return lastReport_; + } + + lastReport_.statusCode = "operator-dry-run-authorized"; + lastReport_.reason = "operator dry-run contract approved tokenlessly; report-only path stayed local and networkStarted=false"; + return lastReport_; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::requestHostStartDryRunPreflight( + const DeckOperatorStartAuthorizationSnapshot& authorization, + const DeckStreamRequest& request) { + lastReport_.hostId = request.hostId; + lastReport_.gameId = request.gameId; + lastReport_.width = request.width; + lastReport_.height = request.height; + lastReport_.fps = request.fps; + lastReport_.bitrateKbps = request.bitrateKbps; + lastReport_.dryRunPreflightRequested = true; + lastReport_.hostStartBoundaryExplicit = true; + lastReport_.hostStartContractAuthorized = false; + lastReport_.operatorAuthorizationState = operatorAuthorizationStateLabel(authorization.mode); + lastReport_.networkStartAllowed = false; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + + if (request.hostId.empty()) { + lastReport_.statusCode = "host-start-preflight-missing-host"; + lastReport_.reason = "host start dry-run preflight requires a missing host selection to be resolved before any host contract can be evaluated; no network path was attempted"; + return lastReport_; + } + + if (!authorization.startAuthorized) { + lastReport_.statusCode = "host-start-preflight-contract-blocked"; + lastReport_.reason = "operator start contract is blocked, so host start dry-run preflight stayed report-only and no producer setup or network path was attempted"; + return lastReport_; + } + + lastReport_.statusCode = "host-start-dry-run-preflight-authorized"; + lastReport_.reason = "operator start contract approved the report-only host start dry-run preflight; requirements were summarized locally and networkStartAllowed=false"; + lastReport_.hostStartContractAuthorized = true; + return lastReport_; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::requestOperatorAuthorizedHostNetworkStart( + const DeckOperatorStartAuthorizationSnapshot& authorization) { + lastReport_.dryRunPreflightRequested = false; + lastReport_.hostStartBoundaryExplicit = true; + lastReport_.hostStartContractAuthorized = authorization.startAuthorized; + lastReport_.operatorAuthorizationState = operatorAuthorizationStateLabel(authorization.mode); + lastReport_.networkStartAllowed = false; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + + if (!authorization.startAuthorized) { + lastReport_.statusCode = "operator-start-blocked"; + lastReport_.reason = "operator start contract is blocked; no external host readiness or network path was attempted"; + return lastReport_; + } + + lastReport_.statusCode = "operator-start-not-ready"; + lastReport_.reason = "operator start contract is approved, but external host readiness is not available and the Deck product route keeps network disabled; no network start was attempted"; + return lastReport_; +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::stop() { + if (lastReport_.state == DeckStreamSessionState::Stopped) { + lastReport_.statusCode = "already-stopped-no-network"; + lastReport_.reason = "guarded product preview is already stopped; duplicate stop request stayed local and idempotent"; + lastReport_.prepared = false; + lastReport_.armed = false; + lastReport_.dryRunPreflightRequested = false; + lastReport_.networkStartAllowed = producer_.moonlightBoundary().networkStartAllowed; + lastReport_.networkStarted = false; + lastReport_.transitionCount = producer_.transitions().size(); + return lastReport_; + } + + const auto stopped = producer_.stop(); + lastReport_ = reportForTransition( + stopped, + stopped.state == DeckStreamSessionState::Stopped ? "stopped-no-network" : "stop-denied-no-network", + false, + false); + return lastReport_; +} + +const DeckGuardedPreviewLifecycleReport& DeckGuardedPreviewLifecycleGate::lastReport() const { + return lastReport_; +} + +const std::vector& DeckGuardedPreviewLifecycleGate::transitions() const { + return producer_.transitions(); +} + +DeckGuardedPreviewLifecycleReport DeckGuardedPreviewLifecycleGate::reportForTransition( + const DeckStreamTransition& transition, + std::string statusCode, + const bool prepared, + const bool armed, + const DeckStreamRequest* request) const { + const DeckStreamRequest retainedRequest{ + .hostId = request != nullptr ? request->hostId : lastReport_.hostId, + .gameId = request != nullptr ? request->gameId : lastReport_.gameId, + .width = request != nullptr ? request->width : lastReport_.width, + .height = request != nullptr ? request->height : lastReport_.height, + .fps = request != nullptr ? request->fps : lastReport_.fps, + .bitrateKbps = request != nullptr ? request->bitrateKbps : lastReport_.bitrateKbps, + }; + return DeckGuardedPreviewLifecycleReport{ + .state = transition.state, + .statusCode = std::move(statusCode), + .reason = transition.reason, + .hostId = retainedRequest.hostId, + .gameId = retainedRequest.gameId, + .width = retainedRequest.width, + .height = retainedRequest.height, + .fps = retainedRequest.fps, + .bitrateKbps = retainedRequest.bitrateKbps, + .prepared = prepared, + .armed = armed, + .dryRunPreflightRequested = false, + .hostStartBoundaryExplicit = lastReport_.hostStartBoundaryExplicit, + .hostStartContractAuthorized = false, + .operatorAuthorizationState = lastReport_.operatorAuthorizationState, + .networkStartAllowed = producer_.moonlightBoundary().networkStartAllowed, + .networkStarted = transition.networkStarted, + .transitionCount = producer_.transitions().size(), + }; +} + } // namespace nova::deck::stream diff --git a/clients/deck/src/stream/deck_stream_media_adapters.h b/clients/deck/src/stream/deck_stream_media_adapters.h index 175aa85a..4b0c0faf 100644 --- a/clients/deck/src/stream/deck_stream_media_adapters.h +++ b/clients/deck/src/stream/deck_stream_media_adapters.h @@ -2,8 +2,19 @@ #include "stream/deck_stream_core.h" +#include +#include +#include #include #include +#include + +#include +#include +#include + +class QQuickWindow; +class QSGTexture; struct AVBufferRef; struct AVCodecContext; @@ -11,6 +22,162 @@ struct AVFrame; namespace nova::deck::stream { +enum class DeckQrhiVaapiImportStatus { + NotAttempted, + DrmPrimeExported, + MissingFrameLease, + InvalidVaapiFrame, + MissingHardwareFramesContext, + DrmPrimeMapFailed, + MissingRenderState, + MissingQrhiCommandBuffer, + MissingRenderContext, + DeckTargetUnavailable, + UnsupportedNonOpenGlSceneGraph, + MissingEglDmabufExtensions, + IncompleteDrmPrimeMetadata, + UnsupportedMultiLayerDrmPrimeImport, + UnsupportedDrmPrimeFormat, + EglImageCreationFailed, + GlTextureBindFailed, + EglImageShaderCompositionFailed, + UnsupportedPublicQtLinuxDmabufImport, +}; + +struct DeckVaapiDrmPrimeObject { + int fd = -1; + std::uint64_t formatModifier = 0; +}; + +struct DeckVaapiDrmPrimePlane { + int objectIndex = -1; + std::int64_t offset = 0; + std::int64_t pitch = 0; +}; + +struct DeckVaapiDrmPrimeLayer { + std::uint32_t format = 0; + int planeCount = 0; + std::array planes{}; +}; + +class DeckQrhiVaapiDrmPrimeDescriptor final { +public: + DeckQrhiVaapiDrmPrimeDescriptor() = default; + ~DeckQrhiVaapiDrmPrimeDescriptor(); + DeckQrhiVaapiDrmPrimeDescriptor(const DeckQrhiVaapiDrmPrimeDescriptor&) = delete; + DeckQrhiVaapiDrmPrimeDescriptor& operator=(const DeckQrhiVaapiDrmPrimeDescriptor&) = delete; + DeckQrhiVaapiDrmPrimeDescriptor(DeckQrhiVaapiDrmPrimeDescriptor&& other) noexcept; + DeckQrhiVaapiDrmPrimeDescriptor& operator=(DeckQrhiVaapiDrmPrimeDescriptor&& other) noexcept; + + DeckQrhiVaapiImportStatus status = DeckQrhiVaapiImportStatus::NotAttempted; + int objectCount = 0; + int layerCount = 0; + std::array objects{}; + std::array layers{}; + std::string detail; + +private: + friend class DeckQrhiVaapiFrameLease; + explicit DeckQrhiVaapiDrmPrimeDescriptor(AVFrame* mappedFrame); + void reset(); + + AVFrame* mappedFrame_ = nullptr; +}; + +struct DeckQrhiVaapiImportPlan { + DeckQrhiVaapiImportStatus status = DeckQrhiVaapiImportStatus::NotAttempted; + int drmPrimeObjectCount = 0; + int drmPrimeLayerCount = 0; + std::string detail; +}; + +enum class DeckVaapiPresenterReadinessState { + NotAttempted, + Ready, + HardwarePresenterPlanned, + HardwareFrameReady, + MissingFrameLease, + InvalidVaapiFrame, + MissingHardwareFramesContext, + DrmPrimeMapFailed, + MissingRenderState, + MissingQrhiCommandBuffer, + MissingRenderContext, + DeckTargetUnavailable, + UnsupportedNonOpenGlSceneGraph, + MissingEglDmabufExtensions, + IncompleteDrmPrimeMetadata, + UnsupportedMultiLayerDrmPrimeImport, + UnsupportedDrmPrimeFormat, + EglImageCreationFailed, + GlTextureBindFailed, + EglImageShaderCompositionFailed, + UnsupportedPublicQtLinuxDmabufImport, +}; + +struct DeckVaapiPresenterReadinessReport { + DeckVaapiPresenterReadinessState state = DeckVaapiPresenterReadinessState::NotAttempted; + DeckQrhiVaapiImportPlan importPlan; + std::string statusCode; + std::string label; + std::string detail; + bool ready = false; + bool hardwarePresenterPlanned = false; +}; + +class DeckVaapiEglImagePresenter final { +public: + struct Resource { + ~Resource(); + Resource() = default; + Resource(const Resource&) = delete; + Resource& operator=(const Resource&) = delete; + Resource(Resource&& other) noexcept; + Resource& operator=(Resource&& other) noexcept; + + bool hasTexture() const; + + QSGTexture* qtTexture = nullptr; + void* eglDisplay = nullptr; + void* eglImage = nullptr; + unsigned int glTexture = 0; + unsigned int glProgram = 0; + std::array eglImages{}; + std::array glTextures{}; + int importedLayerCount = 0; + bool shaderCompositionProved = false; + std::string shaderCompositionDetail; + }; + + static DeckQrhiVaapiImportPlan validateDrmPrimeMetadata(const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor); + static DeckQrhiVaapiImportPlan planOpenGlTextureImport( + QQuickWindow* targetWindow, + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, + const QSize& size); + static DeckQrhiVaapiImportPlan importOpenGlTexture( + QQuickWindow* targetWindow, + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, + const QSize& size, + Resource& resource); + static DeckQrhiVaapiImportPlan importOpenGlTextureForCurrentContext( + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, + const QSize& size, + Resource& resource); + static bool proveOpenGlShaderCompositionForCurrentContext( + Resource& resource, + const QSize& size); + static DeckVaapiPresenterReadinessReport readinessReportForPlan(const DeckQrhiVaapiImportPlan& plan); + static DeckVaapiPresenterReadinessReport readinessReportForDecodedFrameProof( + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor); + static DeckVaapiPresenterReadinessReport readinessReportForDecodedFrameProof( + const DeckQrhiVaapiDrmPrimeDescriptor& drmPrimeDescriptor, + const DeckQrhiVaapiImportPlan& renderTargetPlan); + static DeckVaapiPresenterReadinessReport readinessReportForResource( + const DeckQrhiVaapiImportPlan& plan, + const Resource& resource); +}; + struct DeckLinuxMediaProbe { bool ffmpegLibavcodecHeadersLinked = false; bool ffmpegLibavutilHeadersLinked = false; @@ -36,6 +203,7 @@ struct DeckRendererLifecycle { bool ownsHardwareDevice = false; bool ownsCodecContext = false; int decodedHardwareFrames = 0; + int presentedHardwareFrames = 0; bool lastFrameWasHardwareBacked = false; std::string runtimeStatus; std::string lastRuntimeError; @@ -45,6 +213,146 @@ struct DeckRendererLifecycle { int videoFormat = 0; }; +class DeckQrhiVaapiFrameLease final { +public: + ~DeckQrhiVaapiFrameLease(); + DeckQrhiVaapiFrameLease(const DeckQrhiVaapiFrameLease&) = delete; + DeckQrhiVaapiFrameLease& operator=(const DeckQrhiVaapiFrameLease&) = delete; + DeckQrhiVaapiFrameLease(DeckQrhiVaapiFrameLease&&) = delete; + DeckQrhiVaapiFrameLease& operator=(DeckQrhiVaapiFrameLease&&) = delete; + + static std::shared_ptr cloneHardwareFrame(const AVFrame& frame); + bool valid() const; + std::uintptr_t surfaceId() const; + DeckQrhiVaapiDrmPrimeDescriptor exportDrmPrimeDescriptor() const; + +private: + explicit DeckQrhiVaapiFrameLease(AVFrame* frame); + + AVFrame* frame_ = nullptr; +}; + +struct DeckQrhiVaapiPresentationDescriptor { + int width = 0; + int height = 0; + int redrawRate = 0; + std::uintptr_t surfaceId = 0; + bool hardwareBacked = false; + std::shared_ptr frameLease; + std::string source; +}; + +class DeckQtQuickRhiPresentationSink { +public: + virtual ~DeckQtQuickRhiPresentationSink() = default; + virtual bool presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) = 0; +}; + +class DeckQrhiVaapiPresentationHandoff final { +public: + void setSink(std::shared_ptr sink); + void setBorrowedSink(DeckQtQuickRhiPresentationSink* sink); + void clearSink(); + bool presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor); + int presentedFrames() const; + +private: + std::weak_ptr sink_; + DeckQtQuickRhiPresentationSink* borrowedSink_ = nullptr; + int presentedFrames_ = 0; +}; + +class DeckVaapiPreviewFramePump final { +public: + explicit DeckVaapiPreviewFramePump(DeckQrhiVaapiPresentationHandoff& handoff); + + bool enqueueDecodedFrame(DeckQrhiVaapiPresentationDescriptor descriptor); + bool flushNewest(); + void clearPending(); + + int queuedFrames() const; + int coalescedFrames() const; + int flushedFrames() const; + int invalidatedFrames() const; + int pendingFrames() const; + +private: + static bool isValidPreviewFrame(const DeckQrhiVaapiPresentationDescriptor& descriptor); + + DeckQrhiVaapiPresentationHandoff& handoff_; + DeckQrhiVaapiPresentationDescriptor pendingDescriptor_{}; + bool hasPendingDescriptor_ = false; + int queuedFrames_ = 0; + int coalescedFrames_ = 0; + int flushedFrames_ = 0; + int invalidatedFrames_ = 0; +}; + +class DeckProductPreviewPipeline final : public DeckQtQuickRhiPresentationSink { +public: + DeckProductPreviewPipeline(); + + void attachSink(std::shared_ptr sink); + void attachBorrowedSink(DeckQtQuickRhiPresentationSink* sink); + bool presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) override; + const DeckVaapiPresenterReadinessReport& lastReadinessReport() const; + + int queuedFrames() const; + int flushedFrames() const; + int invalidatedFrames() const; + int pendingFrames() const; + int presentedFrames() const; + +private: + DeckQrhiVaapiPresentationHandoff handoff_{}; + DeckVaapiPreviewFramePump previewFramePump_{handoff_}; + DeckVaapiPresenterReadinessReport lastReadinessReport_{}; +}; + +class DeckQtQuickRhiVaapiRenderNode final : public QSGRenderNode { +public: + explicit DeckQtQuickRhiVaapiRenderNode(DeckQrhiVaapiPresentationDescriptor descriptor, QQuickWindow* targetWindow = nullptr); + ~DeckQtQuickRhiVaapiRenderNode() override; + + const DeckQrhiVaapiPresentationDescriptor& descriptor() const; + // Scenegraph-thread only: replaces the retained frame and drops GL/EGL resources owned by the prior frame. + void replaceDescriptor(DeckQrhiVaapiPresentationDescriptor descriptor, QQuickWindow* targetWindow = nullptr); + bool hasFrameLease() const; + DeckQrhiVaapiImportPlan planQrhiImport(const RenderState* state) const; + const DeckQrhiVaapiImportPlan& lastImportPlan() const; + const DeckVaapiPresenterReadinessReport& lastReadinessReport() const; + StateFlags changedStates() const override; + void render(const RenderState* state) override; + void releaseResources() override; + RenderingFlags flags() const override; + QRectF rect() const override; + +private: + DeckQrhiVaapiPresentationDescriptor descriptor_{}; + DeckQrhiVaapiImportPlan lastImportPlan_{}; + DeckVaapiPresenterReadinessReport readinessReport_{}; + QQuickWindow* targetWindow_ = nullptr; + DeckVaapiEglImagePresenter::Resource presenterResource_{}; +}; + +class DeckQtQuickRhiVaapiItem : public QQuickItem, public DeckQtQuickRhiPresentationSink { +public: + explicit DeckQtQuickRhiVaapiItem(QQuickItem* parent = nullptr); + ~DeckQtQuickRhiVaapiItem() override; + + bool presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) override; + int presentedFrames() const; + +protected: + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* updatePaintNodeData) override; + +private: + DeckQrhiVaapiPresentationDescriptor pendingDescriptor_{}; + bool hasPendingDescriptor_ = false; + bool pendingDescriptorValid_ = false; + int presentedFrames_ = 0; +}; + class DeckVaapiFfmpegRenderer final : public DeckStreamRenderer { public: ~DeckVaapiFfmpegRenderer() override; @@ -62,6 +370,8 @@ class DeckVaapiFfmpegRenderer final : public DeckStreamRenderer { int submitDecodeUnit(PDECODE_UNIT decodeUnit) override; const DeckRendererLifecycle& lifecycle() const; + DeckQrhiVaapiPresentationHandoff& presentationHandoff(); + const DeckQrhiVaapiPresentationHandoff& presentationHandoff() const; private: void resetDecoder(); @@ -71,6 +381,8 @@ class DeckVaapiFfmpegRenderer final : public DeckStreamRenderer { AVBufferRef* hardwareDevice_ = nullptr; AVCodecContext* codecContext_ = nullptr; AVFrame* decodedFrame_ = nullptr; + DeckQrhiVaapiPresentationHandoff presentationHandoff_{}; + DeckVaapiPreviewFramePump previewFramePump_{presentationHandoff_}; }; struct DeckLinuxAudioProbe { @@ -110,4 +422,121 @@ class DeckPipeWireAudio final : public DeckStreamAudio { bool ready_ = false; }; +class DeckGuardedStreamSessionPreviewProducer final : private DeckStreamSessionEvents { +public: + DeckGuardedStreamSessionPreviewProducer(); + ~DeckGuardedStreamSessionPreviewProducer() override; + DeckGuardedStreamSessionPreviewProducer(const DeckGuardedStreamSessionPreviewProducer&) = delete; + DeckGuardedStreamSessionPreviewProducer& operator=(const DeckGuardedStreamSessionPreviewProducer&) = delete; + DeckGuardedStreamSessionPreviewProducer(DeckGuardedStreamSessionPreviewProducer&&) = delete; + DeckGuardedStreamSessionPreviewProducer& operator=(DeckGuardedStreamSessionPreviewProducer&&) = delete; + + void attachProductPreviewPipeline(DeckProductPreviewPipeline& pipeline); + DeckStreamTransition prepareNoNetwork(const DeckStreamRequest& request); + DeckStreamTransition startNoNetwork(); + DeckStreamTransition stop(); + + const DeckMoonlightBoundary& moonlightBoundary() const; + DeckVaapiFfmpegRenderer& decodedFrameProducer(); + const DeckRendererLifecycle& rendererLifecycle() const; + const std::vector& transitions() const; + +private: + class NoopInput final : public DeckStreamInput { + public: + std::string_view adapterName() const override; + void rumble(uint16_t controllerNumber, uint16_t lowFreqMotor, uint16_t highFreqMotor) override; + void setMotionEventState(uint16_t controllerNumber, uint8_t motionType, uint16_t reportRateHz) override; + void setControllerLed(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t b) override; + }; + + void onSessionEvent(DeckStreamSessionState state, std::string_view reason) override; + + DeckVaapiFfmpegRenderer renderer_{}; + DeckPipeWireAudio audio_{}; + NoopInput input_{}; + DeckStreamSession session_; + std::vector transitions_{}; +}; + +struct DeckGuardedPreviewLifecycleReport { + DeckStreamSessionState state = DeckStreamSessionState::Idle; + std::string statusCode = "idle-no-network"; + std::string reason = "guarded product preview lifecycle is idle; host/network start remains disabled"; + std::string hostId; + std::string gameId; + int width = 0; + int height = 0; + int fps = 0; + int bitrateKbps = 0; + bool prepared = false; + bool armed = false; + bool dryRunPreflightRequested = false; + bool hostStartBoundaryExplicit = false; + bool hostStartContractAuthorized = false; + std::string operatorAuthorizationState = "blocked"; + bool networkStartAllowed = false; + bool networkStarted = false; + std::size_t transitionCount = 0; +}; + +enum class DeckOperatorStartAuthorizationMode { + Blocked, + DryRunAuthorized, + StartAuthorized, +}; + +struct DeckOperatorStartAuthorizationSnapshot { + DeckOperatorStartAuthorizationMode mode = DeckOperatorStartAuthorizationMode::Blocked; + std::string statusCode = "operator-start-blocked"; + std::string reason = "operator has not approved a host/network start contract"; + std::string opaqueLocalStateId; + bool dryRunAuthorized = false; + bool startAuthorized = false; + bool tokenless = true; + bool networkStarted = false; +}; + +class DeckOperatorStartAuthorizationPolicy final { +public: + [[nodiscard]] const DeckOperatorStartAuthorizationSnapshot& snapshot() const; + void block(std::string reason = "operator start contract returned to blocked state"); + void authorizeDryRun(std::string opaqueLocalStateId); + void authorizeStart(std::string opaqueLocalStateId); + +private: + DeckOperatorStartAuthorizationSnapshot snapshot_{}; +}; + +class DeckGuardedPreviewLifecycleGate final { +public: + explicit DeckGuardedPreviewLifecycleGate(DeckGuardedStreamSessionPreviewProducer& producer); + + void attachProductPreviewPipeline(DeckProductPreviewPipeline& pipeline); + DeckGuardedPreviewLifecycleReport armNoNetwork(const DeckStreamRequest& request); + DeckGuardedPreviewLifecycleReport requestGuardedHostNetworkStart(); + DeckGuardedPreviewLifecycleReport requestOperatorAuthorizedDryRun( + const DeckOperatorStartAuthorizationSnapshot& authorization); + DeckGuardedPreviewLifecycleReport requestHostStartDryRunPreflight( + const DeckOperatorStartAuthorizationSnapshot& authorization, + const DeckStreamRequest& request); + DeckGuardedPreviewLifecycleReport requestOperatorAuthorizedHostNetworkStart( + const DeckOperatorStartAuthorizationSnapshot& authorization); + DeckGuardedPreviewLifecycleReport stop(); + + const DeckGuardedPreviewLifecycleReport& lastReport() const; + const std::vector& transitions() const; + +private: + DeckGuardedPreviewLifecycleReport reportForTransition( + const DeckStreamTransition& transition, + std::string statusCode, + bool prepared, + bool armed, + const DeckStreamRequest* request = nullptr) const; + + DeckGuardedStreamSessionPreviewProducer& producer_; + DeckGuardedPreviewLifecycleReport lastReport_{}; +}; + } // namespace nova::deck::stream diff --git a/clients/deck/tests/deck_backend_interfaces_test.cpp b/clients/deck/tests/deck_backend_interfaces_test.cpp new file mode 100644 index 00000000..dadedd49 --- /dev/null +++ b/clients/deck/tests/deck_backend_interfaces_test.cpp @@ -0,0 +1,570 @@ +#include "backend/deck_backend_interfaces.h" + +#include +#include +#include +#include +#include + +#include "polaris_game_fixture.h" + +namespace { + +using nova::deck::backend::DeckBackendReadiness; +using nova::deck::backend::DeckCredentialMetadata; +using nova::deck::backend::DeckCredentialStore; +using nova::deck::backend::DeckDiagnosticsModel; +using nova::deck::backend::DeckEndpointClass; +using nova::deck::backend::DeckFakeHostRepository; +using nova::deck::backend::DeckFixtureReadOnlyStateProvider; +using nova::deck::backend::DeckHostState; +using nova::deck::backend::DeckLabGate; +using nova::deck::backend::DeckLabGateMode; +using nova::deck::backend::DeckLaunchPreflightInput; +using nova::deck::backend::DeckLaunchPreflightService; +using nova::deck::backend::DeckLibraryAvailability; +using nova::deck::backend::DeckPublicBackendPreviewRequest; +using nova::deck::backend::DeckPreflightBlockerCategory; +using nova::deck::backend::DeckPreflightReport; +using nova::deck::backend::DeckSessionSummary; +using nova::deck::backend::DeckStreamSessionCoordinator; +using nova::deck::backend::buildReadOnlyHostLibraryStateMatrix; +using nova::deck::backend::requestDeckBackendDiagnosticsPreview; +using nova::deck::backend::requestDeckBackendPreflightPreview; + +bool hasBlocker(const DeckPreflightReport& report, DeckPreflightBlockerCategory category) { + return std::any_of(report.blockers.begin(), report.blockers.end(), [category](const auto& blocker) { + return blocker.category == category; + }); +} + +void requireBlocker(const DeckPreflightReport& report, DeckPreflightBlockerCategory category) { + assert(hasBlocker(report, category)); + assert(!report.approved); + assert(!report.coordinatorRequest.launchAllowed); + assert(!report.coordinatorRequest.streamAllowed); + assert(!report.publicCopy.empty()); +} + +DeckLaunchPreflightInput validInput() { + return DeckLaunchPreflightInput{ + .host = nova::deck::backend::DeckHostSummary{ + .id = "host-gaming-pc", + .displayName = "Gaming PC", + .state = DeckHostState::Paired, + .endpointClass = DeckEndpointClass::Manual, + .fixtureOnly = false, + .hasEndpointCandidate = true, + .polarisAvailable = true, + .standardAppListAvailable = true, + .rawEndpointForBackendOnly = "10.0.0.42:47989", + }, + .credentials = DeckCredentialMetadata{ + .hostId = "host-gaming-pc", + .paired = true, + .pinnedCertFingerprint = "sha256:ABCD1234", + .certMismatch = false, + .authRejected = false, + .rawTokenForBackendOnly = "token-super-secret", + .rawCertificateForBackendOnly = "-----BEGIN CERTIFICATE----- secret -----END CERTIFICATE-----", + .rawPrivateKeyForBackendOnly = "[REDACTED PRIVATE KEY]", + }, + .library = DeckLibraryAvailability{ + .available = true, + .gameAvailable = true, + .sourceLabel = "polaris-fixture-cache", + }, + .session = DeckSessionSummary{ + .ownedByAnotherClient = false, + .watchAvailable = true, + }, + .backendReadiness = DeckBackendReadiness{ + .rendererAvailable = true, + .audioAvailable = true, + .inputAvailable = true, + }, + .labGate = DeckLabGate::forMode(DeckLabGateMode::LaunchDryRun), + .requestedGameId = "game-123", + .requestedProfileId = "balanced-800p", + .requestUrlForBackendOnly = "https://10.0.0.42:47989/launch?token=super-secret", + }; +} + +void assertPreflightBlocksEachRequiredCategory() { + DeckLaunchPreflightService service; + + auto fixture = validInput(); + fixture.host->fixtureOnly = true; + requireBlocker(service.evaluate(fixture), DeckPreflightBlockerCategory::FixtureOnly); + + auto missingHost = validInput(); + missingHost.host.reset(); + requireBlocker(service.evaluate(missingHost), DeckPreflightBlockerCategory::MissingHost); + + auto pairingRequired = validInput(); + pairingRequired.credentials.paired = false; + requireBlocker(service.evaluate(pairingRequired), DeckPreflightBlockerCategory::PairingRequired); + + auto certMismatch = validInput(); + certMismatch.credentials.certMismatch = true; + requireBlocker(service.evaluate(certMismatch), DeckPreflightBlockerCategory::CertMismatch); + + auto authRejected = validInput(); + authRejected.credentials.authRejected = true; + requireBlocker(service.evaluate(authRejected), DeckPreflightBlockerCategory::AuthRejected); + + auto libraryUnavailable = validInput(); + libraryUnavailable.library.available = false; + requireBlocker(service.evaluate(libraryUnavailable), DeckPreflightBlockerCategory::LibraryUnavailable); + + auto sessionOwned = validInput(); + sessionOwned.session.ownedByAnotherClient = true; + sessionOwned.session.watchAvailable = false; + requireBlocker(service.evaluate(sessionOwned), DeckPreflightBlockerCategory::SessionOwnedByAnotherClient); + + auto rendererUnavailable = validInput(); + rendererUnavailable.backendReadiness.rendererAvailable = false; + requireBlocker(service.evaluate(rendererUnavailable), DeckPreflightBlockerCategory::RendererUnavailable); + + auto audioUnavailable = validInput(); + audioUnavailable.backendReadiness.audioAvailable = false; + requireBlocker(service.evaluate(audioUnavailable), DeckPreflightBlockerCategory::AudioUnavailable); + + auto inputUnavailable = validInput(); + inputUnavailable.backendReadiness.inputAvailable = false; + requireBlocker(service.evaluate(inputUnavailable), DeckPreflightBlockerCategory::InputUnavailable); + + auto labGateDisabled = validInput(); + labGateDisabled.labGate = DeckLabGate::forMode(DeckLabGateMode::Disabled); + requireBlocker(service.evaluate(labGateDisabled), DeckPreflightBlockerCategory::LabGateDisabled); +} + +void assertFakeRepositoryAndMetadataFacadeStaySanitized() { + DeckFakeHostRepository repository; + repository.upsertFixtureHost("fixture-host", "Fixture Host"); + repository.upsertManualHostForTest("manual-host", "Manual Host", "192.168.1.77:47989"); + + const auto hosts = repository.listHosts(); + assert(hosts.size() == 2); + assert(hosts[0].id == "fixture-host"); + assert(hosts[0].fixtureOnly); + assert(hosts[0].endpointClass == DeckEndpointClass::Unknown); + assert(hosts[0].rawEndpointForBackendOnly.empty()); + assert(hosts[1].id == "manual-host"); + assert(hosts[1].endpointClass == DeckEndpointClass::Manual); + assert(hosts[1].rawEndpointForBackendOnly.empty()); + assert(repository.backendEndpointForTest("manual-host") == "192.168.1.77:47989"); + + DeckCredentialStore credentialStore; + credentialStore.upsertMetadata(DeckCredentialMetadata{ + .hostId = "manual-host", + .paired = true, + .pinnedCertFingerprint = "sha256:ABCD1234", + .rawTokenForBackendOnly = "token-123", + .rawCertificateForBackendOnly = "-----BEGIN CERTIFICATE----- nope -----END CERTIFICATE-----", + .rawPrivateKeyForBackendOnly = "[REDACTED PRIVATE KEY]", + }); + const auto metadata = credentialStore.metadataForHost("manual-host"); + assert(metadata.has_value()); + assert(metadata->paired); + assert(metadata->pinnedCertFingerprint == "sha256:ABCD1234"); + assert(metadata->rawTokenForBackendOnly.empty()); + assert(metadata->rawCertificateForBackendOnly.empty()); + assert(metadata->rawPrivateKeyForBackendOnly.empty()); +} + +void assertApprovedDryRunStillDoesNotStartNetwork() { + DeckLaunchPreflightService service; + const auto report = service.evaluate(validInput()); + assert(report.approved); + assert(report.coordinatorRequest.launchAllowed); + assert(!report.coordinatorRequest.streamAllowed); + assert(report.coordinatorRequest.hostId == "host-gaming-pc"); + assert(report.coordinatorRequest.gameId == "game-123"); + + DeckStreamSessionCoordinator coordinator; + const auto result = coordinator.dryRun(report.coordinatorRequest); + assert(result.accepted); + assert(!result.networkStarted); + assert(!result.rawStartCalled); + assert(result.statusCode == "coordinator-dry-run-ready"); +} + +void assertDiagnosticsAndPreflightCopyArePrivate() { + DeckLaunchPreflightService service; + const auto report = service.evaluate(validInput()); + DeckDiagnosticsModel diagnostics; + diagnostics.updatePreflight(report); + diagnostics.updateCoordinatorStatus("coordinator-dry-run-ready"); + diagnostics.updateHost(validInput().host.value()); + diagnostics.updateCredentials(validInput().credentials); + diagnostics.updateBackendReadiness(validInput().backendReadiness); + + const std::vector publicCopies{ + report.publicCopy, + diagnostics.copyText(), + }; + const std::vector forbidden{ + "token-super-secret", + "BEGIN CERTIFICATE", + "BEGIN PRIVATE KEY", + "https://10.0.0.42:47989/launch?token=super-secret", + "10.0.0.42", + "192.168.", + "private-hostname.local", + "rawEndpointForBackendOnly", + "rawTokenForBackendOnly", + }; + for (const auto& copy : publicCopies) { + for (const auto forbiddenToken : forbidden) { + assert(copy.find(forbiddenToken) == std::string::npos); + } + } +} + +void assertReadOnlyPreviewBridgeReturnsOnlyPublicBackendDtos() { + DeckFakeHostRepository repository; + repository.upsertManualHostForTest("manual-host", "Manual Host", "192.168.1.77:47989"); + + DeckCredentialStore credentialStore; + credentialStore.upsertMetadata(DeckCredentialMetadata{ + .hostId = "manual-host", + .paired = true, + .pinnedCertFingerprint = "sha256:ABCD1234", + .rawTokenForBackendOnly = "token-super-secret", + .rawCertificateForBackendOnly = "-----BEGIN CERTIFICATE----- secret -----END CERTIFICATE-----", + .rawPrivateKeyForBackendOnly = "[REDACTED PRIVATE KEY]", + }); + + DeckLaunchPreflightService preflightService; + DeckStreamSessionCoordinator coordinator; + DeckDiagnosticsModel diagnostics; + const DeckLabGate labGate = DeckLabGate::forMode(DeckLabGateMode::Disabled); + const DeckPublicBackendPreviewRequest request{ + .hostId = "manual-host", + .gameId = "game-123", + .profileId = "balanced-800p", + }; + + const auto preflight = requestDeckBackendPreflightPreview( + repository, + credentialStore, + preflightService, + coordinator, + labGate, + request); + assert(!preflight.approved); + assert(preflight.statusCode == "backend-preflight-blocked"); + assert(preflight.publicCopy.find("Deck launch preflight") != std::string::npos); + assert(std::find(preflight.blockerCodes.begin(), preflight.blockerCodes.end(), "lab-gate-disabled") != preflight.blockerCodes.end()); + assert(!preflight.launchDryRunAllowed); + assert(!preflight.streamAllowed); + assert(!preflight.backendPowerStarted); + + const auto diagnostic = requestDeckBackendDiagnosticsPreview( + repository, + credentialStore, + preflightService, + coordinator, + diagnostics, + labGate, + request); + assert(diagnostic.statusCode == "backend-diagnostics-ready"); + assert(diagnostic.copyText.find("privacy=redacted") != std::string::npos); + + const std::vector publicCopies{ + preflight.publicCopy, + diagnostic.copyText, + }; + for (const auto& copy : publicCopies) { + for (const auto forbiddenToken : std::vector{ + "token-super-secret", + "BEGIN CERTIFICATE", + "BEGIN PRIVATE KEY", + "192.168.", + "rawEndpointForBackendOnly", + "rawTokenForBackendOnly", + }) { + assert(copy.find(forbiddenToken) == std::string::npos); + } + } +} + +void assertReadOnlyHostLibraryStateComesFromBackendSummaries() { + const auto library = nova::deck::loadSamplePolarisGameLibraryFixture(); + DeckFakeHostRepository repository; + repository.upsertSanitizedHostSummary(nova::deck::backend::DeckHostSummary{ + .id = "host-snapshot-primary", + .displayName = "Polaris Snapshot Primary", + .state = DeckHostState::Fixture, + .endpointClass = DeckEndpointClass::Unknown, + .fixtureOnly = true, + .hasEndpointCandidate = false, + .polarisAvailable = true, + .standardAppListAvailable = true, + .publicStatusLabel = "Backend-owned read-only summary · fixture provenance", + .publicSubtitle = "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read.", + .publicProvenanceLabel = "fixture/read-only/backend-owned", + .rawEndpointForBackendOnly = "10.0.0.42:47989", + }); + + DeckLaunchPreflightService preflightService; + const auto state = buildReadOnlyHostLibraryState( + repository, + library, + preflightService, + DeckLabGate::forMode(DeckLabGateMode::Disabled)); + + assert(state.sourceLabel == "Shared Polaris contract fixture"); + assert(state.readOnly); + assert(state.hosts.size() == 1); + assert(state.hosts[0].id == "host-snapshot-primary"); + assert(state.hosts[0].displayName == "Polaris Snapshot Primary"); + assert(state.hosts[0].statusLabel == "Backend-owned read-only summary · fixture provenance"); + assert(state.hosts[0].subtitle == "Backend read-only host summary — no discovery, join-flow, endpoint, cert, or private material was read."); + assert(state.hosts[0].provenanceLabel == "fixture/read-only/backend-owned"); + assert(state.hosts[0].initialFocus); + assert(state.games.size() == 2); + assert(state.games[0].id == "game-123"); + assert(state.games[0].title == "Portal 2"); + assert(state.games[0].sourceRuntimeLabel == "Steam · Linux · Proton"); + assert(state.games[0].launchModeLabel == "Stream: headless · Steam: direct"); + assert(state.preflight.statusCode == "backend-read-only-preflight-blocked"); + assert(std::find(state.preflight.blockerCodes.begin(), state.preflight.blockerCodes.end(), "lab-gate-disabled") != state.preflight.blockerCodes.end()); + assert(std::find(state.preflight.blockerCodes.begin(), state.preflight.blockerCodes.end(), "fixture-only") != state.preflight.blockerCodes.end()); + assert(state.preflight.publicCopy.find("Lab gate is disabled") != std::string::npos); + assert(state.preflight.publicCopy.find("Fixture provenance only") != std::string::npos); + assert(state.preflight.publicCopy.find("Host is unpaired") != std::string::npos); + assert(!state.preflight.launchDryRunAllowed); + assert(!state.preflight.streamAllowed); + assert(!state.preflight.backendPowerStarted); + assert(state.playerState.title == "Product state: Lab gate locked"); + assert(state.playerState.body == "Launch blocked by lab gate."); + assert(state.playerState.actionLabel == "Ask an operator to open the lab gate before any start path."); + assert(state.playerState.safetyLabel == "Backend power, launch, stream, discovery, and media stay off."); + assert(state.playerState.provenanceLabel == "dto-player-state/backend-owned/redacted-public"); + assert(state.playerState.focusOrder == "state-card-copy-diagnostics"); + assert(state.playerState.focusOrderCopy == "Focus order: state card → Copy plan → Show diagnostics"); + assert(state.dtoParity.contractId == "backend-owned-read-only-dto-v1"); + assert(state.dtoParity.ownerCode == "backend-owned-read-only-model"); + assert(state.dtoParity.privacyCode == "redacted-public-dto"); + assert(state.dtoParity.readinessCode == "dto-parity-ready"); + assert(state.dtoParity.collapsedSummary.find("Backend-owned DTO parity") != std::string::npos); + assert(state.dtoParity.collapsedSummary.find(state.preflight.statusCode) != std::string::npos); + assert(state.dtoParity.expandedDiagnostics.find("backend-owned-read-only-dto-v1") != std::string::npos); + assert(state.dtoParity.expandedDiagnostics.find("privacy=redacted-public-dto") != std::string::npos); + assert(state.dtoParity.artifactSummary.find("backendPowerStarted=false") != std::string::npos); + assert(state.dtoParity.artifactSummary.find("stream=false") != std::string::npos); + + const std::vector publicStateCopies{ + state.hosts[0].statusLabel, + state.hosts[0].subtitle, + state.preflight.publicCopy, + state.dtoParity.collapsedSummary, + state.dtoParity.expandedDiagnostics, + state.dtoParity.artifactSummary, + }; + for (const auto& copy : publicStateCopies) { + for (const auto forbiddenToken : std::vector{ + "10.0.0.42", + "47989", + "BEGIN CERTIFICATE", + "token-super-secret", + "rawEndpointForBackendOnly", + }) { + assert(copy.find(forbiddenToken) == std::string::npos); + } + } +} + +void assertReadOnlyHostLibraryStateMatrixCoversDeterministicBlockers() { + const auto library = nova::deck::loadSamplePolarisGameLibraryFixture(); + DeckLaunchPreflightService preflightService; + const auto matrix = buildReadOnlyHostLibraryStateMatrix(library, preflightService); + + assert(matrix.size() == 5); + + auto stateByScenario = [&matrix](const std::string_view scenarioId) -> const nova::deck::backend::DeckPublicReadOnlyHostLibraryState& { + const auto found = std::find_if(matrix.begin(), matrix.end(), [scenarioId](const auto& state) { + return state.scenarioId == scenarioId; + }); + assert(found != matrix.end()); + return *found; + }; + auto hasPublicBlocker = [](const auto& state, const std::string_view blocker) { + return std::find(state.preflight.blockerCodes.begin(), state.preflight.blockerCodes.end(), blocker) != state.preflight.blockerCodes.end(); + }; + + const auto& empty = stateByScenario("empty"); + assert(empty.scenarioLabel.find("Ready for setup") != std::string::npos); + assert(empty.hosts.empty()); + assert(empty.games.empty()); + assert(hasPublicBlocker(empty, "missing-host")); + assert(hasPublicBlocker(empty, "library-unavailable")); + assert(empty.preflight.publicCopy.find("source=backend-owned-read-only-matrix") != std::string::npos); + + const auto& offline = stateByScenario("offline"); + assert(!offline.hosts.empty()); + assert(offline.hosts[0].statusLabel.find("offline") != std::string::npos); + assert(hasPublicBlocker(offline, "host-unreachable")); + assert(!offline.preflight.backendPowerStarted); + + const auto& unpaired = stateByScenario("unpaired"); + assert(!unpaired.hosts.empty()); + assert(unpaired.hosts[0].statusLabel.find("unpaired") != std::string::npos); + assert(hasPublicBlocker(unpaired, "pairing-required")); + assert(unpaired.preflight.publicCopy.find("credential reads stay disabled") != std::string::npos); + + const auto& libraryUnavailable = stateByScenario("library-unavailable"); + assert(!libraryUnavailable.hosts.empty()); + assert(libraryUnavailable.games.empty()); + assert(hasPublicBlocker(libraryUnavailable, "library-unavailable")); + assert(libraryUnavailable.preflight.publicCopy.find("Backend library summary is unavailable") != std::string::npos); + + const auto& labGated = stateByScenario("lab-gated"); + assert(!labGated.hosts.empty()); + assert(!labGated.games.empty()); + assert(hasPublicBlocker(labGated, "lab-gate-disabled")); + assert(hasPublicBlocker(labGated, "network-disabled")); + assert(labGated.preflight.publicCopy.find("backendPowerStarted=false") != std::string::npos); + + assert(empty.scenarioLabel == "Ready for setup · no host or game selected"); + assert(empty.playerState.title == "Product state: Ready for setup"); + assert(empty.playerState.body == "Select a host and game to continue."); + assert(empty.playerState.actionLabel == "Add a backend host before previewing a launch plan."); + assert(offline.scenarioLabel == "Host offline · reconnect before preview"); + assert(offline.playerState.title == "Product state: Host offline"); + assert(offline.playerState.body == "Host offline. Reconnect or pick another host."); + assert(offline.playerState.actionLabel == "Reconnect the host or choose another backend-owned snapshot."); + assert(offline.playerState.safetyLabel == "Backend power stays off; no retry or network probe runs."); + assert(unpaired.scenarioLabel == "Pair host · approved pairing required"); + assert(unpaired.playerState.title == "Product state: Pair host"); + assert(unpaired.playerState.body == "Pair this host before launch preview."); + assert(libraryUnavailable.scenarioLabel == "Library unavailable · read-only snapshot missing"); + assert(libraryUnavailable.playerState.title == "Product state: Library unavailable"); + assert(libraryUnavailable.playerState.body == "Library unavailable. Try again when the read-only snapshot is back."); + assert(labGated.scenarioLabel == "Lab gate locked · start paths disabled"); + assert(labGated.playerState.title == "Product state: Lab gate locked"); + + for (const auto& state : matrix) { + assert(state.readOnly); + assert(!state.scenarioId.empty()); + assert(!state.scenarioLabel.empty()); + assert(!state.preflight.launchDryRunAllowed); + assert(!state.preflight.streamAllowed); + assert(!state.preflight.backendPowerStarted); + assert(state.sourceLabel.find("read-only") != std::string::npos); + assert(state.dtoParity.contractId == "backend-owned-read-only-dto-v1"); + assert(state.dtoParity.ownerCode == "backend-owned-read-only-model"); + assert(state.dtoParity.privacyCode == "redacted-public-dto"); + assert(state.dtoParity.readinessCode == "dto-parity-ready"); + assert(state.dtoParity.collapsedSummary.find(state.preflight.statusCode) != std::string::npos); + assert(state.dtoParity.expandedDiagnostics.find(state.scenarioId) != std::string::npos); + assert(state.dtoParity.artifactSummary.find("backendPowerStarted=false") != std::string::npos); + assert(state.dtoParity.artifactSummary.find("stream=false") != std::string::npos); + assert(!state.playerState.title.empty()); + assert(!state.playerState.body.empty()); + assert(!state.playerState.actionLabel.empty()); + assert(!state.playerState.safetyLabel.empty()); + assert(state.playerState.provenanceLabel == "dto-player-state/backend-owned/redacted-public"); + assert(state.playerState.focusOrder == "state-card-copy-diagnostics"); + assert(state.playerState.focusOrderCopy == "Focus order: state card → Copy plan → Show diagnostics"); + for (const auto& copy : std::vector{ + state.sourceLabel, + state.scenarioLabel, + state.playerState.title, + state.playerState.body, + state.playerState.actionLabel, + state.playerState.safetyLabel, + state.playerState.provenanceLabel, + state.playerState.focusOrderCopy, + state.preflight.publicCopy, + state.dtoParity.collapsedSummary, + state.dtoParity.expandedDiagnostics, + state.dtoParity.artifactSummary, + }) { + for (const auto forbiddenToken : std::vector{ + "10.0.0.", + "192.168.", + "47989", + "BEGIN CERTIFICATE", + "token-super-secret", + "rawEndpointForBackendOnly", + }) { + assert(copy.find(forbiddenToken) == std::string::npos); + } + } + } +} + +void assertReadOnlyProviderOwnsStateAssemblyAndMatrixParity() { + const auto library = nova::deck::loadSamplePolarisGameLibraryFixture(); + DeckLaunchPreflightService preflightService; + DeckFixtureReadOnlyStateProvider provider(library, preflightService); + + const auto matrix = provider.stateMatrix(); + const auto legacyMatrix = buildReadOnlyHostLibraryStateMatrix(library, preflightService); + assert(matrix.size() == legacyMatrix.size()); + assert(matrix.size() == 5); + + const auto selected = provider.stateForScenario("lab-gated"); + assert(selected.scenarioId == "lab-gated"); + assert(selected.scenarioLabel == "Lab gate locked · start paths disabled"); + assert(!selected.hosts.empty()); + assert(!selected.games.empty()); + assert(!selected.preflight.backendPowerStarted); + assert(selected.playerState.title == "Product state: Lab gate locked"); + assert(selected.playerState.focusOrder == "state-card-copy-diagnostics"); + assert(selected.playerState.focusOrderCopy == "Focus order: state card → Copy plan → Show diagnostics"); + assert(selected.dtoParity.ownerCode == "backend-owned-read-only-model"); + + const auto fallback = provider.stateForScenario("missing-scenario"); + assert(fallback.scenarioId == "lab-gated"); + + for (std::size_t index = 0; index < matrix.size(); ++index) { + assert(matrix[index].scenarioId == legacyMatrix[index].scenarioId); + assert(matrix[index].scenarioLabel == legacyMatrix[index].scenarioLabel); + assert(matrix[index].hosts.size() == legacyMatrix[index].hosts.size()); + assert(matrix[index].games.size() == legacyMatrix[index].games.size()); + assert(matrix[index].preflight.statusCode == legacyMatrix[index].preflight.statusCode); + assert(matrix[index].preflight.blockerCodes == legacyMatrix[index].preflight.blockerCodes); + assert(matrix[index].playerState.title == legacyMatrix[index].playerState.title); + assert(matrix[index].playerState.focusOrder == legacyMatrix[index].playerState.focusOrder); + assert(matrix[index].playerState.focusOrderCopy == legacyMatrix[index].playerState.focusOrderCopy); + assert(matrix[index].dtoParity.contractId == legacyMatrix[index].dtoParity.contractId); + } +} + +void assertReadOnlyProviderDefaultsMissingPlayerStateAndCopiesFocusOrder() { + const auto library = nova::deck::loadSamplePolarisGameLibraryFixture(); + DeckLaunchPreflightService preflightService; + DeckFixtureReadOnlyStateProvider provider(library, preflightService); + + auto state = provider.stateForScenario("offline"); + state.playerState = nova::deck::backend::DeckPublicReadOnlyPlayerState{}; + const auto repaired = provider.withDefaultPlayerState(state); + + assert(repaired.scenarioId == "offline"); + assert(repaired.playerState.title == "Product state: Host offline"); + assert(repaired.playerState.body == "Host offline. Reconnect or pick another host."); + assert(repaired.playerState.actionLabel == "Reconnect the host or choose another backend-owned snapshot."); + assert(repaired.playerState.safetyLabel == "Backend power stays off; no retry or network probe runs."); + assert(repaired.playerState.provenanceLabel == "dto-player-state/backend-owned/redacted-public"); + assert(repaired.playerState.focusOrder == "state-card-copy-diagnostics"); + assert(repaired.playerState.focusOrderCopy == "Focus order: state card → Copy plan → Show diagnostics"); +} + +} // namespace + +int main() { + assertFakeRepositoryAndMetadataFacadeStaySanitized(); + assertPreflightBlocksEachRequiredCategory(); + assertApprovedDryRunStillDoesNotStartNetwork(); + assertDiagnosticsAndPreflightCopyArePrivate(); + assertReadOnlyPreviewBridgeReturnsOnlyPublicBackendDtos(); + assertReadOnlyHostLibraryStateComesFromBackendSummaries(); + assertReadOnlyHostLibraryStateMatrixCoversDeterministicBlockers(); + assertReadOnlyProviderOwnsStateAssemblyAndMatrixParity(); + assertReadOnlyProviderDefaultsMissingPlayerStateAndCopiesFocusOrder(); + return 0; +} diff --git a/clients/deck/tests/deck_frontend_smoke_test.py b/clients/deck/tests/deck_frontend_smoke_test.py new file mode 100644 index 00000000..cb962d5f --- /dev/null +++ b/clients/deck/tests/deck_frontend_smoke_test.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +import contextlib +import io +import pathlib +import sys +import tempfile +import unittest +from unittest import mock + +sys.dont_write_bytecode = True + +ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = ROOT.parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import deck_frontend_smoke as smoke + + +class DeckFrontendSmokeRouteTest(unittest.TestCase): + def test_podman_command_runs_visible_wayland_shell_offline_and_writes_frontend_artifacts(self): + command = smoke.build_podman_smoke_command( + source_dir=pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src"), + artifact_dir=pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts"), + ) + joined = " ".join(command) + + self.assertEqual(command[:3], ["podman", "run", "--rm"]) + self.assertIn("--network=none", command) + self.assertIn("localhost/nova-t24-arch-qt-buildtools", command) + self.assertIn("QT_QPA_PLATFORM=wayland", joined) + self.assertIn("WAYLAND_DISPLAY=gamescope-0", joined) + self.assertIn("QSG_RHI_BACKEND=opengl", joined) + self.assertIn("NOVA_DECK_FRONTEND_SMOKE=1", joined) + self.assertIn("environment-summary.txt", joined) + self.assertIn("ui-launch.log", joined) + self.assertIn("qml-runtime.log", joined) + self.assertIn("smoke-summary.txt", joined) + self.assertIn("backend-dto-interaction-smoke.txt", joined) + self.assertIn("backend-readonly-state-matrix-smoke.txt", joined) + self.assertIn("frontend-frame-capture.png", joined) + self.assertIn("frontend-expanded-diagnostics-capture.png", joined) + self.assertIn("expanded-diagnostics-frame-smoke.txt", joined) + self.assertIn("nova-deck --frontend-smoke-exit-after-ms 2500", joined) + self.assertIn("--frontend-smoke-backend-dto-interactions", joined) + self.assertIn("--frontend-smoke-readonly-state lab-gated", joined) + self.assertIn("--frontend-smoke-readonly-state-matrix", joined) + self.assertIn("--frontend-smoke-capture", joined) + self.assertIn("--frontend-smoke-expanded-diagnostics-frame", joined) + self.assertIn("--frontend-smoke-expanded-diagnostics-capture", joined) + + def test_container_shell_asserts_backend_dto_interaction_artifact_is_public_and_sanitized(self): + shell = smoke.build_container_shell( + source_dir=pathlib.PurePosixPath("/src"), + artifact_dir=pathlib.PurePosixPath("/src/build/deck-frontend-smoke-artifacts"), + ) + + self.assertIn("backend-dto-interaction-smoke.txt", shell) + self.assertIn("backend-preflight-dto-preview", shell) + self.assertIn("backend-diagnostics-dto-preview", shell) + self.assertIn("backend-preflight-blocked", shell) + self.assertIn("backend-diagnostics-ready", shell) + self.assertIn("lab-gate-disabled", shell) + self.assertIn("redacted-public-dto", shell) + self.assertIn("backend-owned-read-only-dto-v1", shell) + self.assertIn("dto-parity-ready", shell) + self.assertIn("dto_contract=backend-owned-read-only-dto-v1", shell) + self.assertIn("dto_privacy=redacted-public-dto", shell) + self.assertIn("dto_readiness=dto-parity-ready", shell) + self.assertIn("dto_owner=backend-owned-read-only-model", shell) + self.assertIn("dto_parity_contract=backend-owned-read-only-dto-v1", shell) + self.assertIn("dto_parity_verdict=pass", shell) + self.assertIn("backend-readonly-state-matrix-smoke.txt", shell) + for scenario in ["empty", "offline", "unpaired", "library-unavailable", "lab-gated"]: + self.assertIn(f"matrix_scenario={scenario}", shell) + self.assertIn("primary=", shell) + self.assertIn("diagnostics=", shell) + self.assertIn("collapsedFirstPaint=true", shell) + self.assertIn("expansionToggle=secondary-diagnostics-toggle", shell) + self.assertIn("controllerReachable=true", shell) + self.assertIn("expandedVisible=true", shell) + self.assertIn("expandedDiagnosticsCopy=Matrix diagnostic:", shell) + self.assertIn("expandedDtoParityCopy=DTO parity:", shell) + self.assertIn("dtoParity=DTO parity:", shell) + self.assertIn("diagnostics_expansion=controller-reachable", shell) + self.assertIn("diagnostics_first_paint=collapsed", shell) + self.assertIn("expanded-diagnostics-frame-smoke.txt", shell) + self.assertIn("frontend-expanded-diagnostics-capture.png", shell) + self.assertIn("expanded_frame_smoke=expanded-diagnostics-frame-smoke.txt", shell) + self.assertIn("expanded_frame_capture=frontend-expanded-diagnostics-capture.png", shell) + self.assertIn("liveExpandedBy=keyboard-controller-toggle", shell) + self.assertIn("expandedFrameSanitized=true", shell) + self.assertIn("expandedFrameReadable=true", shell) + self.assertIn("expandedFrameFocusTarget=secondary-diagnostics-toggle", shell) + self.assertIn("expandedDiagnosticsLaneFocusTarget=expanded-diagnostics-lane", shell) + self.assertIn("expandedDiagnosticsLaneReadable=true", shell) + self.assertIn("expandedDensityRowsPaged=true", shell) + self.assertIn("expandedDiagnosticsLaneHeight=132", shell) + self.assertIn("expandedDiagnosticsPageAffordanceVisible=true", shell) + self.assertIn("expandedDiagnosticsPageAffordancePosition=before-blocker-copy", shell) + self.assertIn("expandedDiagnosticsPageAffordanceText=Diagnostics page 1 of 2", shell) + self.assertIn("expandedDiagnosticsScrollNavigationMoved=true", shell) + self.assertIn("expandedDiagnosticsPostScrollCue=Diagnostics page 2 of 2", shell) + self.assertIn("expandedDiagnosticsPostScrollCueContrast=13", shell) + self.assertIn("expandedDiagnosticsPostScrollCueSpacing=separate-row-after-blocker-copy", shell) + self.assertIn("expandedDiagnosticsPostScrollCueOverlapsBlocker=false", shell) + self.assertIn("expandedDiagnosticsPostScrollTarget=lifecycle-dto-details", shell) + self.assertIn("expandedDiagnosticsFocusAffordance=4px focus ring + active focus badge", shell) + self.assertIn("expandedDiagnosticsPage2Readable=true", shell) + self.assertIn("diagnostics_page_position=page-1-of-2-scroll-affordance", shell) + self.assertIn("diagnostics_scroll_navigation=page-2-lifecycle-dto-proof-no-crowding", shell) + self.assertIn("diagnostics_focus_lane=expanded-diagnostics-lane", shell) + self.assertIn("diagnostics_focus_affordance=4px-ring-active-badge", shell) + self.assertIn("diagnostics_cue_contrast=13.56:1", shell) + self.assertIn("diagnostics_page2_readability=lifecycle-dto-readable", shell) + self.assertIn("diagnostics_density=lane-paged-breathing-room", shell) + self.assertIn("product_readiness_gate=deck-diagnostics-expanded-lane-v1", shell) + self.assertIn("product_readiness_verdict=pass", shell) + self.assertIn("product_readiness_next=backend-fed-read-only-dto-parity", shell) + self.assertIn("player_flow_gate=deck-player-flow-product-shell-v1", shell) + self.assertIn("first_paint_hierarchy=host-game-launch", shell) + self.assertIn("selected_game_readability=large-title-selected-badge", shell) + self.assertIn("launch_cta=copy-safe-plan", shell) + self.assertIn("blocked_state_copy=player-safe-no-backend-power", shell) + self.assertIn("product_state_gate=deck-product-state-matrix-v1", shell) + self.assertIn("product_state_visuals=player-facing-state-card", shell) + self.assertIn("product_state_focus=state-card-copy-diagnostics", shell) + self.assertIn("stateHeadline=Product state: Host offline", shell) + self.assertIn("stateHeadline=Product state: Library unavailable", shell) + self.assertIn("stateHeadline=Product state: Lab gate locked", shell) + self.assertIn("stateAction=Reconnect the host or choose another backend-owned snapshot.", shell) + self.assertIn("stateSafety=Backend power stays off; no retry or network probe runs.", shell) + self.assertIn("stateProvenance=dto-player-state/backend-owned/redacted-public", shell) + self.assertIn("stateFocusOrder=state-card-copy-diagnostics", shell) + self.assertIn("dto_player_state_provenance=dto-player-state/backend-owned/redacted-public", shell) + self.assertIn("dto_player_state_focus_order=state-card-copy-diagnostics", shell) + self.assertIn("dto_player_state_focus_order_copy=Focus order: state card → Copy plan → Show diagnostics", shell) + self.assertIn("diagnostics=Matrix diagnostic:", shell) + self.assertIn("android_touched_guard=app-unchanged", shell) + self.assertIn("expandedFrameFirstPaintCrowding=false", shell) + self.assertIn("Launch blocked by lab gate.", shell) + self.assertIn("Host offline. Reconnect or pick another host.", shell) + self.assertIn("Pair this host before launch preview.", shell) + self.assertIn("Library unavailable. Try again when the read-only snapshot is back.", shell) + self.assertIn("awk -F'primary='", shell) + self.assertIn( + f"! grep -E {smoke.q(r'([0-9]{1,3}[.]){3}[0-9]{1,3}|BEGIN [A-Z ]+|raw[A-Z]')}", + shell, + ) + + def test_container_shell_clears_remote_artifacts_before_collecting_current_run(self): + shell = smoke.build_container_shell( + source_dir=pathlib.PurePosixPath("/src"), + artifact_dir=pathlib.PurePosixPath("/src/build/deck-frontend-smoke-artifacts"), + ) + + self.assertIn("rm -rf /src/build/deck-frontend-smoke-artifacts && mkdir -p /src/build/deck-frontend-smoke-artifacts", shell) + self.assertLess(shell.index("rm -rf /src/build/deck-frontend-smoke-artifacts"), shell.index("environment-summary.txt")) + + def test_reset_local_artifacts_removes_stale_files_before_current_run(self): + artifact_dir = smoke.REPO_ROOT / "build" / "deck-frontend-smoke-artifacts-test" + stale = artifact_dir / "frontend-frame-capture.png" + try: + artifact_dir.mkdir(parents=True, exist_ok=True) + stale.write_text("stale", encoding="utf-8") + + smoke.reset_local_artifacts(artifact_dir) + + self.assertTrue(artifact_dir.is_dir()) + self.assertFalse(stale.exists()) + finally: + if artifact_dir.exists(): + smoke.shutil.rmtree(artifact_dir) + + def test_main_resets_local_artifacts_before_smoke_commands(self): + artifact_dir = smoke.REPO_ROOT / "build" / "deck-frontend-smoke-artifacts-main-test" + stale = artifact_dir / "stale.txt" + try: + artifact_dir.mkdir(parents=True, exist_ok=True) + stale.write_text("stale", encoding="utf-8") + + with ( + mock.patch.object(smoke, "find_forbidden_route_tokens", return_value=[]), + mock.patch.object(smoke, "run_command") as run_command, + ): + exit_code = smoke.main([ + "--skip-sync", + "--local-artifacts", + str(artifact_dir), + ]) + + self.assertEqual(exit_code, 0) + self.assertFalse(stale.exists()) + self.assertEqual(run_command.call_count, 2) + self.assertEqual( + run_command.call_args_list[0].kwargs["log_path"], + artifact_dir / "deck-frontend-smoke.log", + ) + self.assertEqual( + run_command.call_args_list[1].kwargs["log_path"], + artifact_dir / "rsync-artifacts.log", + ) + finally: + if artifact_dir.exists(): + smoke.shutil.rmtree(artifact_dir) + + def test_main_refuses_to_run_when_android_app_tree_has_uncommitted_changes(self): + with mock.patch.object(smoke, "git_has_path_changes", return_value=True) as changed: + exit_code = smoke.main([ + "--dry-run", + "--skip-sync", + "--local-artifacts", + "/repo/build/deck-frontend-smoke-artifacts", + ]) + + self.assertEqual(exit_code, 3) + changed.assert_called_once_with(smoke.REPO_ROOT, "app") + + def test_main_checks_android_app_untouched_before_sync_and_deck_smoke(self): + artifact_dir = smoke.REPO_ROOT / "build" / "deck-frontend-smoke-artifacts-android-guard-test" + try: + with ( + mock.patch.object(smoke, "find_forbidden_route_tokens", return_value=[]), + mock.patch.object(smoke, "git_has_path_changes", return_value=False) as unchanged, + mock.patch.object(smoke, "run_command") as run_command, + ): + exit_code = smoke.main([ + "--skip-sync", + "--local-artifacts", + str(artifact_dir), + ]) + + self.assertEqual(exit_code, 0) + unchanged.assert_called_once_with(smoke.REPO_ROOT, "app") + self.assertEqual(run_command.call_count, 2) + finally: + if artifact_dir.exists(): + smoke.shutil.rmtree(artifact_dir) + + def test_remote_artifact_cleanup_refuses_source_dir_and_paths_outside_source(self): + unsafe_paths = ( + pathlib.PurePosixPath("/"), + pathlib.PurePosixPath("/home/deck"), + pathlib.PurePosixPath("/src"), + pathlib.PurePosixPath("/src/deck-frontend-smoke-artifacts"), + pathlib.PurePosixPath("/tmp/deck-frontend-smoke-artifacts"), + pathlib.PurePosixPath(""), + ) + for artifact_dir in unsafe_paths: + with self.subTest(artifact_dir=str(artifact_dir)): + with self.assertRaises(ValueError): + smoke.build_container_shell( + source_dir=pathlib.PurePosixPath("/src"), + artifact_dir=artifact_dir, + ) + + def test_remote_artifact_cleanup_refuses_parent_traversal(self): + traversal_paths = ( + pathlib.PurePosixPath("/src/../deck-frontend-smoke-artifacts"), + pathlib.PurePosixPath("/src/build/../../deck-frontend-smoke-artifacts"), + ) + for artifact_dir in traversal_paths: + with self.subTest(artifact_dir=str(artifact_dir)): + with self.assertRaises(ValueError): + smoke.build_container_shell( + source_dir=pathlib.PurePosixPath("/src"), + artifact_dir=artifact_dir, + ) + + def test_local_artifact_cleanup_refuses_broad_or_non_artifact_paths(self): + with tempfile.TemporaryDirectory() as tmpdir: + broad = pathlib.Path(tmpdir) + unsafe_paths = ( + pathlib.Path("/"), + pathlib.Path.home(), + smoke.REPO_ROOT, + smoke.REPO_ROOT / "deck-frontend-smoke-artifacts", + pathlib.Path(""), + broad, + broad / "deck-output", + broad / "deck-frontend-smoke-artifacts", + ) + for artifact_dir in unsafe_paths: + with self.subTest(artifact_dir=str(artifact_dir)): + if not artifact_dir.exists() and artifact_dir.is_absolute(): + artifact_dir.mkdir(parents=True) + with self.assertRaises(ValueError): + smoke.reset_local_artifacts(artifact_dir) + + def test_local_artifact_cleanup_refuses_parent_traversal_outside_build_dir(self): + unsafe = smoke.REPO_ROOT / "build" / ".." / "deck-frontend-smoke-artifacts" + + with self.assertRaises(ValueError): + smoke.reset_local_artifacts(unsafe) + + def test_dry_run_prints_sync_validate_pull_and_no_oracle(self): + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = smoke.main([ + "--dry-run", + "--skip-sync", + "--local-artifacts", + "/repo/build/deck-frontend-smoke-artifacts", + ]) + + text = stdout.getvalue() + self.assertEqual(exit_code, 0) + self.assertIn("VALIDATE:", text) + self.assertIn("PULL_ARTIFACTS:", text) + self.assertNotIn("ORACLE:", text) + + def test_route_guardrails_reject_streaming_discovery_pairing_credentials_and_private_hosts(self): + self.assertEqual(smoke.find_forbidden_route_tokens(), []) + + def test_diagnostics_product_readiness_checklist_captures_reusable_gate(self): + checklist = REPO_ROOT / "docs" / "deck-product-readiness-checklist.md" + text = checklist.read_text(encoding="utf-8") + + required_contract = ( + "deck-diagnostics-expanded-lane-v1", + "collapsed first paint", + "page-1 cue", + "focus ring", + "active focus badge", + "D-pad scroll to page 2", + "right-rail breathing room", + "page-2 cue contrast/readability", + "lifecycle idle/no stream", + "redacted-public-dto", + "sanitized artifacts", + "backendPowerStarted=false", + "stream=false", + "backend-fed read-only DTO parity", + ) + for required in required_contract: + self.assertIn(required, text) + + forbidden_scope = ( + "HostStore", + "Moonlight", + "LiStartConnection", + "credential read", + "discovery probe", + "input packet path", + ) + for forbidden in forbidden_scope: + self.assertNotIn(forbidden, text) + + def test_source_sync_excludes_build_git_and_secret_shaped_files(self): + command = smoke.build_rsync_command( + pathlib.Path("/repo"), + "deck@10.0.0.39", + pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src"), + ) + joined = " ".join(command) + + for excluded in ["/.git/", "/build/", "/.gradle/", "/local.properties", ".env*", "id_*", "*.pem"]: + self.assertIn(excluded, joined) + + def test_command_log_redacts_private_deck_addresses(self): + self.assertEqual( + smoke.redact_private_addresses("rsync deck@10.0.0.39:/home/deck/source"), + "rsync deck@:/home/deck/source", + ) + self.assertEqual( + smoke.redact_private_addresses("ssh: Could not resolve hostname steamdeck.local"), + "ssh: Could not resolve hostname ", + ) + + def test_run_command_redacts_private_addresses_from_subprocess_output_logs(self): + with tempfile.TemporaryDirectory() as tmpdir: + log_path = pathlib.Path(tmpdir) / "smoke.log" + + smoke.run_command( + [sys.executable, "-c", "print('ssh: connect to host 10.0.0.39 port 22 failed')"], + log_path=log_path, + ) + + log = log_path.read_text(encoding="utf-8") + self.assertIn("", log) + self.assertNotIn("10.0.0.39", log) + + def test_run_command_redacts_failed_subprocess_stderr_in_logs_and_exceptions(self): + with tempfile.TemporaryDirectory() as tmpdir: + log_path = pathlib.Path(tmpdir) / "rsync-artifacts.log" + + with self.assertRaises(smoke.subprocess.CalledProcessError) as failure: + smoke.run_command( + [ + sys.executable, + "-c", + "import sys; print('rsync: steamdeck.local failed from 10.0.0.39 via 192.168.1.77', file=sys.stderr); sys.exit(23)", + "deck@10.0.0.39:/remote/artifacts", + "steamdeck.local:/remote/artifacts", + ], + log_path=log_path, + ) + + log = log_path.read_text(encoding="utf-8") + self.assertIn("", log) + self.assertIn("", log) + self.assertNotIn("10.0.0.39", log) + self.assertNotIn("192.168.1.77", log) + self.assertNotIn("steamdeck.local", log) + self.assertNotIn("10.0.0.39", failure.exception.output) + self.assertNotIn("192.168.1.77", failure.exception.output) + self.assertNotIn("steamdeck.local", failure.exception.output) + self.assertNotIn("10.0.0.39", str(failure.exception)) + self.assertNotIn("steamdeck.local", str(failure.exception)) + self.assertNotIn("10.0.0.39", " ".join(failure.exception.cmd)) + self.assertNotIn("steamdeck.local", " ".join(failure.exception.cmd)) + + def test_dry_run_redacts_private_deck_targets(self): + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = smoke.main(["--dry-run"]) + + text = stdout.getvalue() + self.assertEqual(exit_code, 0) + self.assertIn("", text) + self.assertNotIn("10.0.0.39", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index d92ae7ac..36bb50c1 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -32,6 +32,17 @@ bool containsIpv4AddressLike(const std::string& text) { } return false; } + +int qmlReadonlyIntProperty(const std::string& qml, std::string_view propertyName) { + const std::string needle = "readonly property int " + std::string(propertyName) + ": "; + const auto propertyStart = qml.find(needle); + assert(propertyStart != std::string::npos); + const auto valueStart = propertyStart + needle.size(); + const auto valueEnd = qml.find_first_not_of("0123456789", valueStart); + assert(valueEnd != valueStart); + return std::stoi(qml.substr(valueStart, valueEnd - valueStart)); +} + std::string readTextFile(const char* path) { std::ifstream stream(path); assert(stream.good()); @@ -76,82 +87,214 @@ int main() { assert(mainQml.find("novaLibraryGames") != std::string::npos); assert(mainQml.find("novaLibraryHosts") != std::string::npos); assert(mainQml.find("libraryGameRepeater") != std::string::npos); - assert(mainQml.find("Polaris library preview") != std::string::npos); + assert(mainQml.find("Backend-fed library snapshot") != std::string::npos); + assert(mainQml.find("novaLaunchIntentBoundary") != std::string::npos); assert(mainQml.find("novaHostLaunchCta.helpText") != std::string::npos); - assert(mainQml.find("novaHostLaunchCta.previewStateLabel") != std::string::npos); - assert(mainQml.find("text: novaHostLaunchCta.helpText") != std::string::npos); assert(mainQml.find("D-pad Navigate") != std::string::npos); assert(mainQml.find("selectedHostForPreview") != std::string::npos); assert(mainQml.find("selectedGameForPreview") != std::string::npos); assert(mainQml.find("refreshLaunchPreviewBinding") != std::string::npos); assert(mainQml.find("selectedLaunchPreviewText") != std::string::npos); assert(mainQml.find("Selected host") != std::string::npos); - assert(mainQml.find("selectedGameForPreview.title") != std::string::npos); + assert(mainQml.find("Selected game") != std::string::npos); assert(mainQml.find("No games in read-only snapshot") != std::string::npos); - assert(mainQml.find("Preview snapshot ready") != std::string::npos); + assert(mainQml.find("backend-owned read-only model") != std::string::npos); assert(mainQml.find("Snapshot unavailable in this preview shell") != std::string::npos); - assert(mainQml.find("copyStatusLabel.text = didCopyPreview") != std::string::npos); + assert(mainQml.find("launchPreviewCopyAction.idleStatusLabel") != std::string::npos); assert(mainQml.find("novaLaunchIntentPreview") != std::string::npos); + assert(mainQml.find("novaBackendReadOnlyState") != std::string::npos); + assert(mainQml.find("novaBackendReadOnlyStateMatrix") != std::string::npos); + assert(mainQml.find("backendReadOnlyDtoParity") != std::string::npos); + assert(mainQml.find("selectedBackendReadOnlyDtoSummary") != std::string::npos); + assert(mainQml.find("backendReadOnlyDtoParity.collapsedSummary") != std::string::npos); + assert(mainQml.find("backendReadOnlyDtoParity.expandedDiagnostics") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.title") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.body") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.actionLabel") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.safetyLabel") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.provenanceLabel") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.focusOrder") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState.focusOrderCopy") != std::string::npos); + assert(mainQml.find("function defaultBackendReadOnlyPlayerState") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState && backendReadOnlyPlayerState.title") != std::string::npos); + assert(mainQml.find("backendReadOnlyPlayerState && backendReadOnlyPlayerState.focusOrder") == std::string::npos); + assert(mainQml.find("text: backendReadOnlyPlayerState.focusOrderCopy") != std::string::npos); + assert(mainQml.find("Focus order: state card → Copy plan → Show diagnostics · DTO focus=") == std::string::npos); + assert(mainQml.find("Backend-owned DTO parity") != std::string::npos); + assert(mainQml.find("selectedBackendReadOnlyScenarioLabel") != std::string::npos); + assert(mainQml.find("compactReadOnlyBlockerCopy(backendReadOnlyPreflight, selectedBackendReadOnlyScenarioLabel)") == std::string::npos); + assert(mainQml.find("runBackendReadOnlyStateMatrixSmoke") != std::string::npos); + assert(mainQml.find("backendReadOnlyPreflight") != std::string::npos); assert(mainQml.find("selectedLaunchPublicCopy") != std::string::npos); assert(mainQml.find("selectedStreamLifecycleCopy") != std::string::npos); + assert(mainQml.find("novaPresenterReadiness") != std::string::npos); + assert(mainQml.find("text: \"VAAPI/EGL presenter readiness: \"") == std::string::npos); + assert(mainQml.find("Readiness checks · safe preview · stream off") != std::string::npos); + assert(mainQml.find("hardwarePresenterPlanned") != std::string::npos); + assert(mainQml.find("statusCode") != std::string::npos); + assert(mainQml.find("import Nova.Deck.Stream 0.1") != std::string::npos); + assert(mainQml.find("DeckVaapiPreviewSurface") != std::string::npos); + assert(mainQml.find("nova-product-preview-surface") != std::string::npos); + assert(mainQml.find("visible: novaPresenterReadiness.ready") != std::string::npos); + assert(qmlReadonlyIntProperty(mainQml, "launchPreviewHeight") >= 286); + assert(qmlReadonlyIntProperty(mainQml, "detailPanelHeight") + qmlReadonlyIntProperty(mainQml, "deckPanelSpacing") + + qmlReadonlyIntProperty(mainQml, "launchPreviewHeight") <= 540); + assert(mainQml.find("Press A on Copy to verify. A Copy preview saves this safe plan locally for inspection.") == std::string::npos); + assert(mainQml.find("text: selectedStreamLifecycleCopy") == std::string::npos); + assert(mainQml.find("copy locally to inspect the preview URI") == std::string::npos); assert(mainQml.find("state=copy-preview-only") == std::string::npos); assert(mainQml.find("readonly property color focusRingColor") != std::string::npos); assert(mainQml.find("readonly property color focusGlowColor") != std::string::npos); assert(mainQml.find("cursorShape: Qt.BlankCursor") != std::string::npos); - assert(mainQml.find("D-pad Navigate") != std::string::npos); - assert(mainQml.find("Exact preview details stay behind Copy preview details") == std::string::npos); - assert(mainQml.find("objectName: \"launch-target-summary-card\"") != std::string::npos); - assert(mainQml.find("objectName: \"launch-target-title\"") != std::string::npos); - assert(mainQml.find("Review path") != std::string::npos); - assert(mainQml.find("Layout.preferredWidth: detailTextWidth - 148") != std::string::npos); - assert(mainQml.find("Layout.preferredWidth: detailTextWidth - 124") == std::string::npos); - assert(mainQml.find("objectName: \"moonlight-handoff-panel\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-handoff-title-row\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-safety-chip-row\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-readiness-row\"") != std::string::npos); - assert(mainQml.find("Checks") != std::string::npos); - assert(mainQml.find("moonlightHandoffPreflight.readinessChecks") != std::string::npos); - assert(mainQml.find("readonly property var selectedMoonlightReadinessChecks") != std::string::npos); - assert(mainQml.find("function readinessStatusColor") != std::string::npos); - assert(mainQml.find("function readinessStatusCopy") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-plan-row\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-runtime-gates-line\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-runtime-gate-chip\"") != std::string::npos); - assert(mainQml.find("objectName: \"moonlight-focus-chip\"") != std::string::npos); - assert(mainQml.find("objectName: \"copy-preview-action-row\"") != std::string::npos); - assert(mainQml.find("Moonlight handoff preview") != std::string::npos); - assert(mainQml.find("No launch") != std::string::npos); - assert(mainQml.find("No network/process") != std::string::npos); - assert(mainQml.find("Focus: unproven_static") != std::string::npos); - assert(mainQml.find("Copy locally — no launch") != std::string::npos); - assert(mainQml.find("id: copyStatusLabel") != std::string::npos); - assert(mainQml.find("visible: text.length > 0") != std::string::npos); - assert(mainQml.find("D-pad focus · A copies preview locally; no launch.") == std::string::npos); - assert(mainQml.find("selectedMoonlightHandoffCopy") != std::string::npos); - assert(mainQml.find("selectedMoonlightHandoffArgvPreview") != std::string::npos); - assert(mainQml.find("Typed argv plan") != std::string::npos); - assert(mainQml.find("Review blocked") != std::string::npos); - assert(mainQml.find("text: moonlightHandoffRuntimeGatesClosed() ? \"Typed argv plan\" : \"Review blocked\"") != std::string::npos); - assert(mainQml.find("text: moonlightHandoffRuntimeGatesClosed() ? \"redacted argv · local preview only\" : selectedMoonlightHandoffArgvPreview") != std::string::npos); - assert(mainQml.find("Typed argv plan unavailable until the preflight is safe to render") != std::string::npos); - assert(mainQml.find("redacted argv · local preview only") != std::string::npos); - assert(mainQml.find("Typed argv plan · redacted host selector · ") == std::string::npos); - assert(mainQml.find("Runtime locked: network · process · Moonlight · host off") != std::string::npos); - assert(mainQml.find("unproven_static") != std::string::npos); - assert(mainQml.find("Nothing will launch yet") != std::string::npos); - assert(mainQml.find("novaMoonlightHandoffPreflightBridge.resolve") != std::string::npos); - assert(mainQml.find("moonlightHandoffPreflight.executable") != std::string::npos); - assert(mainQml.find("moonlightHandoffPreflight.safeToRender") != std::string::npos); - assert(mainQml.find("moonlightHandoffRuntimeGatesClosed") != std::string::npos); - assert(mainQml.find("!moonlightHandoffPreflight.allowsNetwork") != std::string::npos); - assert(mainQml.find("!moonlightHandoffPreflight.allowsProcessExecution") != std::string::npos); - assert(mainQml.find("!moonlightHandoffPreflight.allowsMoonlight") != std::string::npos); - assert(mainQml.find("!moonlightHandoffPreflight.allowsHostMutation") != std::string::npos); - assert(mainQml.find("Moonlight handoff preview blocked until safe public copy is available") != std::string::npos); + assert(mainQml.find("D-pad focus") != std::string::npos); + assert(mainQml.find("Exact preview details stay behind Copy preview details") != std::string::npos); assert(mainQml.find("text: selectedLaunchPreviewText") == std::string::npos); assert(mainQml.find("font.family: monospace") == std::string::npos); - assert(mainQml.find("onClicked: activateMoonlight") == std::string::npos); - assert(mainQml.find("QProcess") == std::string::npos); + assert(mainQml.find("Backend-fed library snapshot") != std::string::npos); + assert(mainQml.find("Backend-fed hosts") != std::string::npos); + assert(mainQml.find("Provenance: ") != std::string::npos); + assert(mainQml.find("backend-owned read-only model") != std::string::npos); + assert(mainQml.find("Preflight blockers") != std::string::npos); + assert(mainQml.find("compactReadOnlyBlockerCopy") == std::string::npos); + assert(mainQml.find("readOnlyBlockerDiagnostics") != std::string::npos); + assert(mainQml.find("readOnlyDtoParityDiagnostics") != std::string::npos); + assert(mainQml.find("primaryBlockerCopy") != std::string::npos); + assert(mainQml.find("secondaryDiagnosticsCopy") != std::string::npos); + assert(mainQml.find("Launch blocked by lab gate.") == std::string::npos); + assert(mainQml.find("Host offline. Reconnect or pick another host.") == std::string::npos); + assert(mainQml.find("Pair this host before launch preview.") == std::string::npos); + assert(mainQml.find("Library unavailable. Try again when the read-only snapshot is back.") == std::string::npos); + assert(mainQml.find("backendReadOnlyPreflight.publicCopy") != std::string::npos); + assert(mainQml.find("Secondary diagnostics stay collapsed on first paint") != std::string::npos); + assert(mainQml.find("visible: diagnosticsExpanded") != std::string::npos); + assert(mainQml.find("property bool diagnosticsExpanded: false") != std::string::npos); + assert(mainQml.find("id: secondaryDiagnosticsToggle") != std::string::npos); + assert(mainQml.find("objectName: \"secondary-diagnostics-toggle\"") != std::string::npos); + assert(mainQml.find("visible: true") != std::string::npos); + assert(mainQml.find("D-pad focus · A · ") != std::string::npos); + assert(mainQml.find("KeyNavigation.down: secondaryDiagnosticsToggle") != std::string::npos); + assert(mainQml.find("secondaryDiagnosticsToggle.forceActiveFocus()") != std::string::npos); + assert(mainQml.find("collapsedFirstPaint") != std::string::npos); + assert(mainQml.find("expansionToggleControllerReachable") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsVisible") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsCopy") != std::string::npos); + assert(mainQml.find("runExpandedDiagnosticsFrameSmoke") != std::string::npos); + assert(mainQml.find("liveExpandedBy") != std::string::npos); + assert(mainQml.find("expandedFrameSanitized") != std::string::npos); + assert(mainQml.find("expandedFrameReadable") != std::string::npos); + assert(mainQml.find("expandedFrameFocusTarget") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsLaneFocusTarget") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsLaneReadable") != std::string::npos); + assert(mainQml.find("expandedDensityRowsPaged") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPageAffordanceVisible") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPageAffordancePosition") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPageAffordanceText") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsScrollNavigationMoved") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPostScrollCue") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPostScrollCueContrast") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPostScrollCueSpacing") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPostScrollCueOverlapsBlocker") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPostScrollTarget") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsFocusAffordance") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsPage2Readable") != std::string::npos); + assert(mainQml.find("readonly property int expandedDiagnosticsLaneHeight: 132") != std::string::npos); + assert(mainQml.find("readonly property string expandedDiagnosticsCueContrastRatio: \"13.56:1\"") != std::string::npos); + assert(mainQml.find("readonly property string expandedDiagnosticsFocusAffordance: \"4px focus ring + active focus badge\"") != std::string::npos); + assert(mainQml.find("id: diagnosticsPagePositionLabel") != std::string::npos); + assert(mainQml.find("Diagnostics page 1 of 2 · scroll for lifecycle + DTO below") != std::string::npos); + assert(mainQml.find("Diagnostics page 2 of 2 · lifecycle=") != std::string::npos); + assert(mainQml.find("DTO privacy=") != std::string::npos); + assert(mainQml.find("id: diagnosticsPostScrollCueLabel") != std::string::npos); + assert(mainQml.find("id: diagnosticsPostScrollCueLabel\n" + " Layout.preferredWidth: expandedDiagnosticsLane.width - 28\n" + " Layout.topMargin: 8\n" + " text: \"Diagnostics page 2 of 2 · lifecycle=\" + previewLifecycleReport.state\n" + " + \"/no stream · DTO privacy=\" + backendDiagnosticsPreview.privacyCode\n" + " color: \"#FFDDA8\"\n" + " font.pixelSize: 10\n" + " font.bold: true\n" + " wrapMode: Text.WordWrap\n" + " visible: true") != std::string::npos); + assert(mainQml.find("id: expandedDiagnosticsPostScrollOverlay") != std::string::npos); + assert(mainQml.find("Layout.topMargin: 8") != std::string::npos); + assert(mainQml.find("id: expandedDiagnosticsPostScrollOverlay\n" + " anchors.bottom: parent.bottom\n" + " anchors.left: parent.left\n" + " anchors.right: parent.right\n" + " anchors.margins: 12\n" + " z: 2\n" + " text: \"Diagnostics page 2 of 2 · lifecycle=\" + previewLifecycleReport.state\n" + " + \"/no stream · DTO privacy=\" + backendDiagnosticsPreview.privacyCode\n" + " color: \"#FFDDA8\"\n" + " font.pixelSize: 10\n" + " font.bold: true\n" + " wrapMode: Text.WordWrap\n" + " visible: expandedDiagnosticsLaneScrolledToDetails") != std::string::npos); + assert(mainQml.find("background: Rectangle") != std::string::npos); + assert(mainQml.find("opacity: 0.94") != std::string::npos); + assert(mainQml.find("text: \"FOCUS\"") != std::string::npos); + assert(mainQml.find("Lifecycle page 2 · status=") != std::string::npos); + assert(mainQml.find("DTO page 2 · preflight=") != std::string::npos); + assert(mainQml.find("backend-owned-read-only-dto-v1") != std::string::npos); + assert(mainQml.find("dto-parity-ready") != std::string::npos); + assert(mainQml.find("id: lifecycleDiagnosticsPageLabel") != std::string::npos); + assert(mainQml.find("id: dtoDiagnosticsPageLabel") != std::string::npos); + assert(mainQml.find("id: expandedDiagnosticsLane") != std::string::npos); + assert(mainQml.find("objectName: \"expanded-diagnostics-lane\"") != std::string::npos); + assert(mainQml.find("id: expandedDiagnosticsScrollView") != std::string::npos); + assert(mainQml.find("objectName: \"expanded-diagnostics-scroll-view\"") != std::string::npos); + assert(mainQml.find("scrollExpandedDiagnosticsLaneToDetails") != std::string::npos); + assert(mainQml.find("const page2AnchorY = lifecycleDiagnosticsPageLabel.y > 0 ? lifecycleDiagnosticsPageLabel.y - 6 : maxContentY") != std::string::npos); + assert(mainQml.find("const targetContentY = Math.min(maxContentY, Math.max(0, page2AnchorY))") != std::string::npos); + assert(mainQml.find("ScrollView") != std::string::npos); + assert(mainQml.find("KeyNavigation.down: diagnosticsExpanded ? expandedDiagnosticsLane : copyPreviewButton") != std::string::npos); + assert(mainQml.find("Keys.onDownPressed: diagnosticsExpanded ? expandedDiagnosticsLane.forceActiveFocus() : copyPreviewButton.forceActiveFocus()") != std::string::npos); + assert(mainQml.find("expandedDiagnosticsLane.forceActiveFocus()") != std::string::npos); + assert(mainQml.find("expandedFrameFirstPaintCrowding") != std::string::npos); + assert(mainQml.find("secondaryDiagnosticsToggle.clicked()") != std::string::npos); + assert(mainQml.find("id: diagnosticsPagePositionLabel") < mainQml.find("id: readonlyDiagnosticsLabel")); + assert(mainQml.find("text: selectedLaunchPublicCopy\n color: \"#C9F0D4\"") != std::string::npos); + assert(mainQml.find("visible: diagnosticsExpanded\n Layout.preferredWidth: detailTextWidth") != std::string::npos); + assert(mainQml.find("maximumLineCount: 3") != std::string::npos); + assert(mainQml.find("Matrix state: " + " + selectedBackendReadOnlyScenarioLabel\n" + " + \" · Read-only model · \" + backendReadOnlyPreflight.statusCode") == std::string::npos); + assert(mainQml.find("Polaris library preview") == std::string::npos); + assert(mainQml.find("Preview lifecycle: ") == std::string::npos); + assert(mainQml.find("Operator authorization: ") == std::string::npos); + assert(mainQml.find("Backend preflight DTO: ") == std::string::npos); + assert(mainQml.find("Backend diagnostics DTO: ") == std::string::npos); + assert(mainQml.find("readonly property string deckPlayerFlowGate: \"deck-player-flow-product-shell-v1\"") != std::string::npos); + assert(mainQml.find("Choose host → Pick game → Review safe launch plan") != std::string::npos); + assert(mainQml.find("1 · Pick host") != std::string::npos); + assert(mainQml.find("2 · Pick game") != std::string::npos); + assert(mainQml.find("3 · Review launch plan") != std::string::npos); + assert(mainQml.find("Selected game · A copies preview") != std::string::npos); + assert(mainQml.find("A = Copy safe launch plan") != std::string::npos); + assert(mainQml.find("Blocked safely: lab gate keeps backend power and streams off.") != std::string::npos); + assert(mainQml.find("Diagnostics explain why; they never start discovery, backend power, or media.") != std::string::npos); + assert(mainQml.find("readonly property string deckProductStateGate: \"deck-product-state-matrix-v1\"") != std::string::npos); + assert(mainQml.find("function readOnlyProductStateHeadline") == std::string::npos); + assert(mainQml.find("function readOnlyProductStateAction") == std::string::npos); + assert(mainQml.find("function readOnlyProductStateSafety") == std::string::npos); + assert(mainQml.find("Product state: Ready for setup") == std::string::npos); + assert(mainQml.find("Product state: Host offline") == std::string::npos); + assert(mainQml.find("Product state: Pair host") == std::string::npos); + assert(mainQml.find("Product state: Library unavailable") == std::string::npos); + assert(mainQml.find("Product state: Lab gate locked") == std::string::npos); + assert(mainQml.find("Add a backend host before previewing a launch plan.") == std::string::npos); + assert(mainQml.find("Reconnect the host or choose another backend-owned snapshot.") == std::string::npos); + assert(mainQml.find("Pair this host in an approved flow before preview launch.") == std::string::npos); + assert(mainQml.find("Wait for the read-only library snapshot to return.") == std::string::npos); + assert(mainQml.find("Ask an operator to open the lab gate before any start path.") == std::string::npos); + assert(mainQml.find("Focus order: state card → Copy plan → Show diagnostics") != std::string::npos); + assert(mainQml.find("text: backendReadOnlyPlayerState.focusOrderCopy") != std::string::npos); + assert(mainQml.find("DTO provenance: ") != std::string::npos); + assert(mainQml.find("objectName: \"selected-game-readability-card\"") != std::string::npos); + assert(mainQml.find("objectName: \"deck-player-flow-stepper\"") != std::string::npos); + assert(mainQml.find("objectName: \"safe-launch-plan-cta\"") != std::string::npos); + assert(mainQml.find("font.pixelSize: 26\n font.bold: true") != std::string::npos); assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, diff --git a/clients/deck/tests/deck_media_assert_guard_test.py b/clients/deck/tests/deck_media_assert_guard_test.py new file mode 100644 index 00000000..2f29ee5e --- /dev/null +++ b/clients/deck/tests/deck_media_assert_guard_test.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +import pathlib +import re +import unittest + + +DECK_ROOT = pathlib.Path(__file__).resolve().parents[1] +MEDIA_ADAPTER_TEST = DECK_ROOT / "tests" / "deck_stream_media_adapters_test.cpp" +MEDIA_ADAPTER_SOURCE = DECK_ROOT / "src" / "stream" / "deck_stream_media_adapters.cpp" +DECK_MAIN_SOURCE = DECK_ROOT / "src" / "main.cpp" +FRONTEND_SMOKE_ROUTE = DECK_ROOT / "scripts" / "deck_frontend_smoke.py" +BACKEND_HEADER = DECK_ROOT / "src" / "backend" / "deck_backend_interfaces.h" +BACKEND_SOURCE = DECK_ROOT / "src" / "backend" / "deck_backend_interfaces.cpp" +BACKEND_TEST = DECK_ROOT / "tests" / "deck_backend_interfaces_test.cpp" + + +class DeckMediaAssertGuardTest(unittest.TestCase): + def test_media_adapter_harness_has_no_runtime_assert_control_flow(self): + source = MEDIA_ADAPTER_TEST.read_text(encoding="utf-8") + runtime_asserts = [] + for line_number, line in enumerate(source.splitlines(), start=1): + if re.search(r"(? -#include -#include -#include -#include -#include -#include -#include - -namespace { - -using nova::deck::stream::DeckMoonlightFocusReturnPlan; -using nova::deck::stream::DeckMoonlightHandoffBlockReason; -using nova::deck::stream::DeckMoonlightHandoffPreflightRequest; -using nova::deck::stream::DeckMoonlightHandoffPreflightResult; -using nova::deck::stream::DeckMoonlightHandoffSurface; -using nova::deck::stream::DeckMoonlightHandoffVerdict; -using nova::deck::stream::DeckMoonlightReadinessCheck; -using nova::deck::stream::DeckMoonlightReadinessCheckStatus; -using nova::deck::stream::resolveDeckMoonlightHandoffPreflight; - -DeckMoonlightHandoffPreflightRequest validRequest( - const DeckMoonlightHandoffSurface surface = DeckMoonlightHandoffSurface::MoonlightQtCli) { - return DeckMoonlightHandoffPreflightRequest{ - .hostDisplayNamePublic = "MacPapi Gaming Host", - .gameTitlePublic = "Black Myth: Wukong", - .privateHostSelectorRedactedForDebug = "redacted-host-selector", - .requestedSurface = surface, - .hasSafeSnapshot = true, - .appPresentInSnapshot = true, - }; -} - -bool hasReason( - const DeckMoonlightHandoffPreflightResult& result, - const DeckMoonlightHandoffBlockReason reason) { - return std::find(result.blockedReasons.begin(), result.blockedReasons.end(), reason) != result.blockedReasons.end(); -} - -bool contains(const std::string& text, const std::string_view needle) { - return text.find(needle) != std::string::npos; -} - -std::string pieces(const std::initializer_list parts) { - std::string value; - for (const auto part : parts) { - value.append(part); - } - return value; -} - -void assertRuntimeBoundaryClosed(const DeckMoonlightHandoffPreflightResult& result) { - assert(!result.executable); - assert(!result.allowsNetwork); - assert(!result.allowsProcessExecution); - assert(!result.allowsMoonlight); - assert(!result.allowsHostMutation); -} - -void assertBlockedStatic(const DeckMoonlightHandoffPreflightResult& result) { - assert(result.verdict == DeckMoonlightHandoffVerdict::BlockedStatic); - assertRuntimeBoundaryClosed(result); - assert(result.candidatePlan.argvTokens.empty()); - assert(!contains(result.publicPreviewCopy, "moonlight://")); - assert(!contains(result.publicPreviewCopy, "http://")); - assert(!contains(result.publicPreviewCopy, "https://")); - assert(!contains(result.publicPreviewCopy, "ssh")); -} - -void assertFocusReturnUnproven(const DeckMoonlightFocusReturnPlan& plan) { - assert(plan.sourceSurface == "Nova Deck preview review"); - assert(plan.intendedReturnTarget == "MacPapi Gaming Host / Black Myth: Wukong"); - assert(plan.confidence == "unproven_static"); - assert(contains(plan.fallbackCopy, "Return to Nova")); - assert(contains(plan.fallbackCopy, "later approved launch")); -} - -const DeckMoonlightReadinessCheck& readinessCheck( - const DeckMoonlightHandoffPreflightResult& result, - const std::string_view id) { - const auto match = std::find_if( - result.readinessChecks.begin(), - result.readinessChecks.end(), - [&](const DeckMoonlightReadinessCheck& check) { - return check.id == id; - }); - assert(match != result.readinessChecks.end()); - return *match; -} - -void assertReadinessCheck( - const DeckMoonlightHandoffPreflightResult& result, - const std::string_view id, - const DeckMoonlightReadinessCheckStatus status, - const std::string_view detailNeedle) { - const auto& check = readinessCheck(result, id); - assert(check.status == status); - assert(!check.label.empty()); - assert(contains(check.detail, detailNeedle)); - assert(!contains(check.detail, "moonlight://")); - assert(!contains(check.detail, "http://")); - assert(!contains(check.detail, "https://")); - assert(!contains(check.detail, "ssh")); -} - -} // namespace - -static_assert(std::is_default_constructible_v); -static_assert(std::is_default_constructible_v); - -int main() { - { - const auto result = resolveDeckMoonlightHandoffPreflight(validRequest()); - assert(result.verdict == DeckMoonlightHandoffVerdict::ReadyForReview); - assert(result.safeToRender); - assertRuntimeBoundaryClosed(result); - assert(result.candidatePlan.surface == DeckMoonlightHandoffSurface::MoonlightQtCli); - assert((result.candidatePlan.argvTokens == std::vector{ - "moonlight", - "stream", - "redacted-host-selector", - "Black Myth: Wukong", - })); - assert(contains(result.publicPreviewCopy, "Ready to review Moonlight handoff")); - assert(contains(result.publicPreviewCopy, "MacPapi Gaming Host")); - assert(contains(result.publicPreviewCopy, "Black Myth: Wukong")); - assert(contains(result.publicPreviewCopy, "Nothing will launch yet")); - assert(!contains(result.publicPreviewCopy, "redacted-host-selector")); - assertFocusReturnUnproven(result.focusReturnPlan); - assert(result.blockedReasons.empty()); - assert(result.readinessChecks.size() == 4); - assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Read-only host snapshot"); - assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Game appears in snapshot"); - assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Passed, "Typed argv preview is redacted"); - assertReadinessCheck(result, "focus-return", DeckMoonlightReadinessCheckStatus::ReviewOnly, "Focus return remains unproven_static"); - } - - { - auto request = validRequest(); - request.hostDisplayNamePublic.clear(); - const auto result = resolveDeckMoonlightHandoffPreflight(request); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::MissingHost)); - assert(contains(result.publicPreviewCopy, "public host label")); - } - - { - auto request = validRequest(); - request.gameTitlePublic.clear(); - const auto result = resolveDeckMoonlightHandoffPreflight(request); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::MissingGame)); - assert(contains(result.publicPreviewCopy, "game title")); - } - - { - auto request = validRequest(); - request.hasSafeSnapshot = false; - const auto result = resolveDeckMoonlightHandoffPreflight(request); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::HostSnapshotMissing)); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::HostPairingUnprovenStatic)); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::FocusReturnUnprovenStatic)); - assert(contains(result.publicPreviewCopy, "cannot verify Moonlight readiness")); - assertFocusReturnUnproven(result.focusReturnPlan); - assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Blocked, "Needs safe host snapshot"); - assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Game appears in snapshot"); - assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "Snapshot gate must pass first"); - } - - { - auto request = validRequest(); - request.appPresentInSnapshot = false; - const auto result = resolveDeckMoonlightHandoffPreflight(request); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::AppNotInSnapshot)); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::HostPairingUnprovenStatic)); - assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Read-only host snapshot"); - assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Blocked, "Game missing from snapshot"); - assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "App snapshot gate must pass first"); - } - - { - const auto result = resolveDeckMoonlightHandoffPreflight(validRequest(DeckMoonlightHandoffSurface::CustomUri)); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::CustomUriNotStreamHandler)); - assert(!contains(result.candidatePlan.publicSummary, "moonlight://")); - } - - { - const std::vector> blockedSurfaces{ - {DeckMoonlightHandoffSurface::DesktopEntry, DeckMoonlightHandoffBlockReason::DesktopEntryNotStreamContract}, - {DeckMoonlightHandoffSurface::FlatpakIdentity, DeckMoonlightHandoffBlockReason::FlatpakContractUnproven}, - {DeckMoonlightHandoffSurface::SteamShortcut, DeckMoonlightHandoffBlockReason::SteamShortcutRuntimeOnly}, - }; - for (const auto& [surface, reason] : blockedSurfaces) { - const auto result = resolveDeckMoonlightHandoffPreflight(validRequest(surface)); - assertBlockedStatic(result); - assert(hasReason(result, reason)); - assert(contains(result.publicPreviewCopy, "research-only")); - } - } - - { - const auto result = resolveDeckMoonlightHandoffPreflight(validRequest(DeckMoonlightHandoffSurface::NovaOwnedCommonCFuture)); - assert(result.verdict == DeckMoonlightHandoffVerdict::ForbiddenRuntimeBoundary); - assertRuntimeBoundaryClosed(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::ForbiddenRuntimeBoundary)); - assert(result.candidatePlan.argvTokens.empty()); - } - - { - const std::vector unsafePublicValues{ - pieces({"10", ".0", ".0", ".232"}), - pieces({"http", "://", "host.local"}), - pieces({"s", "sh pc-papi"}), - pieces({"/Us", "ers/", "papi/", ".s", "sh/", "id_ed25519"}), - pieces({"token", "=redacted-test-value"}), - pieces({"pass", "word: redacted-test-value"}), - pieces({"moon", "light", "://", "stream/host/app"}), - pieces({"MacPapi", ";", " rm -rf /"}), - pieces({"aa", ":bb", ":cc", ":dd", ":ee", ":ff"}), - pieces({"!", "abcdef", ":matrix.local"}), - }; - for (const auto& unsafeValue : unsafePublicValues) { - auto hostRequest = validRequest(); - hostRequest.hostDisplayNamePublic = unsafeValue; - const auto hostResult = resolveDeckMoonlightHandoffPreflight(hostRequest); - assertBlockedStatic(hostResult); - assert(hasReason(hostResult, DeckMoonlightHandoffBlockReason::UnsafePublicCopy)); - assert(!contains(hostResult.publicPreviewCopy, unsafeValue)); - - auto gameRequest = validRequest(); - gameRequest.gameTitlePublic = unsafeValue; - const auto gameResult = resolveDeckMoonlightHandoffPreflight(gameRequest); - assertBlockedStatic(gameResult); - assert(hasReason(gameResult, DeckMoonlightHandoffBlockReason::UnsafePublicCopy)); - assert(!contains(gameResult.publicPreviewCopy, unsafeValue)); - } - } - - { - auto request = validRequest(); - request.privateHostSelectorRedactedForDebug = "host selector; launch"; - const auto result = resolveDeckMoonlightHandoffPreflight(request); - assertBlockedStatic(result); - assert(hasReason(result, DeckMoonlightHandoffBlockReason::UnsafeArgvToken)); - assert(!contains(result.publicPreviewCopy, "host selector; launch")); - assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "Typed argv preview is not public-safe"); - } - - return 0; -} diff --git a/clients/deck/tests/deck_moonlight_handoff_source_guard_test.py b/clients/deck/tests/deck_moonlight_handoff_source_guard_test.py deleted file mode 100644 index bd4cfe06..00000000 --- a/clients/deck/tests/deck_moonlight_handoff_source_guard_test.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Static guard for the local-only Deck Moonlight handoff preflight slice.""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SCAN_FILES = [ - ROOT / "src" / "stream" / "deck_moonlight_handoff_preflight.h", - ROOT / "src" / "stream" / "deck_moonlight_handoff_preflight.cpp", - ROOT / "src" / "main.cpp", - ROOT / "qml" / "Main.qml", - ROOT / "CMakeLists.txt", -] - -FORBIDDEN_PATTERNS = [ - ("process launch API", re.compile(r"\b(QProcess|fork\s*\(|system\s*\(|popen\s*\(|xdg-open|flatpak\s+run)\b")), - ("exec API", re.compile(r"\bexec(?:l|le|lp|lpe|v|ve|vp|vpe)?\s*\(")), - ("Moonlight runtime connection", re.compile(r"\b(LiStartConnection|LiStopConnection|LiInterruptConnection|MoonBridge\.startConnection)\b")), - ("Moonlight stream shell command", re.compile(r"moonlight\s+stream", re.IGNORECASE)), - ("Moonlight custom URI", re.compile(r"moonlight://", re.IGNORECASE)), - ("host HTTP launch surface", re.compile(r"(/launch|/resume|/cancel|/serverinfo|/applist)\b", re.IGNORECASE)), - ("Android host/pairing/persistence", re.compile(r"\b(NvHTTP|PairingManager|ComputerDatabaseManager|HostStore|DiscoveryService)\b")), - ("network discovery/probing", re.compile(r"\b(mDNS|NSD|zeroconf|socket\s*\(|connect\s*\(|send\s*\(|recv\s*\()\b")), - ("media/device probe", re.compile(r"\b(av_hwdevice_ctx_create|PipeWire|PulseAudio|VA-API|/dev/input|xdotool|ffmpeg|systemctl|pgrep|ldd)\b", re.IGNORECASE)), - ("private endpoint literal", re.compile(r"\b(?:10|127|169\.254|172\.(?:1[6-9]|2\d|3[0-1])|192\.168)\.\d{1,3}\.\d{1,3}\b")), - ("MAC-like literal", re.compile(r"\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b")), - ("private key header", re.compile(r"BEGIN [A-Z ]*PRIVATE KEY")), - ("secret-looking assignment", re.compile(r"\b(?:api[_-]?key|client[_-]?secret|session[_-]?token|password)\b\s*[:=]", re.IGNORECASE)), -] - -ALLOWED_CMAKE_PATTERN = re.compile(r"nova_deck_moonlight_handoff_preflight|deck_moonlight_handoff", re.IGNORECASE) - - -def normalized_text(path: Path) -> str: - text = path.read_text(encoding="utf-8") - if path.name == "CMakeLists.txt": - # Target/file names necessarily contain the feature name. Keep command/runtime checks active. - text = ALLOWED_CMAKE_PATTERN.sub("PRELIGHT_TARGET_NAME", text) - if path.name == "main.cpp": - # Qt signal wiring/event loop are not network connect/probe or process exec surfaces. - text = text.replace("QObject::connect", "QT_SIGNAL_CONNECT") - text = text.replace("connect(notifier_", "QT_SIGNAL_CONNECT(notifier_") - text = text.replace("return app.exec();", "return QT_APP_EVENT_LOOP;") - if path.name == "Main.qml": - # Existing inert preview URI path is local copy text, not a host HTTP launch endpoint. - text = text.replace("preview://nova-deck/launch", "preview://nova-deck/PREVIEW_PATH") - return text - - -def main() -> int: - failures: list[str] = [] - for path in SCAN_FILES: - if not path.exists(): - failures.append(f"missing guarded file: {path.relative_to(ROOT)}") - continue - text = normalized_text(path) - for label, pattern in FORBIDDEN_PATTERNS: - match = pattern.search(text) - if match: - rel = path.relative_to(ROOT) - failures.append(f"{rel}: forbidden {label}: {match.group(0)!r}") - if failures: - print("Deck Moonlight preflight source guard failed:", file=sys.stderr) - for failure in failures: - print(f"- {failure}", file=sys.stderr) - return 1 - print("Deck Moonlight preflight source guard passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/clients/deck/tests/deck_qsg_render_node_scenegraph_smoke.cpp b/clients/deck/tests/deck_qsg_render_node_scenegraph_smoke.cpp new file mode 100644 index 00000000..1856f81b --- /dev/null +++ b/clients/deck/tests/deck_qsg_render_node_scenegraph_smoke.cpp @@ -0,0 +1,270 @@ +#include "stream/deck_stream_media_adapters.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +bool require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + return false; + } + return true; +} + +#define NOVA_TEST_REQUIRE(...) \ + do { \ + if (!require(static_cast((__VA_ARGS__)), "expected " #__VA_ARGS__)) { \ + return 1; \ + } \ + } while (false) + +std::mutex g_messageMutex; +std::vector g_qtMessages; + +void recordingMessageHandler(QtMsgType type, const QMessageLogContext& context, const QString& message) { + (void)type; + (void)context; + const std::string line = message.toStdString(); + { + std::lock_guard lock(g_messageMutex); + g_qtMessages.push_back(line); + } + std::cerr << line << '\n'; +} + +bool recordedMessageContains(std::string_view needle) { + std::lock_guard lock(g_messageMutex); + return std::any_of(g_qtMessages.begin(), g_qtMessages.end(), [needle](const std::string& line) { + return line.find(needle) != std::string::npos; + }); +} + +int recordedMessageCount(std::string_view needle) { + std::lock_guard lock(g_messageMutex); + return static_cast(std::count_if(g_qtMessages.begin(), g_qtMessages.end(), [needle](const std::string& line) { + return line.find(needle) != std::string::npos; + })); +} + +std::string joinedRecordedMessages() { + std::lock_guard lock(g_messageMutex); + std::string joined; + for (const std::string& line : g_qtMessages) { + joined += line; + joined += '\n'; + } + return joined; +} + +std::vector readBinaryFile(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!require(input.good(), "expected generated H.264 sample to be readable")) { + return {}; + } + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +std::vector makeLocalAnnexBH264IdrSample() { + const auto output = std::filesystem::temp_directory_path() / ("nova-deck-scenegraph-idr-" + std::to_string(getpid()) + ".h264"); + const pid_t pid = fork(); + if (!require(pid >= 0, "expected ffmpeg sample encoder process to fork")) { + return {}; + } + if (pid == 0) { + execlp( + "ffmpeg", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=128x72:r=1:d=1", + "-frames:v", + "1", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-tune", + "zerolatency", + "-x264-params", + "keyint=1:min-keyint=1:scenecut=0", + "-f", + "h264", + output.c_str(), + static_cast(nullptr)); + _exit(127); + } + int status = 0; + if (!require(waitpid(pid, &status, 0) == pid, "expected ffmpeg sample encoder process to exit")) { + return {}; + } + if (!require(WIFEXITED(status), "expected ffmpeg sample encoder to exit normally")) { + return {}; + } + if (!require(WEXITSTATUS(status) == 0, "expected ffmpeg sample encoder to succeed")) { + return {}; + } + auto bytes = readBinaryFile(output); + std::error_code ignored; + std::filesystem::remove(output, ignored); + return bytes; +} + +DECODE_UNIT makeDecodeUnit(std::vector& annexBBytes, LENTRY& entry, int frameNumber) { + entry.next = nullptr; + entry.data = reinterpret_cast(annexBBytes.data()); + entry.length = static_cast(annexBBytes.size()); + entry.bufferType = BUFFER_TYPE_PICDATA; + + DECODE_UNIT unit{}; + unit.frameNumber = frameNumber; + unit.frameType = FRAME_TYPE_IDR; + unit.fullLength = entry.length; + unit.bufferList = &entry; + return unit; +} + +bool waitForRenderPasses(QGuiApplication& app, QQuickWindow& window, nova::deck::stream::DeckQtQuickRhiVaapiItem& vaapiItem, int expectedPasses) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < 5000 && recordedMessageCount("Nova Deck QSGRenderNode VAAPI/EGL render path") < expectedPasses) { + app.processEvents(QEventLoop::AllEvents, 50); + window.requestUpdate(); + vaapiItem.update(); + QTimer::singleShot(0, &app, [] {}); + } + return recordedMessageCount("Nova Deck QSGRenderNode VAAPI/EGL render path") >= expectedPasses; +} + +std::string environmentDetail(const QQuickWindow& window, const nova::deck::stream::DeckRendererLifecycle& lifecycle) { + return "WAYLAND_DISPLAY=" + std::string(qgetenv("WAYLAND_DISPLAY").constData()) + + " XDG_RUNTIME_DIR=" + std::string(qgetenv("XDG_RUNTIME_DIR").constData()) + + " QT_QPA_PLATFORM=" + std::string(qgetenv("QT_QPA_PLATFORM").constData()) + + " QSG_RHI_BACKEND=" + std::string(qgetenv("QSG_RHI_BACKEND").constData()) + + " graphicsApi=" + std::to_string(static_cast(window.rendererInterface()->graphicsApi())) + + " sceneGraphInitialized=" + std::to_string(window.isSceneGraphInitialized()) + + " exposed=" + std::to_string(window.isExposed()) + + " vaapi=" + lifecycle.runtimeStatus; +} + +} // namespace + +int main(int argc, char** argv) { + qputenv("QT_QPA_PLATFORM", qgetenv("QT_QPA_PLATFORM").isEmpty() ? QByteArray("offscreen") : qgetenv("QT_QPA_PLATFORM")); + qputenv("QSG_RHI_BACKEND", qgetenv("QSG_RHI_BACKEND").isEmpty() ? QByteArray("opengl") : qgetenv("QSG_RHI_BACKEND")); + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGLRhi); + qInstallMessageHandler(recordingMessageHandler); + QGuiApplication app(argc, argv); + + using nova::deck::stream::DeckLinuxMediaProbe; + using nova::deck::stream::DeckQtQuickRhiVaapiItem; + using nova::deck::stream::DeckVaapiFfmpegRenderer; + + const DeckLinuxMediaProbe mediaProbe = DeckLinuxMediaProbe::detect(); + if (!mediaProbe.runtimeVaapiDeviceAvailable) { + std::cout << "Nova Deck QSGRenderNode scenegraph smoke skipped: " << mediaProbe.runtimeStatus << '\n'; + return 0; + } + + QQuickWindow window; + window.setTitle(QStringLiteral("Nova Deck QSGRenderNode scenegraph smoke")); + window.resize(128, 72); + + auto vaapiItem = std::shared_ptr(new DeckQtQuickRhiVaapiItem(), [](DeckQtQuickRhiVaapiItem* item) { + delete item; + }); + vaapiItem->setWidth(128); + vaapiItem->setHeight(72); + vaapiItem->setParentItem(window.contentItem()); + + DeckVaapiFfmpegRenderer renderer; + renderer.presentationHandoff().setSink(vaapiItem); + const int setupResult = renderer.setup(VIDEO_FORMAT_H264, 128, 72, 1, nullptr, 0); + NOVA_TEST_REQUIRE(setupResult == DR_OK); + + auto idrBytes = makeLocalAnnexBH264IdrSample(); + NOVA_TEST_REQUIRE(!idrBytes.empty()); + LENTRY idrEntry{}; + DECODE_UNIT idrUnit = makeDecodeUnit(idrBytes, idrEntry, 1); + NOVA_TEST_REQUIRE(renderer.submitDecodeUnit(&idrUnit) == DR_OK); + NOVA_TEST_REQUIRE(renderer.lifecycle().decodedHardwareFrames == 1); + NOVA_TEST_REQUIRE(renderer.lifecycle().presentedHardwareFrames == 1); + + window.show(); + window.requestUpdate(); + vaapiItem->update(); + if (!waitForRenderPasses(app, window, *vaapiItem, 1)) { + std::cerr << "QSGRenderNode render path did not enter product render() from the first Qt scenegraph pass; " + << environmentDetail(window, renderer.lifecycle()) << "\nRecorded Qt messages:\n" + << joinedRecordedMessages(); + return 1; + } + + LENTRY secondIdrEntry{}; + DECODE_UNIT secondIdrUnit = makeDecodeUnit(idrBytes, secondIdrEntry, 2); + NOVA_TEST_REQUIRE(renderer.submitDecodeUnit(&secondIdrUnit) == DR_OK); + NOVA_TEST_REQUIRE(renderer.lifecycle().decodedHardwareFrames == 2); + NOVA_TEST_REQUIRE(renderer.lifecycle().presentedHardwareFrames == 2); + window.requestUpdate(); + vaapiItem->update(); + if (!waitForRenderPasses(app, window, *vaapiItem, 2)) { + std::cerr << "QSGRenderNode render path did not enter product render() for two consecutive local VAAPI frames; " + << environmentDetail(window, renderer.lifecycle()) << " renderPasses=" + << recordedMessageCount("Nova Deck QSGRenderNode VAAPI/EGL render path") + << "\nRecorded Qt messages:\n" + << joinedRecordedMessages(); + return 1; + } + NOVA_TEST_REQUIRE(recordedMessageContains("readiness stayed false until shader composition proof")); + NOVA_TEST_REQUIRE(recordedMessageContains("layers=2")); + const bool provedReady = recordedMessageContains("status=ready") && recordedMessageContains("ready=1"); + const bool blockedByHeadlessBackend = recordedMessageContains("status=unsupported-non-opengl-scene-graph") && + recordedMessageContains("graphicsApi=") && + recordedMessageContains("render-thread EGLImage import is not attempted"); + const bool blockedByMissingContext = recordedMessageContains("status=missing-render-context") && + recordedMessageContains("No current EGL display/context"); + if (!require(provedReady || blockedByHeadlessBackend || blockedByMissingContext, + "expected scenegraph render pass either to prove ready or report an exact headless backend/context capability")) { + std::cerr << joinedRecordedMessages(); + return 1; + } + + renderer.cleanup(); + vaapiItem->setParentItem(nullptr); + vaapiItem.reset(); + std::cout << "Nova Deck QSGRenderNode scenegraph smoke passed: product render-node path entered " + << recordedMessageCount("Nova Deck QSGRenderNode VAAPI/EGL render path") + << " consecutive render passes; " + << (provedReady ? "imported two DRM_PRIME layers, proved shader composition, then reported ready" + : "blocked with exact headless Qt scenegraph EGL/OpenGL capability detail") + << '\n'; + return 0; +} diff --git a/clients/deck/tests/deck_stream_media_adapters_test.cpp b/clients/deck/tests/deck_stream_media_adapters_test.cpp index 959f1012..1d0643bd 100644 --- a/clients/deck/tests/deck_stream_media_adapters_test.cpp +++ b/clients/deck/tests/deck_stream_media_adapters_test.cpp @@ -2,11 +2,26 @@ #include -#include +extern "C" { +#include +#include +#include +} + +#include +#include + +#include +#include +#include + +#include #include #include #include +#include #include +#include #include #include #include @@ -17,6 +32,21 @@ namespace { +bool require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + return false; + } + return true; +} + +#define NOVA_TEST_REQUIRE(...) \ + do { \ + if (!require(static_cast((__VA_ARGS__)), "expected " #__VA_ARGS__)) { \ + return 1; \ + } \ + } while (false) + class NoopInput final : public nova::deck::stream::DeckStreamInput { public: std::string_view adapterName() const override { return "noop-input"; } @@ -51,14 +81,18 @@ class RecordingEvents final : public nova::deck::stream::DeckStreamSessionEvents std::vector readBinaryFile(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); - assert(input.good()); + if (!require(input.good(), "expected generated H.264 sample to be readable")) { + return {}; + } return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; } std::vector makeLocalAnnexBH264IdrSample() { const auto output = std::filesystem::temp_directory_path() / ("nova-deck-local-idr-" + std::to_string(getpid()) + ".h264"); const pid_t pid = fork(); - assert(pid >= 0); + if (!require(pid >= 0, "expected ffmpeg sample encoder process to fork")) { + return {}; + } if (pid == 0) { execlp( "ffmpeg", @@ -88,11 +122,19 @@ std::vector makeLocalAnnexBH264IdrSample() { _exit(127); } int status = 0; - assert(waitpid(pid, &status, 0) == pid); - assert(WIFEXITED(status)); - assert(WEXITSTATUS(status) == 0); + if (!require(waitpid(pid, &status, 0) == pid, "expected ffmpeg sample encoder process to exit")) { + return {}; + } + if (!require(WIFEXITED(status), "expected ffmpeg sample encoder to exit normally")) { + return {}; + } + if (!require(WEXITSTATUS(status) == 0, "expected ffmpeg sample encoder to succeed")) { + return {}; + } auto bytes = readBinaryFile(output); - assert(!bytes.empty()); + if (!require(!bytes.empty(), "expected generated H.264 sample bytes")) { + return {}; + } return bytes; } @@ -110,12 +152,113 @@ DECODE_UNIT makeDecodeUnit(std::vector& annexBBytes, LENTRY& entry return unit; } +class ScopedLiveEglContext final { +public: + ScopedLiveEglContext() { + const char* clientExtensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + auto getPlatformDisplay = reinterpret_cast(eglGetProcAddress("eglGetPlatformDisplayEXT")); + if (getPlatformDisplay != nullptr && clientExtensions != nullptr && + std::string_view(clientExtensions).find("EGL_MESA_platform_surfaceless") != std::string_view::npos) { + display_ = getPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr); + } + if (display_ == EGL_NO_DISPLAY) { + display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } + if (display_ == EGL_NO_DISPLAY || eglInitialize(display_, nullptr, nullptr) != EGL_TRUE) { + detail_ = "eglInitialize failed for surfaceless/default display"; + display_ = EGL_NO_DISPLAY; + return; + } + if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) { + detail_ = "eglBindAPI(EGL_OPENGL_ES_API) failed"; + return; + } + + const EGLint configAttributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE, + }; + EGLConfig config = nullptr; + EGLint configCount = 0; + if (eglChooseConfig(display_, configAttributes, &config, 1, &configCount) != EGL_TRUE || configCount <= 0) { + detail_ = "eglChooseConfig failed for GLES2 pbuffer"; + return; + } + + const EGLint surfaceAttributes[] = { EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE }; + surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes); + if (surface_ == EGL_NO_SURFACE) { + detail_ = "eglCreatePbufferSurface failed"; + return; + } + const EGLint contextAttributes[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; + context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes); + if (context_ == EGL_NO_CONTEXT) { + detail_ = "eglCreateContext(GLES2) failed"; + return; + } + if (eglMakeCurrent(display_, surface_, surface_, context_) != EGL_TRUE) { + detail_ = "eglMakeCurrent failed"; + return; + } + valid_ = true; + detail_ = "live EGL/GLES2 pbuffer context current"; + } + + ~ScopedLiveEglContext() { + if (display_ != EGL_NO_DISPLAY) { + eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (context_ != EGL_NO_CONTEXT) { + eglDestroyContext(display_, context_); + } + if (surface_ != EGL_NO_SURFACE) { + eglDestroySurface(display_, surface_); + } + eglTerminate(display_); + } + } + + bool valid() const { return valid_; } + const std::string& detail() const { return detail_; } + +private: + EGLDisplay display_ = EGL_NO_DISPLAY; + EGLSurface surface_ = EGL_NO_SURFACE; + EGLContext context_ = EGL_NO_CONTEXT; + bool valid_ = false; + std::string detail_ = "not attempted"; +}; + } // namespace -int main() { +int main(int argc, char** argv) { + qputenv("QT_QPA_PLATFORM", qgetenv("QT_QPA_PLATFORM").isEmpty() ? QByteArray("minimal") : qgetenv("QT_QPA_PLATFORM")); + QGuiApplication app(argc, argv); + using nova::deck::stream::DeckLinuxAudioProbe; using nova::deck::stream::DeckLinuxMediaProbe; using nova::deck::stream::DeckPipeWireAudio; + using nova::deck::stream::DeckQrhiVaapiFrameLease; + using nova::deck::stream::DeckQtQuickRhiVaapiItem; + using nova::deck::stream::DeckQtQuickRhiVaapiRenderNode; + using nova::deck::stream::DeckQrhiVaapiImportStatus; + using nova::deck::stream::DeckQrhiVaapiImportPlan; + using nova::deck::stream::DeckQrhiVaapiPresentationDescriptor; + using nova::deck::stream::DeckQrhiVaapiPresentationHandoff; + using nova::deck::stream::DeckVaapiPreviewFramePump; + using nova::deck::stream::DeckVaapiPresenterReadinessState; + using nova::deck::stream::DeckQtQuickRhiPresentationSink; + using nova::deck::stream::DeckVaapiEglImagePresenter; + using nova::deck::stream::DeckProductPreviewPipeline; + using nova::deck::stream::DeckGuardedPreviewLifecycleGate; + using nova::deck::stream::DeckGuardedStreamSessionPreviewProducer; + using nova::deck::stream::DeckOperatorStartAuthorizationMode; + using nova::deck::stream::DeckOperatorStartAuthorizationPolicy; using nova::deck::stream::DeckStreamRequest; using nova::deck::stream::DeckStreamSession; using nova::deck::stream::DeckStreamSessionState; @@ -125,83 +268,1082 @@ int main() { static_assert(!std::is_move_constructible_v); const DeckLinuxMediaProbe mediaProbe = DeckLinuxMediaProbe::detect(); - assert(mediaProbe.ffmpegLibavcodecHeadersLinked); - assert(mediaProbe.ffmpegLibavutilHeadersLinked); - assert(mediaProbe.vaapiHeadersLinked); - assert(mediaProbe.qtQuickRhiPresentationBoundary); - assert(mediaProbe.hardwareDeviceTypeName == std::string("vaapi")); - assert(!mediaProbe.runtimeStatus.empty()); + NOVA_TEST_REQUIRE(mediaProbe.ffmpegLibavcodecHeadersLinked); + NOVA_TEST_REQUIRE(mediaProbe.ffmpegLibavutilHeadersLinked); + NOVA_TEST_REQUIRE(mediaProbe.vaapiHeadersLinked); + NOVA_TEST_REQUIRE(mediaProbe.qtQuickRhiPresentationBoundary); + NOVA_TEST_REQUIRE(mediaProbe.hardwareDeviceTypeName == std::string("vaapi")); + NOVA_TEST_REQUIRE(!mediaProbe.runtimeStatus.empty()); + + class RecordingPresentationSink final : public DeckQtQuickRhiPresentationSink { + public: + bool presentVaapiSurface(const DeckQrhiVaapiPresentationDescriptor& descriptor) override { + ++presentCalls; + lastDescriptor = descriptor; + return descriptor.hardwareBacked && descriptor.surfaceId != 0; + } + + int presentCalls = 0; + DeckQrhiVaapiPresentationDescriptor lastDescriptor{}; + }; + + auto presentationSink = std::make_shared(); + DeckQrhiVaapiPresentationHandoff presentationHandoff; + presentationHandoff.setSink(presentationSink); + NOVA_TEST_REQUIRE(presentationHandoff.presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = 42, + .hardwareBacked = true, + .source = "test-vaapi-surface", + })); + NOVA_TEST_REQUIRE(presentationHandoff.presentedFrames() == 1); + NOVA_TEST_REQUIRE(presentationSink->presentCalls == 1); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.width == 1280); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.height == 800); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.redrawRate == 60); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.surfaceId == 42); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.hardwareBacked); + NOVA_TEST_REQUIRE(presentationSink->lastDescriptor.source == std::string("test-vaapi-surface")); + NOVA_TEST_REQUIRE(!presentationHandoff.presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = 0, + .hardwareBacked = false, + .source = "software-frame-rejected", + })); + NOVA_TEST_REQUIRE(presentationHandoff.presentedFrames() == 1); + + class TestableQtQuickRhiVaapiItem final : public DeckQtQuickRhiVaapiItem { + public: + using DeckQtQuickRhiVaapiItem::updatePaintNode; + }; + + auto makeFakeFrameLease = [](std::uintptr_t surfaceId) { + AVFrame* fakeVaapiFrame = av_frame_alloc(); + if (fakeVaapiFrame == nullptr) { + return std::shared_ptr{}; + } + fakeVaapiFrame->format = AV_PIX_FMT_VAAPI; + fakeVaapiFrame->buf[0] = av_buffer_alloc(1); + if (fakeVaapiFrame->buf[0] == nullptr) { + av_frame_free(&fakeVaapiFrame); + return std::shared_ptr{}; + } + fakeVaapiFrame->data[3] = reinterpret_cast(surfaceId); + std::shared_ptr lease = DeckQrhiVaapiFrameLease::cloneHardwareFrame(*fakeVaapiFrame); + av_frame_free(&fakeVaapiFrame); + return lease; + }; + + DeckQrhiVaapiPresentationHandoff previewHandoff; + auto previewSink = std::make_shared(); + previewHandoff.setSink(previewSink); + DeckVaapiPreviewFramePump previewFramePump(previewHandoff); + std::shared_ptr previewFrame1 = makeFakeFrameLease(0x101); + std::shared_ptr previewFrame2 = makeFakeFrameLease(0x102); + std::weak_ptr previewFrame1Weak = previewFrame1; + std::weak_ptr previewFrame2Weak = previewFrame2; + NOVA_TEST_REQUIRE(previewFramePump.enqueueDecodedFrame(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = previewFrame1->surfaceId(), + .hardwareBacked = true, + .frameLease = previewFrame1, + .source = "preview-fixture-older", + })); + previewFrame1.reset(); + NOVA_TEST_REQUIRE(!previewFrame1Weak.expired()); + NOVA_TEST_REQUIRE(previewFramePump.enqueueDecodedFrame(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = previewFrame2->surfaceId(), + .hardwareBacked = true, + .frameLease = previewFrame2, + .source = "preview-fixture-newest", + })); + previewFrame2.reset(); + NOVA_TEST_REQUIRE(previewFrame1Weak.expired()); + NOVA_TEST_REQUIRE(!previewFrame2Weak.expired()); + NOVA_TEST_REQUIRE(previewFramePump.queuedFrames() == 2); + NOVA_TEST_REQUIRE(previewFramePump.coalescedFrames() == 1); + NOVA_TEST_REQUIRE(previewFramePump.pendingFrames() == 1); + NOVA_TEST_REQUIRE(previewFramePump.flushNewest()); + NOVA_TEST_REQUIRE(previewFramePump.flushedFrames() == 1); + NOVA_TEST_REQUIRE(previewFramePump.pendingFrames() == 0); + NOVA_TEST_REQUIRE(previewSink->presentCalls == 1); + NOVA_TEST_REQUIRE(previewSink->lastDescriptor.surfaceId == 0x102); + NOVA_TEST_REQUIRE(previewSink->lastDescriptor.source == std::string("preview-fixture-newest")); + NOVA_TEST_REQUIRE(!previewFramePump.flushNewest()); + NOVA_TEST_REQUIRE(!previewFramePump.enqueueDecodedFrame(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = 0, + .hardwareBacked = false, + .source = "preview-invalid-reset", + })); + NOVA_TEST_REQUIRE(previewFramePump.invalidatedFrames() == 1); + NOVA_TEST_REQUIRE(previewFramePump.pendingFrames() == 0); + NOVA_TEST_REQUIRE(previewSink->presentCalls == 2); + NOVA_TEST_REQUIRE(previewSink->lastDescriptor.source == std::string("preview-invalid-reset")); + NOVA_TEST_REQUIRE(previewFrame2Weak.expired()); + + auto productPreviewPipeline = std::make_shared(); + auto productPreviewSink = std::make_shared(); + productPreviewPipeline->attachSink(productPreviewSink); + NOVA_TEST_REQUIRE(!productPreviewPipeline->presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = 0, + .hardwareBacked = false, + .source = "product-offline-preview-fixture-missing-lease", + })); + const auto missingLeaseReadiness = productPreviewPipeline->lastReadinessReport(); + NOVA_TEST_REQUIRE(missingLeaseReadiness.statusCode == std::string("missing-frame-lease")); + NOVA_TEST_REQUIRE(!missingLeaseReadiness.ready); + NOVA_TEST_REQUIRE(!missingLeaseReadiness.hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(missingLeaseReadiness.detail.find("fail-closed") != std::string::npos); + NOVA_TEST_REQUIRE(productPreviewPipeline->invalidatedFrames() == 1); + NOVA_TEST_REQUIRE(productPreviewPipeline->pendingFrames() == 0); + NOVA_TEST_REQUIRE(productPreviewPipeline->presentedFrames() == 0); + NOVA_TEST_REQUIRE(productPreviewSink->presentCalls == 1); + NOVA_TEST_REQUIRE(productPreviewSink->lastDescriptor.source == std::string("product-offline-preview-fixture-missing-lease")); + + std::shared_ptr productFixtureFrame = makeFakeFrameLease(0x201); + std::weak_ptr productFixtureFrameWeak = productFixtureFrame; + NOVA_TEST_REQUIRE(productPreviewPipeline->presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = productFixtureFrame->surfaceId(), + .hardwareBacked = true, + .frameLease = productFixtureFrame, + .source = "product-decoded-preview-fixture-vaapi", + })); + const auto queuedFixtureReadiness = productPreviewPipeline->lastReadinessReport(); + productFixtureFrame.reset(); + NOVA_TEST_REQUIRE(queuedFixtureReadiness.statusCode == std::string("hardware-frame-ready")); + NOVA_TEST_REQUIRE(queuedFixtureReadiness.hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(!queuedFixtureReadiness.ready); + NOVA_TEST_REQUIRE(queuedFixtureReadiness.detail.find("product Deck preview pipeline") != std::string::npos); + NOVA_TEST_REQUIRE(productPreviewPipeline->queuedFrames() == 1); + NOVA_TEST_REQUIRE(productPreviewPipeline->flushedFrames() == 1); + NOVA_TEST_REQUIRE(productPreviewPipeline->presentedFrames() == 1); + NOVA_TEST_REQUIRE(productPreviewSink->lastDescriptor.surfaceId == 0x201); + NOVA_TEST_REQUIRE(productPreviewSink->lastDescriptor.source == std::string("product-decoded-preview-fixture-vaapi")); + NOVA_TEST_REQUIRE(!productFixtureFrameWeak.expired()); + + DeckProductPreviewPipeline guardedStreamPipeline; + auto guardedStreamSink = std::make_shared(); + guardedStreamPipeline.attachSink(guardedStreamSink); + DeckGuardedStreamSessionPreviewProducer guardedStreamProducer; + NOVA_TEST_REQUIRE(!guardedStreamProducer.moonlightBoundary().networkStartAllowed); + guardedStreamProducer.attachProductPreviewPipeline(guardedStreamPipeline); + std::shared_ptr guardedStreamFrame = makeFakeFrameLease(0x301); + NOVA_TEST_REQUIRE(guardedStreamProducer.decodedFrameProducer().presentationHandoff().presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = guardedStreamFrame->surfaceId(), + .hardwareBacked = true, + .frameLease = guardedStreamFrame, + .source = "guarded-stream-session-producer", + })); + NOVA_TEST_REQUIRE(guardedStreamPipeline.presentedFrames() == 1); + NOVA_TEST_REQUIRE(guardedStreamSink->lastDescriptor.surfaceId == 0x301); + NOVA_TEST_REQUIRE(guardedStreamSink->lastDescriptor.source == std::string("guarded-stream-session-producer")); + const auto guardedPrepared = guardedStreamProducer.prepareNoNetwork(DeckStreamRequest{ + .hostId = "offline-guarded-host", + .gameId = "offline-guarded-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + NOVA_TEST_REQUIRE(guardedPrepared.state == DeckStreamSessionState::Preparing); + NOVA_TEST_REQUIRE(!guardedPrepared.networkStarted); + NOVA_TEST_REQUIRE(guardedStreamProducer.rendererLifecycle().setupCalls == 0); + const int guardedSetup = guardedStreamProducer.moonlightBoundary().videoCallbacks->setup( + VIDEO_FORMAT_H264, + 1280, + 800, + 60, + guardedStreamProducer.moonlightBoundary().callbackContext, + 0); + NOVA_TEST_REQUIRE(guardedSetup == (guardedStreamProducer.rendererLifecycle().runtimeVaapiDeviceAvailable ? DR_OK : DR_NEED_IDR)); + NOVA_TEST_REQUIRE(guardedStreamProducer.rendererLifecycle().setupCalls == 1); + const auto guardedStarted = guardedStreamProducer.startNoNetwork(); + NOVA_TEST_REQUIRE(guardedStarted.state == DeckStreamSessionState::Active); + NOVA_TEST_REQUIRE(!guardedStarted.networkStarted); + guardedStreamProducer.stop(); + + DeckProductPreviewPipeline guardedLifecyclePipeline; + auto guardedLifecycleSink = std::make_shared(); + guardedLifecyclePipeline.attachSink(guardedLifecycleSink); + DeckGuardedStreamSessionPreviewProducer guardedLifecycleProducer; + DeckGuardedPreviewLifecycleGate guardedLifecycleGate(guardedLifecycleProducer); + NOVA_TEST_REQUIRE(!guardedLifecycleGate.lastReport().networkStartAllowed); + NOVA_TEST_REQUIRE(guardedLifecycleGate.lastReport().statusCode == std::string("idle-no-network")); + guardedLifecycleGate.attachProductPreviewPipeline(guardedLifecyclePipeline); + std::shared_ptr guardedLifecycleFrame = makeFakeFrameLease(0x302); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.decodedFrameProducer().presentationHandoff().presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = guardedLifecycleFrame->surfaceId(), + .hardwareBacked = true, + .frameLease = guardedLifecycleFrame, + .source = "guarded-preview-lifecycle-gate", + })); + NOVA_TEST_REQUIRE(guardedLifecyclePipeline.presentedFrames() == 1); + NOVA_TEST_REQUIRE(guardedLifecycleSink->lastDescriptor.surfaceId == 0x302); + const auto lifecycleArmed = guardedLifecycleGate.armNoNetwork(DeckStreamRequest{ + .hostId = "offline-guarded-host", + .gameId = "offline-guarded-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + NOVA_TEST_REQUIRE(lifecycleArmed.state == DeckStreamSessionState::Active); + NOVA_TEST_REQUIRE(lifecycleArmed.statusCode == std::string("active-no-network")); + NOVA_TEST_REQUIRE(lifecycleArmed.hostId == std::string("offline-guarded-host")); + NOVA_TEST_REQUIRE(lifecycleArmed.gameId == std::string("offline-guarded-game")); + NOVA_TEST_REQUIRE(lifecycleArmed.width == 1280); + NOVA_TEST_REQUIRE(lifecycleArmed.height == 800); + NOVA_TEST_REQUIRE(lifecycleArmed.fps == 60); + NOVA_TEST_REQUIRE(lifecycleArmed.bitrateKbps == 20000); + NOVA_TEST_REQUIRE(lifecycleArmed.prepared); + NOVA_TEST_REQUIRE(lifecycleArmed.armed); + NOVA_TEST_REQUIRE(!lifecycleArmed.networkStartAllowed); + NOVA_TEST_REQUIRE(!lifecycleArmed.networkStarted); + NOVA_TEST_REQUIRE(lifecycleArmed.reason.find("offline-guarded-host") == std::string::npos); + NOVA_TEST_REQUIRE(lifecycleArmed.reason.find("token") == std::string::npos); + NOVA_TEST_REQUIRE(lifecycleArmed.reason.find("credential") == std::string::npos); + NOVA_TEST_REQUIRE(lifecycleArmed.transitionCount >= 3); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + const auto hostStartBlocked = guardedLifecycleGate.requestGuardedHostNetworkStart(); + NOVA_TEST_REQUIRE(hostStartBlocked.state == DeckStreamSessionState::Active); + NOVA_TEST_REQUIRE(hostStartBlocked.statusCode == std::string("host-network-start-blocked")); + NOVA_TEST_REQUIRE(hostStartBlocked.prepared); + NOVA_TEST_REQUIRE(hostStartBlocked.armed); + NOVA_TEST_REQUIRE(hostStartBlocked.hostStartBoundaryExplicit); + NOVA_TEST_REQUIRE(hostStartBlocked.hostStartContractAuthorized == false); + NOVA_TEST_REQUIRE(!hostStartBlocked.networkStartAllowed); + NOVA_TEST_REQUIRE(!hostStartBlocked.networkStarted); + NOVA_TEST_REQUIRE(hostStartBlocked.reason.find("operator authorization") != std::string::npos); + NOVA_TEST_REQUIRE(hostStartBlocked.reason.find("offline-guarded-host") == std::string::npos); + NOVA_TEST_REQUIRE(hostStartBlocked.reason.find("token") == std::string::npos); + NOVA_TEST_REQUIRE(hostStartBlocked.reason.find("credential") == std::string::npos); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + + DeckOperatorStartAuthorizationPolicy defaultOperatorPolicy; + const auto defaultAuthorization = defaultOperatorPolicy.snapshot(); + NOVA_TEST_REQUIRE(defaultAuthorization.mode == DeckOperatorStartAuthorizationMode::Blocked); + NOVA_TEST_REQUIRE(defaultAuthorization.statusCode == std::string("operator-start-blocked")); + NOVA_TEST_REQUIRE(defaultAuthorization.dryRunAuthorized == false); + NOVA_TEST_REQUIRE(defaultAuthorization.startAuthorized == false); + NOVA_TEST_REQUIRE(defaultAuthorization.tokenless); + NOVA_TEST_REQUIRE(defaultAuthorization.opaqueLocalStateId.empty()); + NOVA_TEST_REQUIRE(defaultAuthorization.networkStarted == false); + + const auto defaultDryRunDenied = guardedLifecycleGate.requestOperatorAuthorizedDryRun(defaultAuthorization); + NOVA_TEST_REQUIRE(defaultDryRunDenied.statusCode == std::string("operator-dry-run-blocked")); + NOVA_TEST_REQUIRE(defaultDryRunDenied.operatorAuthorizationState == std::string("blocked")); + NOVA_TEST_REQUIRE(defaultDryRunDenied.hostStartContractAuthorized == false); + NOVA_TEST_REQUIRE(defaultDryRunDenied.networkStarted == false); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + + DeckOperatorStartAuthorizationPolicy dryRunOperatorPolicy; + dryRunOperatorPolicy.authorizeDryRun("local-operator-dry-run-ok"); + const auto dryRunAuthorization = dryRunOperatorPolicy.snapshot(); + NOVA_TEST_REQUIRE(dryRunAuthorization.mode == DeckOperatorStartAuthorizationMode::DryRunAuthorized); + NOVA_TEST_REQUIRE(dryRunAuthorization.statusCode == std::string("operator-dry-run-authorized")); + NOVA_TEST_REQUIRE(dryRunAuthorization.dryRunAuthorized); + NOVA_TEST_REQUIRE(!dryRunAuthorization.startAuthorized); + NOVA_TEST_REQUIRE(dryRunAuthorization.tokenless); + NOVA_TEST_REQUIRE(dryRunAuthorization.opaqueLocalStateId == std::string("local-operator-dry-run-ok")); + const auto dryRunApproved = guardedLifecycleGate.requestOperatorAuthorizedDryRun(dryRunAuthorization); + NOVA_TEST_REQUIRE(dryRunApproved.statusCode == std::string("operator-dry-run-authorized")); + NOVA_TEST_REQUIRE(dryRunApproved.operatorAuthorizationState == std::string("dry-run-authorized")); + NOVA_TEST_REQUIRE(dryRunApproved.hostStartBoundaryExplicit); + NOVA_TEST_REQUIRE(dryRunApproved.hostStartContractAuthorized == false); + NOVA_TEST_REQUIRE(!dryRunApproved.networkStartAllowed); + NOVA_TEST_REQUIRE(!dryRunApproved.networkStarted); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + + DeckOperatorStartAuthorizationPolicy startOperatorPolicy; + startOperatorPolicy.authorizeStart("local-operator-start-ok"); + const auto startAuthorization = startOperatorPolicy.snapshot(); + NOVA_TEST_REQUIRE(startAuthorization.mode == DeckOperatorStartAuthorizationMode::StartAuthorized); + NOVA_TEST_REQUIRE(startAuthorization.statusCode == std::string("operator-start-authorized")); + NOVA_TEST_REQUIRE(startAuthorization.dryRunAuthorized); + NOVA_TEST_REQUIRE(startAuthorization.startAuthorized); + NOVA_TEST_REQUIRE(startAuthorization.tokenless); + NOVA_TEST_REQUIRE(startAuthorization.opaqueLocalStateId == std::string("local-operator-start-ok")); + const auto startNotReady = guardedLifecycleGate.requestOperatorAuthorizedHostNetworkStart(startAuthorization); + NOVA_TEST_REQUIRE(startNotReady.statusCode == std::string("operator-start-not-ready")); + NOVA_TEST_REQUIRE(startNotReady.operatorAuthorizationState == std::string("start-authorized")); + NOVA_TEST_REQUIRE(startNotReady.hostStartBoundaryExplicit); + NOVA_TEST_REQUIRE(startNotReady.hostStartContractAuthorized); + NOVA_TEST_REQUIRE(!startNotReady.networkStartAllowed); + NOVA_TEST_REQUIRE(!startNotReady.networkStarted); + NOVA_TEST_REQUIRE(startNotReady.reason.find("external host readiness") != std::string::npos); + NOVA_TEST_REQUIRE(startNotReady.reason.find("local-operator-start-ok") == std::string::npos); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + + const DeckStreamRequest selectedHostPreflightRequest{ + .hostId = "offline-preflight-host", + .gameId = "offline-preflight-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }; + const auto missingHostPreflight = guardedLifecycleGate.requestHostStartDryRunPreflight( + startAuthorization, + DeckStreamRequest{ + .hostId = "", + .gameId = "offline-preflight-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + NOVA_TEST_REQUIRE(missingHostPreflight.statusCode == std::string("host-start-preflight-missing-host")); + NOVA_TEST_REQUIRE(missingHostPreflight.hostId.empty()); + NOVA_TEST_REQUIRE(missingHostPreflight.gameId == std::string("offline-preflight-game")); + NOVA_TEST_REQUIRE(missingHostPreflight.dryRunPreflightRequested); + NOVA_TEST_REQUIRE(!missingHostPreflight.hostStartContractAuthorized); + NOVA_TEST_REQUIRE(!missingHostPreflight.networkStartAllowed); + NOVA_TEST_REQUIRE(!missingHostPreflight.networkStarted); + NOVA_TEST_REQUIRE(missingHostPreflight.reason.find("missing host selection") != std::string::npos); + + const auto blockedPreflight = guardedLifecycleGate.requestHostStartDryRunPreflight( + defaultAuthorization, + selectedHostPreflightRequest); + NOVA_TEST_REQUIRE(blockedPreflight.statusCode == std::string("host-start-preflight-contract-blocked")); + NOVA_TEST_REQUIRE(blockedPreflight.hostId == std::string("offline-preflight-host")); + NOVA_TEST_REQUIRE(blockedPreflight.gameId == std::string("offline-preflight-game")); + NOVA_TEST_REQUIRE(blockedPreflight.width == 1280); + NOVA_TEST_REQUIRE(blockedPreflight.height == 800); + NOVA_TEST_REQUIRE(blockedPreflight.fps == 60); + NOVA_TEST_REQUIRE(blockedPreflight.bitrateKbps == 20000); + NOVA_TEST_REQUIRE(blockedPreflight.dryRunPreflightRequested); + NOVA_TEST_REQUIRE(!blockedPreflight.hostStartContractAuthorized); + NOVA_TEST_REQUIRE(!blockedPreflight.networkStartAllowed); + NOVA_TEST_REQUIRE(!blockedPreflight.networkStarted); + NOVA_TEST_REQUIRE(blockedPreflight.reason.find("operator start contract") != std::string::npos); + NOVA_TEST_REQUIRE(blockedPreflight.reason.find("offline-preflight-host") == std::string::npos); + NOVA_TEST_REQUIRE(blockedPreflight.reason.find("token") == std::string::npos); + + const auto authorizedPreflight = guardedLifecycleGate.requestHostStartDryRunPreflight( + startAuthorization, + selectedHostPreflightRequest); + NOVA_TEST_REQUIRE(authorizedPreflight.statusCode == std::string("host-start-dry-run-preflight-authorized")); + NOVA_TEST_REQUIRE(authorizedPreflight.hostId == std::string("offline-preflight-host")); + NOVA_TEST_REQUIRE(authorizedPreflight.gameId == std::string("offline-preflight-game")); + NOVA_TEST_REQUIRE(authorizedPreflight.dryRunPreflightRequested); + NOVA_TEST_REQUIRE(authorizedPreflight.hostStartContractAuthorized); + NOVA_TEST_REQUIRE(!authorizedPreflight.networkStartAllowed); + NOVA_TEST_REQUIRE(!authorizedPreflight.networkStarted); + NOVA_TEST_REQUIRE(authorizedPreflight.reason.find("report-only") != std::string::npos); + NOVA_TEST_REQUIRE(authorizedPreflight.reason.find("offline-preflight-host") == std::string::npos); + NOVA_TEST_REQUIRE(guardedLifecycleProducer.rendererLifecycle().setupCalls == 0); + + const auto realStartStillUnavailable = guardedLifecycleGate.requestOperatorAuthorizedHostNetworkStart(startAuthorization); + NOVA_TEST_REQUIRE(realStartStillUnavailable.statusCode == std::string("operator-start-not-ready")); + NOVA_TEST_REQUIRE(!realStartStillUnavailable.dryRunPreflightRequested); + NOVA_TEST_REQUIRE(!realStartStillUnavailable.networkStartAllowed); + NOVA_TEST_REQUIRE(!realStartStillUnavailable.networkStarted); + NOVA_TEST_REQUIRE(realStartStillUnavailable.reason.find("network disabled") != std::string::npos); + const auto lifecycleStopped = guardedLifecycleGate.stop(); + NOVA_TEST_REQUIRE(lifecycleStopped.state == DeckStreamSessionState::Stopped); + NOVA_TEST_REQUIRE(lifecycleStopped.statusCode == std::string("stopped-no-network")); + NOVA_TEST_REQUIRE(!lifecycleStopped.dryRunPreflightRequested); + NOVA_TEST_REQUIRE(!lifecycleStopped.networkStarted); + NOVA_TEST_REQUIRE(guardedLifecycleGate.transitions().size() >= 4); + + DeckGuardedStreamSessionPreviewProducer idempotentLifecycleProducer; + DeckGuardedPreviewLifecycleGate idempotentLifecycleGate(idempotentLifecycleProducer); + const auto idempotentFirstArm = idempotentLifecycleGate.armNoNetwork(DeckStreamRequest{ + .hostId = "offline-guarded-host", + .gameId = "offline-guarded-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + NOVA_TEST_REQUIRE(idempotentFirstArm.statusCode == std::string("active-no-network")); + const auto idempotentTransitionCountAfterArm = idempotentLifecycleGate.transitions().size(); + const auto idempotentSecondArm = idempotentLifecycleGate.armNoNetwork(DeckStreamRequest{ + .hostId = "offline-guarded-host", + .gameId = "offline-guarded-game", + .width = 1280, + .height = 800, + .fps = 60, + .bitrateKbps = 20000, + }); + NOVA_TEST_REQUIRE(idempotentSecondArm.state == DeckStreamSessionState::Active); + NOVA_TEST_REQUIRE(idempotentSecondArm.statusCode == std::string("already-active-no-network")); + NOVA_TEST_REQUIRE(idempotentSecondArm.armed); + NOVA_TEST_REQUIRE(!idempotentSecondArm.networkStartAllowed); + NOVA_TEST_REQUIRE(!idempotentSecondArm.networkStarted); + NOVA_TEST_REQUIRE(idempotentLifecycleGate.transitions().size() == idempotentTransitionCountAfterArm); + const auto idempotentFirstStop = idempotentLifecycleGate.stop(); + NOVA_TEST_REQUIRE(idempotentFirstStop.statusCode == std::string("stopped-no-network")); + const auto idempotentTransitionCountAfterStop = idempotentLifecycleGate.transitions().size(); + const auto idempotentSecondStop = idempotentLifecycleGate.stop(); + NOVA_TEST_REQUIRE(idempotentSecondStop.state == DeckStreamSessionState::Stopped); + NOVA_TEST_REQUIRE(idempotentSecondStop.statusCode == std::string("already-stopped-no-network")); + NOVA_TEST_REQUIRE(!idempotentSecondStop.armed); + NOVA_TEST_REQUIRE(!idempotentSecondStop.networkStarted); + NOVA_TEST_REQUIRE(idempotentLifecycleGate.transitions().size() == idempotentTransitionCountAfterStop); + + AVFrame* fakeVaapiFrame = av_frame_alloc(); + if (!require(fakeVaapiFrame != nullptr, "expected test VAAPI frame allocation")) { + return 1; + } + fakeVaapiFrame->format = AV_PIX_FMT_VAAPI; + fakeVaapiFrame->buf[0] = av_buffer_alloc(1); + if (!require(fakeVaapiFrame->buf[0] != nullptr, "expected test VAAPI frame buffer allocation")) { + av_frame_free(&fakeVaapiFrame); + return 1; + } + fakeVaapiFrame->data[3] = reinterpret_cast(0x2a); + std::shared_ptr itemFrameLease = DeckQrhiVaapiFrameLease::cloneHardwareFrame(*fakeVaapiFrame); + av_frame_free(&fakeVaapiFrame); + if (!require(itemFrameLease != nullptr, "expected test VAAPI frame lease to clone")) { + return 1; + } + std::weak_ptr itemFrameLeaseWeak = itemFrameLease; + auto vaapiItem = std::make_shared(); + DeckQrhiVaapiPresentationHandoff qtQuickPresentationHandoff; + qtQuickPresentationHandoff.setSink(vaapiItem); + if (!require(qtQuickPresentationHandoff.presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = itemFrameLease->surfaceId(), + .hardwareBacked = true, + .frameLease = itemFrameLease, + .source = "qt-quick-rhi-item-test", + }), "expected handoff to accept the Qt Quick VAAPI item as a sink")) { + return 1; + } + if (!require(qtQuickPresentationHandoff.presentedFrames() == 1, "expected handoff presented frame count")) { + return 1; + } + if (!require(vaapiItem->presentedFrames() == 1, "expected Qt Quick VAAPI item presented frame count")) { + return 1; + } + itemFrameLease.reset(); + if (!require(!itemFrameLeaseWeak.expired(), "expected Qt Quick item to retain frame lease before scene graph update")) { + return 1; + } + QSGNode* sceneGraphNode = vaapiItem->updatePaintNode(nullptr, nullptr); + if (!require(sceneGraphNode != nullptr, "expected Qt Quick item to create a scene graph node")) { + return 1; + } + if (!require(sceneGraphNode->type() == QSGNode::RenderNodeType, "expected Qt Quick item to create a QSGRenderNode")) { + delete sceneGraphNode; + return 1; + } + auto* vaapiRenderNode = static_cast(sceneGraphNode); + if (!require(vaapiRenderNode->descriptor().width == 1280, "expected render node width descriptor")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->descriptor().height == 800, "expected render node height descriptor")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->descriptor().surfaceId == 42, "expected render node VAAPI surface id")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->descriptor().source == std::string("qt-quick-rhi-item-test"), "expected render node descriptor source")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->hasFrameLease(), "expected render node to retain frame lease")) { + delete sceneGraphNode; + return 1; + } + const auto fakeDrmPrimeExport = vaapiRenderNode->descriptor().frameLease->exportDrmPrimeDescriptor(); + if (!require(fakeDrmPrimeExport.status == DeckQrhiVaapiImportStatus::MissingHardwareFramesContext, + "expected fake VAAPI frame to report missing hardware frames context before DRM_PRIME export")) { + delete sceneGraphNode; + return 1; + } + const auto missingRenderStatePlan = vaapiRenderNode->planQrhiImport(nullptr); + if (!require(missingRenderStatePlan.status == DeckQrhiVaapiImportStatus::MissingRenderState, + "expected null render state to block QRhi import planning")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastReadinessReport().state == DeckVaapiPresenterReadinessState::NotAttempted, + "expected render node readiness report to start as not attempted")) { + delete sceneGraphNode; + return 1; + } + nova::deck::stream::DeckQrhiVaapiDrmPrimeDescriptor testDrmPrimeDescriptor; + testDrmPrimeDescriptor.status = DeckQrhiVaapiImportStatus::DrmPrimeExported; + testDrmPrimeDescriptor.objectCount = 1; + testDrmPrimeDescriptor.layerCount = 1; + testDrmPrimeDescriptor.objects[0].fd = 0; + testDrmPrimeDescriptor.layers[0].format = 0x34325258; // DRM_FORMAT_XRGB8888 + testDrmPrimeDescriptor.layers[0].planeCount = 1; + testDrmPrimeDescriptor.layers[0].planes[0].objectIndex = 0; + testDrmPrimeDescriptor.layers[0].planes[0].pitch = 5120; + const auto missingTargetPlan = DeckVaapiEglImagePresenter::planOpenGlTextureImport(nullptr, testDrmPrimeDescriptor, QSize(1280, 800)); + if (!require(missingTargetPlan.status == DeckQrhiVaapiImportStatus::DeckTargetUnavailable, + "expected EGLImage presenter to require a Deck Qt Quick target window")) { + delete sceneGraphNode; + return 1; + } + const auto missingTargetReadiness = DeckVaapiEglImagePresenter::readinessReportForPlan(missingTargetPlan); + if (!require(missingTargetReadiness.state == DeckVaapiPresenterReadinessState::DeckTargetUnavailable, + "expected readiness report to preserve missing Deck target diagnostics")) { + delete sceneGraphNode; + return 1; + } + if (!require(missingTargetReadiness.statusCode == std::string("deck-target-unavailable"), + "expected stable missing Deck target status code")) { + delete sceneGraphNode; + return 1; + } + if (!require(!missingTargetReadiness.ready && !missingTargetReadiness.hardwarePresenterPlanned, + "expected missing Deck target readiness to stay not ready and not planned")) { + delete sceneGraphNode; + return 1; + } + const auto frameBoundMissingTargetReadiness = DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof( + testDrmPrimeDescriptor, + missingTargetPlan); + if (!require(frameBoundMissingTargetReadiness.state == DeckVaapiPresenterReadinessState::DeckTargetUnavailable, + "expected decoded frame proof to bind to missing target readiness")) { + delete sceneGraphNode; + return 1; + } + if (!require(frameBoundMissingTargetReadiness.hardwarePresenterPlanned && !frameBoundMissingTargetReadiness.ready, + "expected decoded frame plus missing target to stay planned but not texture-ready")) { + delete sceneGraphNode; + return 1; + } + if (!require(frameBoundMissingTargetReadiness.importPlan.drmPrimeObjectCount == 1 && + frameBoundMissingTargetReadiness.importPlan.drmPrimeLayerCount == 1, + "expected decoded frame bound readiness to preserve DRM_PRIME object/layer counts")) { + delete sceneGraphNode; + return 1; + } + if (!require(frameBoundMissingTargetReadiness.detail.find("Hardware-backed VAAPI frame decoded") != std::string::npos && + frameBoundMissingTargetReadiness.detail.find("Qt Quick target") != std::string::npos, + "expected decoded frame bound readiness to explain both frame proof and render-target gate")) { + delete sceneGraphNode; + return 1; + } + nova::deck::stream::DeckQrhiVaapiDrmPrimeDescriptor incompleteDrmPrimeDescriptor; + incompleteDrmPrimeDescriptor.status = DeckQrhiVaapiImportStatus::DrmPrimeExported; + const auto incompleteMetadataPlan = DeckVaapiEglImagePresenter::validateDrmPrimeMetadata(incompleteDrmPrimeDescriptor); + if (!require(incompleteMetadataPlan.status == DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata, + "expected EGLImage presenter to reject incomplete DRM_PRIME plane metadata")) { + delete sceneGraphNode; + return 1; + } + nova::deck::stream::DeckQrhiVaapiDrmPrimeDescriptor multiLayerDrmPrimeDescriptor; + multiLayerDrmPrimeDescriptor.status = DeckQrhiVaapiImportStatus::DrmPrimeExported; + multiLayerDrmPrimeDescriptor.objectCount = 1; + multiLayerDrmPrimeDescriptor.layerCount = 2; + multiLayerDrmPrimeDescriptor.objects[0].fd = 0; + multiLayerDrmPrimeDescriptor.layers[0].format = 0x20203852; // DRM_FORMAT_R8 + multiLayerDrmPrimeDescriptor.layers[0].planeCount = 1; + multiLayerDrmPrimeDescriptor.layers[0].planes[0].objectIndex = 0; + multiLayerDrmPrimeDescriptor.layers[0].planes[0].pitch = 1280; + multiLayerDrmPrimeDescriptor.layers[1].format = 0x38385247; // DRM_FORMAT_GR88 + multiLayerDrmPrimeDescriptor.layers[1].planeCount = 1; + multiLayerDrmPrimeDescriptor.layers[1].planes[0].objectIndex = 0; + multiLayerDrmPrimeDescriptor.layers[1].planes[0].pitch = 1280; + const auto multiLayerMetadataPlan = DeckVaapiEglImagePresenter::validateDrmPrimeMetadata(multiLayerDrmPrimeDescriptor); + if (!require(multiLayerMetadataPlan.status == DeckQrhiVaapiImportStatus::DrmPrimeExported, + "expected EGLImage presenter to accept the real Deck two-layer Y/UV DRM_PRIME shape")) { + delete sceneGraphNode; + return 1; + } + if (!require(multiLayerMetadataPlan.detail.find("2-layer DRM_PRIME") != std::string::npos && + multiLayerMetadataPlan.detail.find("YUV") != std::string::npos && + multiLayerMetadataPlan.detail.find("shader") != std::string::npos, + "expected multi-layer plan to document two EGLImages and shader composition")) { + delete sceneGraphNode; + return 1; + } + const auto multiLayerReadiness = DeckVaapiEglImagePresenter::readinessReportForPlan(multiLayerMetadataPlan); + if (!require(multiLayerReadiness.state == DeckVaapiPresenterReadinessState::HardwarePresenterPlanned, + "expected accepted two-layer DRM_PRIME metadata to report hardware presenter planned")) { + delete sceneGraphNode; + return 1; + } + if (!require(multiLayerReadiness.statusCode == std::string("hardware-presenter-planned"), + "expected stable planned readiness status code for two-layer DRM_PRIME")) { + delete sceneGraphNode; + return 1; + } + DeckVaapiEglImagePresenter::Resource noContextPresenterResource; + const auto noContextImportPlan = DeckVaapiEglImagePresenter::importOpenGlTextureForCurrentContext( + multiLayerDrmPrimeDescriptor, + QSize(1280, 800), + noContextPresenterResource); + if (!require(noContextImportPlan.status == DeckQrhiVaapiImportStatus::MissingRenderContext, + "expected current-context live import smoke to fail closed with a distinct missing render context status")) { + delete sceneGraphNode; + return 1; + } + if (!require(noContextImportPlan.detail.find("No current EGL display/context") != std::string::npos, + "expected current-context live import failure to name the missing EGL context capability")) { + delete sceneGraphNode; + return 1; + } + multiLayerDrmPrimeDescriptor.layers[1].format = 0x34325258; // DRM_FORMAT_XRGB8888 + const auto unsupportedMultiLayerFormatPlan = DeckVaapiEglImagePresenter::validateDrmPrimeMetadata(multiLayerDrmPrimeDescriptor); + if (!require(unsupportedMultiLayerFormatPlan.status == DeckQrhiVaapiImportStatus::UnsupportedDrmPrimeFormat, + "expected non-Y/UV two-layer DRM_PRIME formats to fail closed with an explicit status")) { + delete sceneGraphNode; + return 1; + } + multiLayerDrmPrimeDescriptor.layerCount = 3; + multiLayerDrmPrimeDescriptor.layers[1].format = 0x38385247; // DRM_FORMAT_GR88 + multiLayerDrmPrimeDescriptor.layers[2] = multiLayerDrmPrimeDescriptor.layers[1]; + const auto unsupportedThreeLayerPlan = DeckVaapiEglImagePresenter::validateDrmPrimeMetadata(multiLayerDrmPrimeDescriptor); + if (!require(unsupportedThreeLayerPlan.status == DeckQrhiVaapiImportStatus::UnsupportedMultiLayerDrmPrimeImport, + "expected more-than-two-layer DRM_PRIME descriptors to fail closed without truncating layers")) { + delete sceneGraphNode; + return 1; + } + const std::array, 10> presenterFailureCodes{{ + {DeckQrhiVaapiImportStatus::UnsupportedNonOpenGlSceneGraph, "unsupported-non-opengl-scene-graph"}, + {DeckQrhiVaapiImportStatus::MissingEglDmabufExtensions, "missing-egl-dmabuf-extensions"}, + {DeckQrhiVaapiImportStatus::MissingRenderContext, "missing-render-context"}, + {DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata, "incomplete-drm-prime-metadata"}, + {DeckQrhiVaapiImportStatus::UnsupportedMultiLayerDrmPrimeImport, "unsupported-multilayer-drm-prime-import"}, + {DeckQrhiVaapiImportStatus::UnsupportedDrmPrimeFormat, "unsupported-drm-prime-format"}, + {DeckQrhiVaapiImportStatus::EglImageCreationFailed, "eglimage-creation-failed"}, + {DeckQrhiVaapiImportStatus::GlTextureBindFailed, "gl-texture-bind-failed"}, + {DeckQrhiVaapiImportStatus::EglImageShaderCompositionFailed, "eglimage-shader-composition-failed"}, + {DeckQrhiVaapiImportStatus::MissingFrameLease, "missing-frame-lease"}, + }}; + for (const auto& [status, statusCode] : presenterFailureCodes) { + const auto readiness = DeckVaapiEglImagePresenter::readinessReportForPlan(DeckQrhiVaapiImportPlan{ + .status = status, + .detail = statusCode, + }); + if (!require(readiness.statusCode == statusCode, "expected distinct presenter readiness failure status code")) { + delete sceneGraphNode; + return 1; + } + if (!require(!readiness.ready && !readiness.hardwarePresenterPlanned, "expected presenter failure not to report ready/planned")) { + delete sceneGraphNode; + return 1; + } + } + const auto plannedReadiness = DeckVaapiEglImagePresenter::readinessReportForPlan(DeckQrhiVaapiImportPlan{ + .status = DeckQrhiVaapiImportStatus::DrmPrimeExported, + .drmPrimeObjectCount = 1, + .drmPrimeLayerCount = 1, + .detail = "DRM_PRIME dmabuf metadata is complete for EGLImage import", + }); + if (!require(plannedReadiness.state == DeckVaapiPresenterReadinessState::HardwarePresenterPlanned, + "expected exported DRM_PRIME plan to report hardware presenter planning success")) { + delete sceneGraphNode; + return 1; + } + if (!require(plannedReadiness.statusCode == std::string("hardware-presenter-planned"), + "expected stable hardware presenter planned status code")) { + delete sceneGraphNode; + return 1; + } + if (!require(!plannedReadiness.ready && plannedReadiness.hardwarePresenterPlanned, + "expected planned presenter readiness to be planned but not texture-ready yet")) { + delete sceneGraphNode; + return 1; + } + DeckVaapiEglImagePresenter::Resource sourcePresenterResource; + sourcePresenterResource.glProgram = 42; + DeckVaapiEglImagePresenter::Resource readyPresenterResource; + readyPresenterResource.qtTexture = reinterpret_cast(0x1); + readyPresenterResource.eglImage = reinterpret_cast(0x2); + readyPresenterResource.glTexture = 7; + readyPresenterResource.shaderCompositionDetail = "test shader proof not attempted"; + const auto importedButUncomposedReadiness = DeckVaapiEglImagePresenter::readinessReportForResource(plannedReadiness.importPlan, readyPresenterResource); + if (!require(importedButUncomposedReadiness.state == DeckVaapiPresenterReadinessState::HardwarePresenterPlanned, + "expected imported EGLImage texture to stay planned until shader composition proof passes")) { + delete sceneGraphNode; + return 1; + } + if (!require(!importedButUncomposedReadiness.ready && importedButUncomposedReadiness.hardwarePresenterPlanned, + "expected imported but uncomposed presenter not to become texture-ready")) { + delete sceneGraphNode; + return 1; + } + if (!require(importedButUncomposedReadiness.detail.find("test shader proof not attempted") != std::string::npos, + "expected uncomposed readiness to preserve the exact shader composition failure detail")) { + delete sceneGraphNode; + return 1; + } + readyPresenterResource.shaderCompositionProved = true; + const auto readyReadiness = DeckVaapiEglImagePresenter::readinessReportForResource(plannedReadiness.importPlan, readyPresenterResource); + readyPresenterResource.qtTexture = nullptr; + readyPresenterResource.eglImage = nullptr; + readyPresenterResource.glTexture = 0; + if (!require(readyReadiness.state == DeckVaapiPresenterReadinessState::Ready, + "expected imported resource to report ready hardware presenter")) { + delete sceneGraphNode; + return 1; + } + if (!require(readyReadiness.statusCode == std::string("ready"), + "expected stable ready presenter status code")) { + delete sceneGraphNode; + return 1; + } + if (!require(readyReadiness.ready && readyReadiness.hardwarePresenterPlanned, + "expected ready presenter to be both ready and planned")) { + delete sceneGraphNode; + return 1; + } + DeckVaapiEglImagePresenter::Resource targetPresenterResource; + targetPresenterResource = std::move(sourcePresenterResource); + if (!require(sourcePresenterResource.glProgram == 0, + "expected moved-from EGLImage presenter resource to release GL program ownership")) { + targetPresenterResource.glProgram = 0; + delete sceneGraphNode; + return 1; + } + targetPresenterResource.glProgram = 0; + vaapiRenderNode->render(nullptr); + if (!require(vaapiRenderNode->lastImportPlan().status == DeckQrhiVaapiImportStatus::MissingRenderState, + "expected render node to retain last failed QRhi import plan")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastReadinessReport().state == DeckVaapiPresenterReadinessState::MissingRenderState, + "expected render node to convert the actual render failure into a readiness report")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastReadinessReport().statusCode == std::string("missing-render-state"), + "expected render node readiness report to expose a stable failure code")) { + delete sceneGraphNode; + return 1; + } + std::shared_ptr replacementFrameLease = makeFakeFrameLease(0x2b); + if (!require(replacementFrameLease != nullptr, "expected replacement VAAPI frame lease to clone")) { + delete sceneGraphNode; + return 1; + } + std::weak_ptr replacementFrameLeaseWeak = replacementFrameLease; + if (!require(qtQuickPresentationHandoff.presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = replacementFrameLease->surfaceId(), + .hardwareBacked = true, + .frameLease = replacementFrameLease, + .source = "qt-quick-rhi-item-test-replacement", + }), "expected handoff to accept a consecutive Qt Quick VAAPI frame")) { + delete sceneGraphNode; + return 1; + } + replacementFrameLease.reset(); + QSGNode* replacementSceneGraphNode = vaapiItem->updatePaintNode(sceneGraphNode, nullptr); + if (!require(replacementSceneGraphNode == sceneGraphNode, + "expected consecutive VAAPI frames to update the existing render node instead of replacing the QSG node")) { + delete replacementSceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->descriptor().surfaceId == 43, + "expected consecutive render node descriptor to switch to the replacement VAAPI surface")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->descriptor().source == std::string("qt-quick-rhi-item-test-replacement"), + "expected consecutive render node descriptor to preserve the replacement source")) { + delete sceneGraphNode; + return 1; + } + if (!require(itemFrameLeaseWeak.expired(), "expected render node replacement to release the prior frame lease immediately")) { + delete sceneGraphNode; + return 1; + } + if (!require(!replacementFrameLeaseWeak.expired(), "expected render node replacement to retain only the newest frame lease")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastImportPlan().status == DeckQrhiVaapiImportStatus::NotAttempted, + "expected render node replacement to reset stale import plan state before the next render")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastReadinessReport().state == DeckVaapiPresenterReadinessState::NotAttempted, + "expected render node replacement to reset stale readiness before the next render")) { + delete sceneGraphNode; + return 1; + } + vaapiRenderNode->render(nullptr); + if (!require(vaapiRenderNode->lastImportPlan().status == DeckQrhiVaapiImportStatus::MissingRenderState, + "expected replacement render to fail closed until the scenegraph supplies render state")) { + delete sceneGraphNode; + return 1; + } + if (!require(vaapiRenderNode->lastReadinessReport().statusCode == std::string("missing-render-state"), + "expected replacement render failure to keep readiness not-ready with an exact status")) { + delete sceneGraphNode; + return 1; + } + if (!require(!replacementFrameLeaseWeak.expired(), "expected render node to keep replacement frame lease alive")) { + delete sceneGraphNode; + return 1; + } + if (!require(!vaapiItem->presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = 0, + .hardwareBacked = false, + .source = "invalid-replacement", + }), "expected Qt Quick VAAPI item to reject an invalid replacement frame")) { + delete sceneGraphNode; + return 1; + } + sceneGraphNode = vaapiItem->updatePaintNode(sceneGraphNode, nullptr); + if (!require(sceneGraphNode == nullptr, + "expected invalid replacement to remove the stale render node instead of leaving old readiness visible")) { + delete sceneGraphNode; + return 1; + } + if (!require(replacementFrameLeaseWeak.expired(), "expected invalid replacement to release the stale replacement frame lease")) { + return 1; + } + + itemFrameLease = makeFakeFrameLease(0x2a); + if (!require(itemFrameLease != nullptr, "expected follow-up VAAPI frame lease to clone after invalid replacement")) { + return 1; + } + itemFrameLeaseWeak = itemFrameLease; + if (!require(qtQuickPresentationHandoff.presentVaapiSurface(DeckQrhiVaapiPresentationDescriptor{ + .width = 1280, + .height = 800, + .redrawRate = 60, + .surfaceId = itemFrameLease->surfaceId(), + .hardwareBacked = true, + .frameLease = itemFrameLease, + .source = "qt-quick-rhi-item-test-after-invalid", + }), "expected handoff to accept a new valid frame after invalid replacement reset")) { + return 1; + } + itemFrameLease.reset(); + sceneGraphNode = vaapiItem->updatePaintNode(nullptr, nullptr); + if (!require(sceneGraphNode != nullptr, "expected Qt Quick item to recreate a render node after invalid replacement reset")) { + return 1; + } + vaapiRenderNode = static_cast(sceneGraphNode); + if (!require(!itemFrameLeaseWeak.expired(), "expected recreated render node to retain the follow-up frame lease")) { + delete sceneGraphNode; + return 1; + } + vaapiRenderNode->releaseResources(); + if (!require(itemFrameLeaseWeak.expired(), "expected render node releaseResources to release frame lease")) { + delete sceneGraphNode; + return 1; + } + delete sceneGraphNode; DeckVaapiFfmpegRenderer renderer; - assert(renderer.adapterName() == "ffmpeg-vaapi-h264-qt-rhi-prototype"); + NOVA_TEST_REQUIRE(renderer.adapterName() == "ffmpeg-vaapi-h264-qt-rhi-prototype"); const int rendererSetup = renderer.setup(VIDEO_FORMAT_H264, 1280, 800, 60, nullptr, 0); - assert(rendererSetup == (renderer.lifecycle().runtimeVaapiDeviceAvailable ? DR_OK : DR_NEED_IDR)); + NOVA_TEST_REQUIRE(rendererSetup == (renderer.lifecycle().runtimeVaapiDeviceAvailable ? DR_OK : DR_NEED_IDR)); renderer.start(); - assert(renderer.submitDecodeUnit(nullptr) == DR_NEED_IDR); + NOVA_TEST_REQUIRE(renderer.submitDecodeUnit(nullptr) == DR_NEED_IDR); renderer.stop(); renderer.cleanup(); - assert(renderer.lifecycle().setupCalls == 1); - assert(renderer.lifecycle().startCalls == 1); - assert(renderer.lifecycle().submitCalls == 1); - assert(renderer.lifecycle().stopCalls == 1); - assert(renderer.lifecycle().cleanupCalls == 1); - assert(!renderer.lifecycle().acceptedNullDecodeUnit); - assert(renderer.lifecycle().networkStartAllowed == false); - assert(renderer.lifecycle().runtimeVaapiDeviceAvailable == mediaProbe.runtimeVaapiDeviceAvailable); - assert(!renderer.lifecycle().runtimeStatus.empty()); + NOVA_TEST_REQUIRE(renderer.lifecycle().setupCalls == 1); + NOVA_TEST_REQUIRE(renderer.lifecycle().startCalls == 1); + NOVA_TEST_REQUIRE(renderer.lifecycle().submitCalls == 1); + NOVA_TEST_REQUIRE(renderer.lifecycle().stopCalls == 1); + NOVA_TEST_REQUIRE(renderer.lifecycle().cleanupCalls == 1); + NOVA_TEST_REQUIRE(!renderer.lifecycle().acceptedNullDecodeUnit); + NOVA_TEST_REQUIRE(renderer.lifecycle().networkStartAllowed == false); + NOVA_TEST_REQUIRE(renderer.lifecycle().runtimeVaapiDeviceAvailable == mediaProbe.runtimeVaapiDeviceAvailable); + NOVA_TEST_REQUIRE(!renderer.lifecycle().runtimeStatus.empty()); DeckVaapiFfmpegRenderer decodeRenderer; + auto decodePresentationSink = std::make_shared(); + decodeRenderer.presentationHandoff().setSink(decodePresentationSink); const int decodeSetup = decodeRenderer.setup(VIDEO_FORMAT_H264, 128, 72, 1, nullptr, 0); auto idrBytes = makeLocalAnnexBH264IdrSample(); + NOVA_TEST_REQUIRE(!idrBytes.empty()); LENTRY idrEntry{}; DECODE_UNIT idrUnit = makeDecodeUnit(idrBytes, idrEntry); if (decodeRenderer.lifecycle().runtimeVaapiDeviceAvailable) { - assert(decodeSetup == DR_OK); - assert(decodeRenderer.lifecycle().ownsHardwareDevice); - assert(decodeRenderer.lifecycle().ownsCodecContext); - assert(decodeRenderer.submitDecodeUnit(&idrUnit) == DR_OK); - assert(decodeRenderer.lifecycle().decodedHardwareFrames == 1); - assert(decodeRenderer.lifecycle().lastFrameWasHardwareBacked); + NOVA_TEST_REQUIRE(decodeSetup == DR_OK); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().ownsHardwareDevice); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().ownsCodecContext); + NOVA_TEST_REQUIRE(decodeRenderer.submitDecodeUnit(&idrUnit) == DR_OK); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().decodedHardwareFrames == 1); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().lastFrameWasHardwareBacked); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().presentedHardwareFrames == 1); + NOVA_TEST_REQUIRE(decodePresentationSink->presentCalls == 1); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.width == 128); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.height == 72); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.redrawRate == 1); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.surfaceId != 0); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.hardwareBacked); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.frameLease != nullptr); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.frameLease->valid()); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.frameLease->surfaceId() == decodePresentationSink->lastDescriptor.surfaceId); + NOVA_TEST_REQUIRE(decodePresentationSink->lastDescriptor.source == std::string("ffmpeg-vaapi-h264")); + const auto realDrmPrimeExport = decodePresentationSink->lastDescriptor.frameLease->exportDrmPrimeDescriptor(); + NOVA_TEST_REQUIRE(realDrmPrimeExport.status == DeckQrhiVaapiImportStatus::DrmPrimeExported); + NOVA_TEST_REQUIRE(realDrmPrimeExport.objectCount > 0); + NOVA_TEST_REQUIRE(realDrmPrimeExport.layerCount > 0); + NOVA_TEST_REQUIRE(!realDrmPrimeExport.detail.empty()); + const auto realFrameReadiness = DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof(realDrmPrimeExport); + NOVA_TEST_REQUIRE(realFrameReadiness.state == DeckVaapiPresenterReadinessState::HardwareFrameReady); + NOVA_TEST_REQUIRE(realFrameReadiness.statusCode == std::string("hardware-frame-ready")); + NOVA_TEST_REQUIRE(realFrameReadiness.hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(!realFrameReadiness.ready); + NOVA_TEST_REQUIRE(realFrameReadiness.importPlan.drmPrimeObjectCount == realDrmPrimeExport.objectCount); + NOVA_TEST_REQUIRE(realFrameReadiness.importPlan.drmPrimeLayerCount == realDrmPrimeExport.layerCount); + const auto realFrameMissingTargetPlan = DeckVaapiEglImagePresenter::planOpenGlTextureImport( + nullptr, + realDrmPrimeExport, + QSize(decodePresentationSink->lastDescriptor.width, decodePresentationSink->lastDescriptor.height)); + const auto realFrameRenderTargetReadiness = DeckVaapiEglImagePresenter::readinessReportForDecodedFrameProof( + realDrmPrimeExport, + realFrameMissingTargetPlan); + if (realFrameMissingTargetPlan.status == DeckQrhiVaapiImportStatus::UnsupportedMultiLayerDrmPrimeImport) { + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.state == DeckVaapiPresenterReadinessState::UnsupportedMultiLayerDrmPrimeImport); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.statusCode == std::string("unsupported-multilayer-drm-prime-import")); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.detail.find("2-layer DRM_PRIME") != std::string::npos); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.detail.find("YUV") != std::string::npos); + } else if (realFrameMissingTargetPlan.status == DeckQrhiVaapiImportStatus::IncompleteDrmPrimeMetadata) { + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.state == DeckVaapiPresenterReadinessState::IncompleteDrmPrimeMetadata); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.statusCode == std::string("incomplete-drm-prime-metadata")); + } else { + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.state == DeckVaapiPresenterReadinessState::DeckTargetUnavailable); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.statusCode == std::string("deck-target-unavailable")); + } + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(!realFrameRenderTargetReadiness.ready); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.importPlan.drmPrimeObjectCount == realDrmPrimeExport.objectCount); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.importPlan.drmPrimeLayerCount == realDrmPrimeExport.layerCount); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.detail.find("Hardware-backed VAAPI frame decoded") != std::string::npos); + NOVA_TEST_REQUIRE(realFrameRenderTargetReadiness.detail.find("render-target readiness") != std::string::npos); + + DeckVaapiFfmpegRenderer productDecodeRenderer; + auto productDecodePipeline = std::make_shared(); + auto productDecodeSink = std::make_shared(); + productDecodePipeline->attachSink(productDecodeSink); + productDecodeRenderer.presentationHandoff().setSink(productDecodePipeline); + NOVA_TEST_REQUIRE(productDecodeRenderer.setup(VIDEO_FORMAT_H264, 128, 72, 1, nullptr, 0) == DR_OK); + LENTRY productIdrEntry{}; + DECODE_UNIT productIdrUnit = makeDecodeUnit(idrBytes, productIdrEntry); + NOVA_TEST_REQUIRE(productDecodeRenderer.submitDecodeUnit(&productIdrUnit) == DR_OK); + NOVA_TEST_REQUIRE(productDecodeRenderer.lifecycle().decodedHardwareFrames == 1); + NOVA_TEST_REQUIRE(productDecodeRenderer.lifecycle().presentedHardwareFrames == 1); + NOVA_TEST_REQUIRE(productDecodePipeline->queuedFrames() == 1); + NOVA_TEST_REQUIRE(productDecodePipeline->flushedFrames() == 1); + NOVA_TEST_REQUIRE(productDecodePipeline->presentedFrames() == 1); + NOVA_TEST_REQUIRE(productDecodePipeline->lastReadinessReport().statusCode == std::string("hardware-frame-ready")); + NOVA_TEST_REQUIRE(productDecodePipeline->lastReadinessReport().hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(!productDecodePipeline->lastReadinessReport().ready); + NOVA_TEST_REQUIRE(productDecodeSink->presentCalls == 1); + NOVA_TEST_REQUIRE(productDecodeSink->lastDescriptor.source == std::string("ffmpeg-vaapi-h264")); + NOVA_TEST_REQUIRE(productDecodeSink->lastDescriptor.frameLease != nullptr); + NOVA_TEST_REQUIRE(productDecodeSink->lastDescriptor.frameLease->valid()); + NOVA_TEST_REQUIRE(productDecodeSink->lastDescriptor.surfaceId != 0); + NOVA_TEST_REQUIRE(productDecodeSink->lastDescriptor.hardwareBacked); + NOVA_TEST_REQUIRE(productDecodePipeline->lastReadinessReport().detail.find("decoded hardware frame") != std::string::npos); + productDecodeRenderer.cleanup(); + + if (realDrmPrimeExport.layerCount == 2 && std::getenv("NOVA_DECK_REQUIRE_LIVE_EGL_COMPOSITION") != nullptr) { + ScopedLiveEglContext liveEglContext; + NOVA_TEST_REQUIRE(liveEglContext.valid()); + DeckVaapiEglImagePresenter::Resource livePresenterResource; + const auto liveImportPlan = DeckVaapiEglImagePresenter::importOpenGlTextureForCurrentContext( + realDrmPrimeExport, + QSize(decodePresentationSink->lastDescriptor.width, decodePresentationSink->lastDescriptor.height), + livePresenterResource); + NOVA_TEST_REQUIRE(liveImportPlan.status == DeckQrhiVaapiImportStatus::DrmPrimeExported); + const auto importedReadiness = DeckVaapiEglImagePresenter::readinessReportForResource(liveImportPlan, livePresenterResource); + NOVA_TEST_REQUIRE(importedReadiness.hardwarePresenterPlanned); + NOVA_TEST_REQUIRE(!importedReadiness.ready); + NOVA_TEST_REQUIRE(DeckVaapiEglImagePresenter::proveOpenGlShaderCompositionForCurrentContext( + livePresenterResource, + QSize(decodePresentationSink->lastDescriptor.width, decodePresentationSink->lastDescriptor.height))); + const auto liveReadyReadiness = DeckVaapiEglImagePresenter::readinessReportForResource(liveImportPlan, livePresenterResource); + NOVA_TEST_REQUIRE(liveImportPlan.drmPrimeLayerCount == 2); + NOVA_TEST_REQUIRE(liveReadyReadiness.ready); + NOVA_TEST_REQUIRE(liveReadyReadiness.statusCode == std::string("ready")); + NOVA_TEST_REQUIRE(liveReadyReadiness.detail.find("2-layer DRM_PRIME Y/UV") != std::string::npos); + std::cout << "Nova Deck live EGL two-layer composition " + << liveReadyReadiness.statusCode << ' ' + << "objects=" << liveReadyReadiness.importPlan.drmPrimeObjectCount << ' ' + << "layers=" << liveReadyReadiness.importPlan.drmPrimeLayerCount << ' ' + << liveEglContext.detail() << ' ' + << liveReadyReadiness.detail << '\n'; + } + std::cout << "Nova Deck VAAPI/EGL presenter readiness " + << realFrameReadiness.statusCode << ' ' + << "objects=" << realFrameReadiness.importPlan.drmPrimeObjectCount << ' ' + << "layers=" << realFrameReadiness.importPlan.drmPrimeLayerCount << ' ' + << realFrameReadiness.detail << '\n'; } else { - assert(decodeSetup == DR_NEED_IDR); - assert(!decodeRenderer.lifecycle().ownsHardwareDevice); - assert(!decodeRenderer.lifecycle().ownsCodecContext); - assert(decodeRenderer.submitDecodeUnit(&idrUnit) == DR_NEED_IDR); - assert(decodeRenderer.lifecycle().decodedHardwareFrames == 0); - assert(!decodeRenderer.lifecycle().lastRuntimeError.empty()); - assert(decodeRenderer.lifecycle().lastRuntimeError.find("av_hwdevice_ctx_create(VAAPI) failed") != std::string::npos); + NOVA_TEST_REQUIRE(decodeSetup == DR_NEED_IDR); + NOVA_TEST_REQUIRE(!decodeRenderer.lifecycle().ownsHardwareDevice); + NOVA_TEST_REQUIRE(!decodeRenderer.lifecycle().ownsCodecContext); + NOVA_TEST_REQUIRE(decodeRenderer.submitDecodeUnit(&idrUnit) == DR_NEED_IDR); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().decodedHardwareFrames == 0); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().presentedHardwareFrames == 0); + NOVA_TEST_REQUIRE(decodePresentationSink->presentCalls == 0); + NOVA_TEST_REQUIRE(!decodeRenderer.lifecycle().lastRuntimeError.empty()); + NOVA_TEST_REQUIRE(decodeRenderer.lifecycle().lastRuntimeError.find("av_hwdevice_ctx_create(VAAPI) failed") != std::string::npos); } decodeRenderer.cleanup(); const DeckLinuxAudioProbe audioProbe = DeckLinuxAudioProbe::detect(); - assert(audioProbe.pipeWireHeadersLinked); - assert(audioProbe.pulseFallbackHeadersLinked); + NOVA_TEST_REQUIRE(audioProbe.pipeWireHeadersLinked); + NOVA_TEST_REQUIRE(audioProbe.pulseFallbackHeadersLinked); OPUS_MULTISTREAM_CONFIGURATION opusConfig{}; opusConfig.samplesPerFrame = 240; DeckPipeWireAudio audio; - assert(audio.adapterName() == "pipewire-pcm-pulse-fallback-prototype"); - assert(audio.init(AUDIO_CONFIGURATION_STEREO, &opusConfig, nullptr, 0) == 0); + NOVA_TEST_REQUIRE(audio.adapterName() == "pipewire-pcm-pulse-fallback-prototype"); + NOVA_TEST_REQUIRE(audio.init(AUDIO_CONFIGURATION_STEREO, &opusConfig, nullptr, 0) == 0); audio.start(); char pcm[] = {'p', 'c', 'm', '!'}; audio.decodeAndPlaySample(pcm, 4); audio.stop(); audio.cleanup(); - assert(audio.lifecycle().initCalls == 1); - assert(audio.lifecycle().startCalls == 1); - assert(audio.lifecycle().sampleCalls == 1); - assert(audio.lifecycle().lastSampleLength == 4); - assert(audio.lifecycle().samplesPerFrame == 240); - assert(audio.lifecycle().stopCalls == 1); - assert(audio.lifecycle().cleanupCalls == 1); - assert(audio.lifecycle().networkStartAllowed == false); + NOVA_TEST_REQUIRE(audio.lifecycle().initCalls == 1); + NOVA_TEST_REQUIRE(audio.lifecycle().startCalls == 1); + NOVA_TEST_REQUIRE(audio.lifecycle().sampleCalls == 1); + NOVA_TEST_REQUIRE(audio.lifecycle().lastSampleLength == 4); + NOVA_TEST_REQUIRE(audio.lifecycle().samplesPerFrame == 240); + NOVA_TEST_REQUIRE(audio.lifecycle().stopCalls == 1); + NOVA_TEST_REQUIRE(audio.lifecycle().cleanupCalls == 1); + NOVA_TEST_REQUIRE(audio.lifecycle().networkStartAllowed == false); DeckVaapiFfmpegRenderer callbackRenderer; DeckPipeWireAudio callbackAudio; NoopInput input; RecordingEvents events; DeckStreamSession session(callbackRenderer, callbackAudio, input, events); - assert(!session.moonlightBoundary().networkStartAllowed); + NOVA_TEST_REQUIRE(!session.moonlightBoundary().networkStartAllowed); const auto prepared = session.prepare(DeckStreamRequest{ .hostId = "offline-harness-host", .gameId = "offline-harness-game", @@ -210,20 +1352,20 @@ int main() { .fps = 60, .bitrateKbps = 20000, }); - assert(prepared.state == DeckStreamSessionState::Preparing); - assert(!prepared.networkStarted); + NOVA_TEST_REQUIRE(prepared.state == DeckStreamSessionState::Preparing); + NOVA_TEST_REQUIRE(!prepared.networkStarted); const int callbackRendererSetup = session.moonlightBoundary().videoCallbacks->setup(VIDEO_FORMAT_H264, 1280, 800, 60, session.moonlightBoundary().callbackContext, 0); - assert(callbackRendererSetup == (callbackRenderer.lifecycle().runtimeVaapiDeviceAvailable ? DR_OK : DR_NEED_IDR)); - assert(session.moonlightBoundary().videoCallbacks->submitDecodeUnit(nullptr) == DR_NEED_IDR); + NOVA_TEST_REQUIRE(callbackRendererSetup == (callbackRenderer.lifecycle().runtimeVaapiDeviceAvailable ? DR_OK : DR_NEED_IDR)); + NOVA_TEST_REQUIRE(session.moonlightBoundary().videoCallbacks->submitDecodeUnit(nullptr) == DR_NEED_IDR); OPUS_MULTISTREAM_CONFIGURATION callbackOpusConfig{}; callbackOpusConfig.samplesPerFrame = 240; - assert(session.moonlightBoundary().audioCallbacks->init(AUDIO_CONFIGURATION_STEREO, &callbackOpusConfig, session.moonlightBoundary().callbackContext, 0) == 0); - assert(callbackRenderer.lifecycle().setupCalls == 1); - assert(callbackRenderer.lifecycle().submitCalls == 1); - assert(callbackAudio.lifecycle().initCalls == 1); + NOVA_TEST_REQUIRE(session.moonlightBoundary().audioCallbacks->init(AUDIO_CONFIGURATION_STEREO, &callbackOpusConfig, session.moonlightBoundary().callbackContext, 0) == 0); + NOVA_TEST_REQUIRE(callbackRenderer.lifecycle().setupCalls == 1); + NOVA_TEST_REQUIRE(callbackRenderer.lifecycle().submitCalls == 1); + NOVA_TEST_REQUIRE(callbackAudio.lifecycle().initCalls == 1); const auto started = session.startNoNetwork(); - assert(started.state == DeckStreamSessionState::Active); - assert(!started.networkStarted); + NOVA_TEST_REQUIRE(started.state == DeckStreamSessionState::Active); + NOVA_TEST_REQUIRE(!started.networkStarted); session.stop(); return 0; diff --git a/clients/deck/tests/deck_t31_podman_validation_test.py b/clients/deck/tests/deck_t31_podman_validation_test.py new file mode 100644 index 00000000..c8fcff5f --- /dev/null +++ b/clients/deck/tests/deck_t31_podman_validation_test.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import contextlib +import io +import pathlib +import sys +import unittest + +sys.dont_write_bytecode = True + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import deck_t31_podman_validation as route + + +class DeckT31PodmanValidationRouteTest(unittest.TestCase): + def test_podman_command_mounts_gamescope_runtime_dri_and_forces_deck_render_environment(self): + command = route.build_podman_validation_command( + source_dir=pathlib.PurePosixPath("/home/deck/nova-t31-src"), + artifact_dir=pathlib.PurePosixPath("/home/deck/nova-t31-src/build/deck-t31-artifacts"), + ) + joined = " ".join(command) + + self.assertEqual(command[:3], ["podman", "run", "--rm"]) + self.assertIn("localhost/nova-t24-arch-qt-buildtools", command) + self.assertIn("/run/user/1000:/run/user/1000", joined) + self.assertIn("/dev/dri:/dev/dri", joined) + self.assertIn("QT_QPA_PLATFORM=wayland", joined) + self.assertIn("WAYLAND_DISPLAY=gamescope-0", joined) + self.assertIn("QSG_RHI_BACKEND=opengl", joined) + self.assertIn("LIBVA_DRIVER_NAME=radeonsi", joined) + self.assertIn("ctest --test-dir build/deck-t31", joined) + self.assertIn("nova_deck_qsg_render_node_scenegraph_smoke", joined) + self.assertIn("(cp -f build/deck-t31/Testing/Temporary/LastTest.log", joined) + self.assertNotIn("LastTest.log || true && QT_QPA_PLATFORM=wayland", joined) + self.assertIn("build/deck-t31-artifacts", joined) + + def test_route_runs_t32_preview_pump_oracle_after_artifact_pull(self): + command = route.build_oracle_command(pathlib.Path("/repo/build/deck-t32-artifacts")) + + self.assertEqual(command[0], sys.executable) + self.assertIn("deck_t32_preview_pump_oracle.py", command[1]) + self.assertEqual(command[-2:], ["--artifacts", "/repo/build/deck-t32-artifacts"]) + + def test_guardrails_reject_streaming_host_launch_and_sensitive_routes(self): + forbidden = route.find_forbidden_route_tokens() + self.assertEqual(forbidden, []) + + def test_source_sync_excludes_local_build_git_and_secret_shaped_files(self): + command = route.build_rsync_command( + pathlib.Path("/repo"), + "deck@10.0.0.39", + pathlib.PurePosixPath("/home/deck/nova-t31-src"), + ) + joined = " ".join(command) + + for excluded in ["/.git/", "/build/", "/.gradle/", "/local.properties", ".env*", "id_*", "*.pem"]: + self.assertIn(excluded, joined) + for root_only_secret_pattern in ["/.env*", "/id_*", "/*.pem"]: + self.assertNotIn(root_only_secret_pattern, command) + + def test_dry_run_prints_the_machine_checkable_oracle_by_default(self): + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = route.main([ + "--dry-run", + "--skip-sync", + "--local-artifacts", + "/repo/build/deck-t32-artifacts", + ]) + + self.assertEqual(exit_code, 0) + self.assertIn("ORACLE:", stdout.getvalue()) + self.assertIn("deck_t32_preview_pump_oracle.py", stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/clients/deck/tests/deck_t32_preview_pump_oracle_test.py b/clients/deck/tests/deck_t32_preview_pump_oracle_test.py new file mode 100644 index 00000000..8797643a --- /dev/null +++ b/clients/deck/tests/deck_t32_preview_pump_oracle_test.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import pathlib +import tempfile +import unittest + +import sys + +sys.dont_write_bytecode = True + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import deck_t32_preview_pump_oracle as oracle + + +CTEST_LOG = """Test project /home/deck/nova-t31-src/build/deck-t31 + Start 3: nova_deck_stream_media_adapters_test +3/8 Test #3: nova_deck_stream_media_adapters_test ......... Passed 0.32 sec + Start 4: nova_deck_qsg_render_node_scenegraph_smoke +4/8 Test #4: nova_deck_qsg_render_node_scenegraph_smoke ... Passed 0.20 sec + +100% tests passed, 0 tests failed out of 8 +""" + +QSG_LOG = """Nova Deck QSGRenderNode VAAPI/EGL render path status=ready objects=1 layers=2 ready=1 planned=1 readiness stayed false until shader composition proof Hardware-backed VAAPI frame decoded and exported as DRM_PRIME dmabuf metadata; Qt Quick render-target readiness is proven +Nova Deck QSGRenderNode scenegraph smoke passed: product render-node path entered 2 consecutive render passes; imported two DRM_PRIME layers, proved shader composition, then reported ready +""" + +READY_LAST_TEST_LOG = """4/4 Testing: nova_deck_qsg_render_node_scenegraph_smoke +Output: +---------------------------------------------------------- +Nova Deck QSGRenderNode VAAPI/EGL render path status=ready objects=1 layers=2 ready=1 planned=1 readiness stayed false until shader composition proof Hardware-backed VAAPI frame decoded and exported as DRM_PRIME dmabuf metadata; Qt Quick render-target readiness is proven +Nova Deck QSGRenderNode scenegraph smoke passed: product render-node path entered 2 consecutive render passes; imported two DRM_PRIME layers, proved shader composition, then reported ready + +""" + +UNSUPPORTED_QSG_LOG = QSG_LOG.replace("status=ready", "status=unsupported-non-opengl-scene-graph").replace( + "ready=1", "ready=0" +) + + +class DeckT32PreviewPumpOracleTest(unittest.TestCase): + def write_artifacts(self, directory: pathlib.Path, *, qsg_log: str = QSG_LOG) -> None: + (directory / "ctest.log").write_text(CTEST_LOG, encoding="utf-8") + (directory / "qsg-gamescope-smoke.log").write_text(qsg_log, encoding="utf-8") + (directory / "LastTest.log").write_text(READY_LAST_TEST_LOG, encoding="utf-8") + + def test_oracle_accepts_deck_ctest_preview_pump_and_gamescope_ready_artifacts(self): + with tempfile.TemporaryDirectory() as temp: + artifact_dir = pathlib.Path(temp) + self.write_artifacts(artifact_dir) + + results = oracle.validate_oracle(artifact_dir=artifact_dir, deck_root=ROOT) + + self.assertIn("preview pump source guard PASS", results) + self.assertIn("Deck artifacts PASS", results) + self.assertIn("gamescope QSG ready proof PASS", results) + self.assertIn("route guardrails PASS", results) + + def test_oracle_rejects_headless_or_unsupported_qsg_artifacts(self): + with tempfile.TemporaryDirectory() as temp: + artifact_dir = pathlib.Path(temp) + self.write_artifacts(artifact_dir, qsg_log=UNSUPPORTED_QSG_LOG) + + with self.assertRaisesRegex(oracle.OracleFailure, "gamescope QSG ready render proof"): + oracle.validate_oracle(artifact_dir=artifact_dir, deck_root=ROOT) + + def test_oracle_rejects_missing_preview_pump_ctest_pass(self): + with tempfile.TemporaryDirectory() as temp: + artifact_dir = pathlib.Path(temp) + self.write_artifacts(artifact_dir) + (artifact_dir / "ctest.log").write_text(CTEST_LOG.replace("nova_deck_stream_media_adapters_test", "missing"), encoding="utf-8") + + with self.assertRaisesRegex(oracle.OracleFailure, "Deck CTest preview pump binary"): + oracle.validate_oracle(artifact_dir=artifact_dir, deck_root=ROOT) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/deck-product-readiness-checklist.md b/docs/deck-product-readiness-checklist.md new file mode 100644 index 00000000..a4806a82 --- /dev/null +++ b/docs/deck-product-readiness-checklist.md @@ -0,0 +1,43 @@ +# Deck product-readiness checklist + +This checklist promotes the Deck diagnostics lane into a reusable product-readiness gate instead of another round of smoke-only cosmetics. Use it for every future diagnostics/read-only DTO card before claiming the shell is product-ready. + +## Gate: deck-diagnostics-expanded-lane-v1 + +Status: active for Deck diagnostics lane work. + +Pass criteria: + +- collapsed first paint: secondary diagnostics stay collapsed until the operator expands them. +- page-1 cue: the first expanded diagnostics page shows the page-position affordance before long blocker copy. +- controller accessibility: the diagnostics toggle and expanded lane are reachable with controller/D-pad focus. +- focus affordance: expanded diagnostics preserve the 4px focus ring and active focus badge. +- D-pad scroll to page 2: the frontend smoke must prove controller/key navigation moves to lifecycle + DTO details. +- right-rail breathing room: collapsed readiness copy stays player-facing, and the expanded diagnostics lane keeps extra vertical room instead of cramming every backend/status token into first paint. +- page-2 cue contrast/readability: the page-2 cue stays readable at the recorded 13.56:1 contrast and does not overlap blocker copy. +- lifecycle idle/no stream: lifecycle detail remains idle/no stream while the smoke route is offline. +- sanitized DTO detail: diagnostics expose redacted-public-dto only. +- backend-fed DTO parity: collapsed summary, expanded diagnostics, and smoke artifacts expose the same backend-owned read-only DTO contract (`backend-owned-read-only-dto-v1`) with `dto-parity-ready` readiness, never raw backend fields. +- DTO-owned player state: title, body, action, safety, provenance, and focus-order copy come from the sanitized read-only DTO (`dto-player-state/backend-owned/redacted-public`) instead of QML fixture/debug branches. +- sanitized artifacts: frontend smoke artifacts contain no private addresses, PEM blocks, or raw* shaped fields. +- backendPowerStarted=false: all read-only matrix states must preserve backendPowerStarted=false. +- stream=false: dry-run/preview state must keep stream/network start disallowed. +- product state matrix: empty, offline, unpaired, library-unavailable, and lab-gated states must render as player-facing product states with a visible next action and safety reassurance before diagnostics. +- focus order: controller focus must make the product state card reachable before Copy plan and the secondary diagnostics toggle. + +## Evidence required before pass + +- `clients/deck/tests/deck_frontend_smoke_test.py` asserts the reusable gate appears in smoke summary output. +- `clients/deck/scripts/deck_frontend_smoke.py` writes `product_readiness_gate=deck-diagnostics-expanded-lane-v1`, `product_readiness_verdict=pass`, and `product_readiness_next=backend-fed-read-only-dto-parity` only after the existing expanded diagnostics assertions pass. +- `clients/deck/tests/deck_layout_test.cpp` keeps the QML source contract observable for collapsed first paint, page-position affordance, focus affordance, D-pad lane focus, page-2 copy, lifecycle/DTO labels, and privacy copy. +- `clients/deck/tests/deck_backend_interfaces_test.cpp` asserts every read-only matrix state carries product-safe DTO player-state fields plus redacted provenance/focus order, and that the backend read-only state provider owns matrix assembly/default player-state repair before Qt/QML consumption. +- `clients/deck/tests/deck_media_assert_guard_test.py` keeps raw backend/start symbols out of UI and stream-core surfaces. +- Real Deck gamescope smoke, when the Deck is reachable, must copy the artifact directory back under `build/deck-frontend-smoke-artifacts` and retain non-empty captures/smoke text. + +## Non-goals for this gate + +This gate must not add host scanning, pairing flows, secret storage access, app launch, media start, controller packets into an active session, real stream lab work, or raw start calls. If future backend cards need those capabilities, they need their own reviewed backend gate first. Tiny copy/QML changes are allowed only when they make the existing contract observable. + +## Next-card direction + +The next single-card recommendation is review-and-accept M28 evidence after this backend-fed read-only DTO parity slice, then consider a separate explicitly approved backend-fed data source spike that still does not start real streaming. Stop polishing diagnostics cosmetics unless a criterion above becomes unobservable or fails. From 09c943a7a68d0e47510b878e178f29b12e3743fd Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:12:20 -0400 Subject: [PATCH 2/2] fix(deck): redact product-readiness smoke paths --- clients/deck/README.md | 2 +- clients/deck/scripts/deck_frontend_smoke.py | 6 +++--- .../deck/scripts/deck_t31_podman_validation.py | 8 ++++---- .../deck-t14-vaapi-presentation-strategy.md | 4 ++-- clients/deck/tests/deck_frontend_smoke_test.py | 16 ++++++++-------- .../tests/deck_t31_podman_validation_test.py | 8 ++++---- .../tests/deck_t32_preview_pump_oracle_test.py | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/clients/deck/README.md b/clients/deck/README.md index 95fcd8c0..537ac0db 100644 --- a/clients/deck/README.md +++ b/clients/deck/README.md @@ -42,7 +42,7 @@ Steam Deck Game Mode rootless Podman validation route, for preview/QSG render ca python3 clients/deck/scripts/deck_t31_podman_validation.py -The script syncs the current source tree to `deck@10.0.0.39:/home/deck/nova-t31-src`, runs `localhost/nova-t24-arch-qt-buildtools` with `/run/user/1000` and `/dev/dri` mounted, builds `clients/deck` with CMake/Ninja, runs Deck CTest, runs `nova_deck_qsg_render_node_scenegraph_smoke` directly with `QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0 QSG_RHI_BACKEND=opengl LIBVA_DRIVER_NAME=radeonsi` so the CTest offscreen property cannot mask the live gamescope route, and pulls logs into `build/deck-t31-artifacts`. Use `--dry-run` to print the exact sync/container/artifact commands, or `--skip-sync` when the Deck source directory is already prepared. +The script syncs the current source tree to `deck@:/var/tmp/nova-t31-src`, runs `localhost/nova-t24-arch-qt-buildtools` with `/run/user/1000` and `/dev/dri` mounted, builds `clients/deck` with CMake/Ninja, runs Deck CTest, runs `nova_deck_qsg_render_node_scenegraph_smoke` directly with `QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=gamescope-0 QSG_RHI_BACKEND=opengl LIBVA_DRIVER_NAME=radeonsi` so the CTest offscreen property cannot mask the live gamescope route, and pulls logs into `build/deck-t31-artifacts`. Use `--dry-run` to print the exact sync/container/artifact commands, or `--skip-sync` when the Deck source directory is already prepared. The route now runs the T32 preview pump oracle after pulling artifacts, so the same command exits non-zero unless the Deck artifacts machine-prove all of the following: `nova_deck_stream_media_adapters_test` covered newest-frame coalescing and invalid-reset stale-presentation clearing, full remote CTest passed, `qsg-gamescope-smoke.log` contains a real Deck render proof with `status=ready objects=1 layers=2 ready=1`, and the route source still avoids host streaming, discovery, pairing, credential, and Polaris launch paths. To check already-pulled artifacts directly, run: diff --git a/clients/deck/scripts/deck_frontend_smoke.py b/clients/deck/scripts/deck_frontend_smoke.py index 9f5a4a01..cf544972 100644 --- a/clients/deck/scripts/deck_frontend_smoke.py +++ b/clients/deck/scripts/deck_frontend_smoke.py @@ -18,8 +18,8 @@ from typing import Iterable, Sequence DEFAULT_DECK = "deck@" + "10.0." + "0.39" -DEFAULT_REMOTE_SOURCE = PurePosixPath("/home/deck/nova-frontend-smoke-src") -DEFAULT_ARTIFACT_DIR = PurePosixPath("/home/deck/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts") +DEFAULT_REMOTE_SOURCE = PurePosixPath("/var/tmp/nova-frontend-smoke-src") +DEFAULT_ARTIFACT_DIR = PurePosixPath("/var/tmp/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts") DEFAULT_IMAGE = "localhost/nova-t24-arch-qt-buildtools" DEFAULT_BUILD_DIR = "build/deck-frontend-smoke" @@ -105,7 +105,7 @@ def validate_local_artifact_dir(local_artifacts: Path) -> None: resolved = local_artifacts.expanduser().resolve() forbidden_roots = {Path('/'), Path.home().resolve(), REPO_ROOT.resolve()} if resolved in forbidden_roots: - raise ValueError("local artifact directory refuses broad root/home/repo cleanup") + raise ValueError("local artifact directory refuses broad system/source cleanup") safe_root = REPO_ROOT.resolve() / "build" try: resolved.relative_to(safe_root) diff --git a/clients/deck/scripts/deck_t31_podman_validation.py b/clients/deck/scripts/deck_t31_podman_validation.py index 70aa92d1..b21d857c 100644 --- a/clients/deck/scripts/deck_t31_podman_validation.py +++ b/clients/deck/scripts/deck_t31_podman_validation.py @@ -17,9 +17,9 @@ from pathlib import Path, PurePosixPath from typing import Iterable, Sequence -DEFAULT_DECK = "deck@10.0.0.39" -DEFAULT_REMOTE_SOURCE = PurePosixPath("/home/deck/nova-t31-src") -DEFAULT_ARTIFACT_DIR = PurePosixPath("/home/deck/nova-t31-src/build/deck-t31-artifacts") +DEFAULT_DECK = "deck@" +DEFAULT_REMOTE_SOURCE = PurePosixPath("/var/tmp/nova-t31-src") +DEFAULT_ARTIFACT_DIR = PurePosixPath("/var/tmp/nova-t31-src/build/deck-t31-artifacts") DEFAULT_IMAGE = "localhost/nova-t24-arch-qt-buildtools" DEFAULT_BUILD_DIR = "build/deck-t31" DEFAULT_ORACLE = "deck_t32_preview_pump_oracle.py" @@ -187,7 +187,7 @@ def run_command(command: Sequence[str], *, log_path: Path | None = None) -> None def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--deck", default=DEFAULT_DECK, help="SSH target, default deck@10.0.0.39") + parser.add_argument("--deck", default=DEFAULT_DECK, help="SSH target, default deck@") parser.add_argument("--source", type=Path, default=REPO_ROOT, help="local repo/source root to sync") parser.add_argument("--remote-source", default=str(DEFAULT_REMOTE_SOURCE), help="Deck source directory") parser.add_argument("--remote-artifacts", default=str(DEFAULT_ARTIFACT_DIR), help="Deck artifact directory") diff --git a/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md b/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md index c2d2498b..26b57edd 100644 --- a/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md +++ b/clients/deck/spikes/deck-t14-vaapi-presentation-strategy.md @@ -23,7 +23,7 @@ This is the safest next step because it stays on public Qt Quick API for the sce ### Local Qt/API facts -Commands run from `/home/papi/Documents/github/nova`: +Commands run from ``: ```text pkg-config --modversion Qt6Quick Qt6Gui Qt6Multimedia @@ -93,7 +93,7 @@ Result: 5/5 tests passed locally: ### Steam Deck probe attempt ```text -ssh -o BatchMode=yes -o ConnectTimeout=8 deck@10.0.0.39 '...' +ssh -o BatchMode=yes -o ConnectTimeout=8 deck@ '...' => ssh: connect to host 10.0.0.39 port 22: Connection timed out ``` diff --git a/clients/deck/tests/deck_frontend_smoke_test.py b/clients/deck/tests/deck_frontend_smoke_test.py index cb962d5f..aeb720c6 100644 --- a/clients/deck/tests/deck_frontend_smoke_test.py +++ b/clients/deck/tests/deck_frontend_smoke_test.py @@ -19,8 +19,8 @@ class DeckFrontendSmokeRouteTest(unittest.TestCase): def test_podman_command_runs_visible_wayland_shell_offline_and_writes_frontend_artifacts(self): command = smoke.build_podman_smoke_command( - source_dir=pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src"), - artifact_dir=pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts"), + source_dir=pathlib.PurePosixPath("/var/tmp/nova-frontend-smoke-src"), + artifact_dir=pathlib.PurePosixPath("/var/tmp/nova-frontend-smoke-src/build/deck-frontend-smoke-artifacts"), ) joined = " ".join(command) @@ -239,7 +239,7 @@ def test_main_checks_android_app_untouched_before_sync_and_deck_smoke(self): def test_remote_artifact_cleanup_refuses_source_dir_and_paths_outside_source(self): unsafe_paths = ( pathlib.PurePosixPath("/"), - pathlib.PurePosixPath("/home/deck"), + pathlib.PurePosixPath("/var/tmp/deck-home"), pathlib.PurePosixPath("/src"), pathlib.PurePosixPath("/src/deck-frontend-smoke-artifacts"), pathlib.PurePosixPath("/tmp/deck-frontend-smoke-artifacts"), @@ -349,8 +349,8 @@ def test_diagnostics_product_readiness_checklist_captures_reusable_gate(self): def test_source_sync_excludes_build_git_and_secret_shaped_files(self): command = smoke.build_rsync_command( pathlib.Path("/repo"), - "deck@10.0.0.39", - pathlib.PurePosixPath("/home/deck/nova-frontend-smoke-src"), + "deck@", + pathlib.PurePosixPath("/var/tmp/nova-frontend-smoke-src"), ) joined = " ".join(command) @@ -359,8 +359,8 @@ def test_source_sync_excludes_build_git_and_secret_shaped_files(self): def test_command_log_redacts_private_deck_addresses(self): self.assertEqual( - smoke.redact_private_addresses("rsync deck@10.0.0.39:/home/deck/source"), - "rsync deck@:/home/deck/source", + smoke.redact_private_addresses("rsync deck@" + "10.0." + "0.39:/var/tmp/source"), + "rsync deck@:/var/tmp/source", ) self.assertEqual( smoke.redact_private_addresses("ssh: Could not resolve hostname steamdeck.local"), @@ -390,7 +390,7 @@ def test_run_command_redacts_failed_subprocess_stderr_in_logs_and_exceptions(sel sys.executable, "-c", "import sys; print('rsync: steamdeck.local failed from 10.0.0.39 via 192.168.1.77', file=sys.stderr); sys.exit(23)", - "deck@10.0.0.39:/remote/artifacts", + "deck@:/remote/artifacts", "steamdeck.local:/remote/artifacts", ], log_path=log_path, diff --git a/clients/deck/tests/deck_t31_podman_validation_test.py b/clients/deck/tests/deck_t31_podman_validation_test.py index c8fcff5f..3eab59de 100644 --- a/clients/deck/tests/deck_t31_podman_validation_test.py +++ b/clients/deck/tests/deck_t31_podman_validation_test.py @@ -16,8 +16,8 @@ class DeckT31PodmanValidationRouteTest(unittest.TestCase): def test_podman_command_mounts_gamescope_runtime_dri_and_forces_deck_render_environment(self): command = route.build_podman_validation_command( - source_dir=pathlib.PurePosixPath("/home/deck/nova-t31-src"), - artifact_dir=pathlib.PurePosixPath("/home/deck/nova-t31-src/build/deck-t31-artifacts"), + source_dir=pathlib.PurePosixPath("/var/tmp/nova-t31-src"), + artifact_dir=pathlib.PurePosixPath("/var/tmp/nova-t31-src/build/deck-t31-artifacts"), ) joined = " ".join(command) @@ -49,8 +49,8 @@ def test_guardrails_reject_streaming_host_launch_and_sensitive_routes(self): def test_source_sync_excludes_local_build_git_and_secret_shaped_files(self): command = route.build_rsync_command( pathlib.Path("/repo"), - "deck@10.0.0.39", - pathlib.PurePosixPath("/home/deck/nova-t31-src"), + "deck@", + pathlib.PurePosixPath("/var/tmp/nova-t31-src"), ) joined = " ".join(command) diff --git a/clients/deck/tests/deck_t32_preview_pump_oracle_test.py b/clients/deck/tests/deck_t32_preview_pump_oracle_test.py index 8797643a..bae03d24 100644 --- a/clients/deck/tests/deck_t32_preview_pump_oracle_test.py +++ b/clients/deck/tests/deck_t32_preview_pump_oracle_test.py @@ -13,7 +13,7 @@ import deck_t32_preview_pump_oracle as oracle -CTEST_LOG = """Test project /home/deck/nova-t31-src/build/deck-t31 +CTEST_LOG = """Test project /var/tmp/nova-t31-src/build/deck-t31 Start 3: nova_deck_stream_media_adapters_test 3/8 Test #3: nova_deck_stream_media_adapters_test ......... Passed 0.32 sec Start 4: nova_deck_qsg_render_node_scenegraph_smoke