diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt new file mode 100644 index 00000000..58a7f9c5 --- /dev/null +++ b/clients/deck/CMakeLists.txt @@ -0,0 +1,79 @@ +cmake_minimum_required(VERSION 3.24) + +project(NovaDeck LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(nova_deck_core + src/deck_gamepad.cpp + src/deck_layout.cpp + src/polaris_game_fixture.cpp +) +target_include_directories(nova_deck_core + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/src +) +target_compile_definitions(nova_deck_core + PUBLIC + NOVA_DECK_SAMPLE_GAME_FIXTURE=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures/sample_polaris_game.json\" + NOVA_DECK_SAMPLE_LIBRARY_FIXTURE=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures/sample_polaris_library.json\" +) + +include(CTest) +if(BUILD_TESTING) + add_executable(nova_deck_layout_test + tests/deck_layout_test.cpp + ) + target_link_libraries(nova_deck_layout_test PRIVATE nova_deck_core) + target_compile_definitions(nova_deck_layout_test + PRIVATE + NOVA_DECK_MAIN_QML_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/qml/Main.qml" + ) + add_test(NAME nova_deck_controller_library_smoke COMMAND nova_deck_layout_test) +endif() + +option(NOVA_DECK_BUILD_QT_SHELL "Build the experimental Qt/QML Steam Deck shell" ON) + +if(NOVA_DECK_BUILD_QT_SHELL) + find_package(Qt6 QUIET COMPONENTS Core Gui Qml Quick QuickControls2) + + if(Qt6_FOUND) + qt_standard_project_setup(REQUIRES 6.5) + if(COMMAND qt_policy AND Qt6_VERSION VERSION_GREATER_EQUAL 6.8) + qt_policy(SET QTP0004 NEW) + endif() + + qt_add_executable(nova-deck + src/main.cpp + ) + + qt_add_qml_module(nova-deck + URI Nova.Deck + VERSION 0.1 + QML_FILES + qml/Main.qml + ) + + target_link_libraries(nova-deck + PRIVATE + nova_deck_core + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + ) + + if(BUILD_TESTING) + add_test(NAME nova_deck_qt_shell_smoke COMMAND nova-deck --smoke-exit) + set_tests_properties(nova_deck_qt_shell_smoke PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + TIMEOUT 10 + ) + endif() + else() + message(WARNING "Qt6 Quick/QuickControls2 not found; building nova_deck_core and tests only. On Fedora install cmake gcc-c++ qt6-qtbase-devel qt6-qtdeclarative-devel; qt6-qtdeclarative-devel provides cmake(Qt6QuickControls2).") + endif() +endif() diff --git a/clients/deck/README.md b/clients/deck/README.md index 8f67aa5a..773a81d6 100644 --- a/clients/deck/README.md +++ b/clients/deck/README.md @@ -1,8 +1,19 @@ # Nova Deck Client -This directory is reserved for the native Steam Deck client. +This directory is the first native Steam Deck client slice for Nova. It is intentionally a scaffold, not the streamer port. -Current status: scaffold only. +Current status: + +- CMake builds a small native core library on Linux/SteamOS-capable development hosts. +- Qt 6/QML shell builds when Qt Quick and QuickControls2 development packages are installed. +- Fallback build path keeps the core/controller/library smoke runnable without Qt. +- The shell consumes a generated sample Polaris game fixture shaped after shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt. + +## Current preview smoke scope + +This slice is a preview-only Deck smoke shell. It validates the native window, 1280x800 controller-first layout, fake host list states, an inert launch preview, local clipboard copy feedback, and Steam Input primary-action routing for the copy-preview flow. + +It intentionally does **not** validate or perform backend launch, Moonlight streaming, host discovery, pairing, HostStore persistence, network calls, shell/process execution, or real game launch behavior. Keep that boundary visible until the next vertical slice wires a real read-only data source or typed launch-intent contract. Planned role: @@ -11,15 +22,41 @@ Planned role: - controller-first fullscreen handheld UX - built on top of the shared Nova backend layers rather than the Android shell -Expected dependencies: +## Runnable smoke paths + +Fallback native core and controller/library placeholder, no Qt required: + + cmake -S clients/deck -B build/deck-smoke-core -DNOVA_DECK_BUILD_QT_SHELL=OFF + cmake --build build/deck-smoke-core + ctest --test-dir build/deck-smoke-core --output-on-failure + +Full Qt shell smoke, when Qt deps are present: + + cmake -S clients/deck -B build/deck-smoke-qt + cmake --build build/deck-smoke-qt + ctest --test-dir build/deck-smoke-qt --output-on-failure + +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. + +## 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. + +Keep this boundary explicit until the shared contract is exported through a real native-consumable API. Do not fake Kotlin/C++ interop by including .kt files. + +## Fedora or SteamOS dependency notes + +The fallback smoke only needs CMake and a C++20 compiler. + +For the Qt shell on Fedora, install the Qt 6 development packages if CMake warns that Qt6 Quick or QuickControls2 is missing: + + sudo dnf install cmake gcc-c++ qt6-qtbase-devel qt6-qtdeclarative-devel -- `../../shared/models/` -- `../../shared/polaris/` -- `../../shared/stream-core/` +On Fedora, qt6-qtdeclarative-devel provides cmake(Qt6QuickControls2). SteamOS package names may differ; the required CMake components are Qt6 Core, Qt6 Gui, Qt6 Qml, Qt6 Quick, and Qt6 QuickControls2. Primary design reference: -- [`../../docs/steam_deck_native_port_study.md`](../../docs/steam_deck_native_port_study.md) +- ../../docs/steam_deck_native_port_study.md Guardrails: diff --git a/clients/deck/fixtures/sample_polaris_game.json b/clients/deck/fixtures/sample_polaris_game.json new file mode 100644 index 00000000..36919479 --- /dev/null +++ b/clients/deck/fixtures/sample_polaris_game.json @@ -0,0 +1,33 @@ +{ + "id": "game-123", + "app_id": 456, + "name": "Portal 2", + "source": "steam", + "launcher_source": "steam", + "launcher_detail": "library", + "platform": "linux", + "runtime": "proton", + "platform_label": "Linux", + "runtime_label": "Proton", + "steam_appid": "620", + "category": "fast_action", + "installed": true, + "cover_url": "/polaris/v1/games/game-123/cover", + "genres": ["Action", "Puzzle"], + "last_launched": 1718187600000, + "mangohud": true, + "hdr_supported": true, + "launch_mode": { + "preferred_mode": "virtual_display", + "recommended_mode": "headless", + "allowed_modes": ["headless", "virtual_display"], + "mode_reason": "Host default is headless." + }, + "steam_launch": { + "available": true, + "mode": "big-picture", + "recommended_mode": "direct", + "allowed_modes": ["direct", "big-picture"], + "mode_reason": "Steam Input fallback." + } +} diff --git a/clients/deck/fixtures/sample_polaris_library.json b/clients/deck/fixtures/sample_polaris_library.json new file mode 100644 index 00000000..4aac8652 --- /dev/null +++ b/clients/deck/fixtures/sample_polaris_library.json @@ -0,0 +1,72 @@ +{ + "fixture_source": "Shared Polaris contract fixture", + "read_only": true, + "games": [ + { + "id": "game-123", + "app_id": 456, + "name": "Portal 2", + "source": "steam", + "launcher_source": "steam", + "launcher_detail": "library", + "platform": "linux", + "runtime": "proton", + "platform_label": "Linux", + "runtime_label": "Proton", + "steam_appid": "620", + "category": "fast_action", + "installed": true, + "cover_url": "/polaris/v1/games/game-123/cover", + "genres": ["Action", "Puzzle"], + "last_launched": 1718187600000, + "mangohud": true, + "hdr_supported": true, + "launch_mode": { + "preferred_mode": "virtual_display", + "recommended_mode": "headless", + "allowed_modes": ["headless", "virtual_display"], + "mode_reason": "Host default is headless." + }, + "steam_launch": { + "available": true, + "mode": "big-picture", + "recommended_mode": "direct", + "allowed_modes": ["direct", "big-picture"], + "mode_reason": "Steam Input fallback." + } + }, + { + "id": "game-456", + "app_id": 789, + "name": "Hades", + "source": "steam", + "launcher_source": "steam", + "launcher_detail": "library", + "platform": "linux", + "runtime": "proton", + "platform_label": "Linux", + "runtime_label": "Proton", + "steam_appid": "1145360", + "category": "fast_action", + "installed": true, + "cover_url": "/polaris/v1/games/game-456/cover", + "genres": ["Action", "Roguelike"], + "last_launched": 1718191200000, + "mangohud": true, + "hdr_supported": false, + "launch_mode": { + "preferred_mode": "headless", + "recommended_mode": "virtual_display", + "allowed_modes": ["headless", "virtual_display"], + "mode_reason": "Virtual display is available for this host." + }, + "steam_launch": { + "available": true, + "mode": "direct", + "recommended_mode": "big-picture", + "allowed_modes": ["direct", "big-picture"], + "mode_reason": "Big Picture is controller-friendly." + } + } + ] +} diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml new file mode 100644 index 00000000..22563853 --- /dev/null +++ b/clients/deck/qml/Main.qml @@ -0,0 +1,477 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + + width: novaDeckWidth + height: novaDeckHeight + visible: true + title: novaDeckShellName + color: "#070B18" + + readonly property int deckSafeMargin: 32 + readonly property int deckShellSpacing: 16 + readonly property int deckPanelSpacing: 12 + readonly property int deckRowSpacing: 16 + readonly property int hostColumnWidth: 336 + readonly property int sampleCardWidth: 392 + readonly property int detailColumnWidth: 424 + readonly property int hostCardHeight: 104 + readonly property int detailPanelHeight: 196 + readonly property int launchPreviewHeight: 236 + readonly property int hostTextWidth: hostColumnWidth - 40 + readonly property int sampleTextWidth: sampleCardWidth - 48 + readonly property int detailTextWidth: detailColumnWidth - 48 + property int previewCopyActivationCount: 0 + + function activateLaunchPreviewCopyFromController() { + const canCopyPreview = novaLaunchPreviewCopyAction.enabled + && novaLaunchPreviewCopyAction.previewText.length > 0 + && novaLaunchPreviewCopyAction.copyOnly + && novaLaunchPreviewCopyAction.uiLocalClipboardOnly + && !novaLaunchPreviewCopyAction.executable + const didCopyPreview = canCopyPreview + && novaLocalClipboard.copyPreviewText(novaLaunchPreviewCopyAction.previewText) + if (didCopyPreview) { + previewCopyActivationCount += 1 + } + copyStatusLabel.text = didCopyPreview + ? novaLaunchPreviewCopyAction.successToast + " · A pressed #" + previewCopyActivationCount + : novaLaunchPreviewCopyAction.inertToast + " · A press stayed preview-only" + copyStatusLabel.color = didCopyPreview ? "#8AFFC1" : "#FFDDA8" + } + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: "#111936" } + GradientStop { position: 1.0; color: "#070B18" } + } + } + + Connections { + target: novaGamepad + function onPrimaryActionPressed(activationCount) { + activateLaunchPreviewCopyFromController() + } + } + + FocusScope { + id: libraryFocusScope + anchors.fill: parent + focus: true + Component.onCompleted: Qt.callLater(function() { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + }) + + ColumnLayout { + anchors.fill: parent + anchors.margins: deckSafeMargin + spacing: deckShellSpacing + + Label { + text: novaDeckShellName + color: "#E9ECFF" + font.pixelSize: 48 + font.bold: true + } + + Label { + text: "Controller-first Steam Deck shell scaffold" + color: "#A8B0D8" + font.pixelSize: 24 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 2 + color: "#7C73FF" + opacity: 0.65 + } + + RowLayout { + Layout.fillWidth: true + spacing: deckRowSpacing + + ColumnLayout { + Layout.preferredWidth: hostColumnWidth + spacing: deckPanelSpacing + + Label { + text: "Demo hosts" + color: "#E9ECFF" + font.pixelSize: 28 + font.bold: true + } + + Rectangle { + id: emptyHostState + objectName: "host-empty-state" + visible: novaDemoHosts.length === 0 + Layout.preferredWidth: hostColumnWidth + Layout.preferredHeight: visible ? 120 : 0 + radius: 20 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + focus: visible + activeFocusOnTab: visible + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 5 + + Label { + text: "No demo hosts yet" + color: "#E9ECFF" + font.pixelSize: 22 + font.bold: true + } + + Label { + text: "Empty host state is focusable and deterministic." + color: "#A8B0D8" + font.pixelSize: 12 + } + } + } + + Repeater { + id: hostRepeater + model: novaDemoHosts + + delegate: Rectangle { + required property int index + required property var modelData + + objectName: modelData.id + Layout.preferredWidth: hostColumnWidth + Layout.preferredHeight: hostCardHeight + radius: 20 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#7C73FF" + border.width: activeFocus ? 5 : 3 + focus: modelData.initialFocus + activeFocusOnTab: true + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + Keys.onDownPressed: { + const next = hostRepeater.itemAt(index + 1) + if (next !== null) { + next.forceActiveFocus() + } + } + Keys.onUpPressed: { + const previous = hostRepeater.itemAt(index - 1) + if (previous !== null) { + previous.forceActiveFocus() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 5 + + Label { + text: modelData.displayName + color: "#E9ECFF" + font.pixelSize: 20 + font.bold: true + } + + Label { + text: modelData.statusLabel + color: "#B8C2F0" + font.pixelSize: 16 + } + } + } + } + } + + ColumnLayout { + id: libraryGameList + objectName: "library-game-list" + Layout.preferredWidth: sampleCardWidth + spacing: deckPanelSpacing + + Label { + text: "Read-only Polaris library" + color: "#E9ECFF" + font.pixelSize: 24 + font.bold: true + } + + Label { + Layout.preferredWidth: sampleTextWidth + text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only" : "") + color: "#A8B0D8" + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + + Repeater { + id: libraryGameRepeater + model: novaLibraryGames + + delegate: Rectangle { + required property int index + required property var modelData + + objectName: modelData.id + Layout.preferredWidth: sampleCardWidth + Layout.preferredHeight: 88 + radius: 18 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#7C73FF" + border.width: activeFocus ? 5 : 2 + focus: modelData.initialFocus + activeFocusOnTab: true + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + Keys.onDownPressed: { + const next = libraryGameRepeater.itemAt(index + 1) + if (next !== null) { + next.forceActiveFocus() + } + } + Keys.onUpPressed: { + const previous = libraryGameRepeater.itemAt(index - 1) + if (previous !== null) { + previous.forceActiveFocus() + } + } + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 4 + + Label { + text: modelData.title + color: "#E9ECFF" + font.pixelSize: 20 + font.bold: true + } + + Label { + text: modelData.sourceRuntimeLabel + " · " + modelData.installedLabel + color: "#B8C2F0" + font.pixelSize: 13 + } + + Label { + text: modelData.launchModeLabel + color: "#A8B0D8" + font.pixelSize: 12 + } + } + } + } + } + + ColumnLayout { + Layout.preferredWidth: detailColumnWidth + spacing: deckPanelSpacing + + Rectangle { + id: hostDetailPanel + objectName: "host-detail-panel" + Layout.preferredWidth: detailColumnWidth + Layout.preferredHeight: detailPanelHeight + radius: 22 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + focus: true + activeFocusOnTab: true + KeyNavigation.left: hostRepeater.itemAt(0) !== null ? hostRepeater.itemAt(0) : emptyHostState + KeyNavigation.down: launchCtaPlaceholder + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + Keys.onDownPressed: launchCtaPlaceholder.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 8 + + Label { + text: "Demo host detail" + color: "#7C88B8" + font.pixelSize: 16 + } + + Label { + text: novaSelectedHostDetail.displayName + color: "#E9ECFF" + font.pixelSize: 30 + font.bold: true + } + + Label { + text: novaSelectedHostDetail.statusLabel + color: "#B8C2F0" + font.pixelSize: 19 + } + + Label { + Layout.preferredWidth: detailTextWidth + text: novaSelectedHostDetail.subtitle + color: "#A8B0D8" + font.pixelSize: 16 + wrapMode: Text.WordWrap + } + } + } + + Rectangle { + id: launchCtaPlaceholder + objectName: novaHostLaunchCta.id + Layout.preferredWidth: detailColumnWidth + Layout.preferredHeight: launchPreviewHeight + radius: 20 + color: activeFocus ? "#2A2948" : "#181D34" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + opacity: novaHostLaunchCta.enabled ? 1.0 : 0.72 + focus: false + activeFocusOnTab: true + KeyNavigation.up: hostDetailPanel + KeyNavigation.down: copyPreviewButton + Keys.onUpPressed: hostDetailPanel.forceActiveFocus() + Keys.onDownPressed: copyPreviewButton.forceActiveFocus() + Keys.onReturnPressed: activateLaunchPreviewCopyFromController() + Keys.onEnterPressed: activateLaunchPreviewCopyFromController() + Keys.onSpacePressed: activateLaunchPreviewCopyFromController() + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 5 + + Label { + text: novaHostLaunchCta.label + color: "#E9ECFF" + font.pixelSize: 20 + font.bold: true + } + + Label { + Layout.preferredWidth: detailTextWidth + text: novaHostLaunchCta.helpText + color: "#B8C2F0" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: novaHostLaunchCta.previewStateLabel + color: "#FFDDA8" + font.pixelSize: 12 + font.bold: true + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: "Typed launch boundary: " + novaLaunchIntentBoundary.label + " · network/process/Moonlight blocked" + color: "#FFDDA8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: novaLaunchIntentBoundary.reason + color: "#A8B0D8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: novaHostLaunchCta.previewText + color: "#A8B0D8" + font.pixelSize: 12 + font.family: "monospace" + wrapMode: Text.WrapAnywhere + } + + Button { + id: copyPreviewButton + objectName: novaLaunchPreviewCopyAction.id + text: activeFocus ? "A · " + novaLaunchPreviewCopyAction.label : novaLaunchPreviewCopyAction.label + enabled: novaLaunchPreviewCopyAction.enabled + focusPolicy: Qt.StrongFocus + activeFocusOnTab: true + KeyNavigation.up: launchCtaPlaceholder + Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + Keys.onReturnPressed: activateLaunchPreviewCopyFromController() + Keys.onEnterPressed: activateLaunchPreviewCopyFromController() + Keys.onSpacePressed: activateLaunchPreviewCopyFromController() + onClicked: activateLaunchPreviewCopyFromController() + } + + Label { + id: copyStatusLabel + Layout.preferredWidth: detailTextWidth + text: novaLaunchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify." + color: "#FFDDA8" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + } + } + } + + Item { Layout.fillHeight: true } + + Label { + text: novaDeckFullscreenPreferred + ? "Deck default: 1280×800 · fullscreen-first · controller-first" + : "Deck default: 1280×800 · windowed test mode" + color: "#7C88B8" + font.pixelSize: 18 + } + } + } +} diff --git a/clients/deck/src/deck_gamepad.cpp b/clients/deck/src/deck_gamepad.cpp new file mode 100644 index 00000000..3eaa8346 --- /dev/null +++ b/clients/deck/src/deck_gamepad.cpp @@ -0,0 +1,22 @@ +#include "deck_gamepad.h" + +namespace nova::deck { + +DeckGamepadAction decodeGamepadAction(const DeckGamepadEvent& event) { + if ((event.type & kDeckGamepadInitEvent) != 0) { + return DeckGamepadAction::None; + } + + const auto eventType = static_cast(event.type & ~kDeckGamepadInitEvent); + if (eventType != kDeckGamepadButtonEvent) { + return DeckGamepadAction::None; + } + + if (event.number == kDeckGamepadPrimaryButton && event.value == 1) { + return DeckGamepadAction::PrimaryPressed; + } + + return DeckGamepadAction::None; +} + +} // namespace nova::deck diff --git a/clients/deck/src/deck_gamepad.h b/clients/deck/src/deck_gamepad.h new file mode 100644 index 00000000..26264a30 --- /dev/null +++ b/clients/deck/src/deck_gamepad.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace nova::deck { + +constexpr unsigned char kDeckGamepadButtonEvent = 0x01; +constexpr unsigned char kDeckGamepadAxisEvent = 0x02; +constexpr unsigned char kDeckGamepadInitEvent = 0x80; +constexpr unsigned char kDeckGamepadPrimaryButton = 0; + +struct DeckGamepadEvent { + std::uint32_t timeMs = 0; + short value = 0; + unsigned char type = 0; + unsigned char number = 0; +}; + +enum class DeckGamepadAction { + None, + PrimaryPressed, +}; + +DeckGamepadAction decodeGamepadAction(const DeckGamepadEvent& event); + +} // namespace nova::deck diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp new file mode 100644 index 00000000..9675ee88 --- /dev/null +++ b/clients/deck/src/deck_layout.cpp @@ -0,0 +1,449 @@ +#include "deck_layout.h" +#include "polaris_game_fixture.h" + +#include +#include +#include + +namespace nova::deck { +namespace { + +constexpr std::string_view kPreviewStateLabel = "Preview only — not executable"; +constexpr std::string_view kPreviewBoundaryId = "deck-launch-preview-only"; +constexpr std::string_view kPreviewBoundaryLabel = "Preview-only typed intent boundary"; +constexpr std::string_view kPreviewBoundaryReason = "Deck shell may build copyable preview text, but launch execution is blocked."; +constexpr std::string_view kCopyIdleStatusLabel = "Copy action is preview-only and not executable."; +constexpr std::string_view kCopySuccessToast = "Preview text copied for inspection only — still not executable."; +constexpr std::string_view kCopyInertToast = "No preview text to copy — preview-only action stayed inert."; + +std::string encodePreviewComponent(const std::string& value) { + std::string encoded; + for (const unsigned char ch : value) { + if (std::isalnum(ch) || ch == char(45) || ch == char(95)) { + encoded.push_back(static_cast(ch)); + } else if (ch == char(32)) { + encoded += "%20"; + } + } + return encoded; +} + +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 joinedSourceRuntimeLabelFor(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; +} + +} // namespace + + +DeckWindowProfile defaultWindowProfile() { + return DeckWindowProfile{ + .width = 1280, + .height = 800, + .fullscreenPreferred = true, + .shellName = "Nova Deck", + }; +} + +std::vector defaultLibraryFocusTargets() { + return { + DeckFocusTarget{ + .id = "sample-game-card", + .label = "Shared Polaris DTO fixture", + .row = 0, + .column = 0, + .initialFocus = true, + }, + DeckFocusTarget{ + .id = "details-placeholder", + .label = "Controller placeholder scope", + .row = 0, + .column = 1, + .initialFocus = false, + }, + }; +} + +std::string_view initialLibraryFocusTarget(const std::vector& targets) { + const auto initial = std::ranges::find_if(targets, [](const DeckFocusTarget& target) { + return target.initialFocus; + }); + + if (initial != targets.end()) { + return initial->id; + } + + if (!targets.empty()) { + return targets.front().id; + } + + return {}; +} + +std::string_view nextLibraryFocusTarget( + const std::vector& targets, + const std::string_view currentId, + const DeckFocusDirection direction) { + const auto current = std::ranges::find_if(targets, [currentId](const DeckFocusTarget& target) { + return target.id == currentId; + }); + + if (current == targets.end()) { + return initialLibraryFocusTarget(targets); + } + + const int horizontalDelta = direction == DeckFocusDirection::Left ? -1 : direction == DeckFocusDirection::Right ? 1 : 0; + const int verticalDelta = direction == DeckFocusDirection::Up ? -1 : direction == DeckFocusDirection::Down ? 1 : 0; + + const auto next = std::ranges::find_if(targets, [&](const DeckFocusTarget& target) { + return target.row == current->row + verticalDelta && target.column == current->column + horizontalDelta; + }); + + if (next != targets.end()) { + return next->id; + } + + return current->id; +} + +std::vector emptyHostListState() { + return {}; +} + +std::vector demoHostListState() { + return { + DeckHostListItem{ + .id = "host-gaming-pc", + .displayName = "Gaming PC", + .statusLabel = "Ready for local demo", + .row = 0, + .initialFocus = true, + }, + DeckHostListItem{ + .id = "host-living-room-pc", + .displayName = "Living Room PC", + .statusLabel = "Ready for local demo", + .row = 1, + .initialFocus = false, + }, + }; +} + +std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library) { + std::vector cards; + cards.reserve(library.games.size()); + int row = 0; + for (const auto& game : library.games) { + cards.push_back(DeckLibraryGameCard{ + .id = game.id.empty() ? "library-game-" + std::to_string(row) : game.id, + .title = game.name.empty() ? "Untitled game" : game.name, + .sourceRuntimeLabel = joinedSourceRuntimeLabelFor(game), + .launchModeLabel = launchModeLabelFor(game), + .installedLabel = game.installed ? "Installed" : "Not installed", + .row = row, + .initialFocus = row == 0, + }); + ++row; + } + return cards; +} + +std::string_view initialHostFocusTarget(const std::vector& hosts) { + const auto initial = std::ranges::find_if(hosts, [](const DeckHostListItem& host) { + return host.initialFocus; + }); + + if (initial != hosts.end()) { + return initial->id; + } + + if (!hosts.empty()) { + return hosts.front().id; + } + + return "host-empty-state"; +} + +std::string_view nextHostFocusTarget( + const std::vector& hosts, + const std::string_view currentId, + const DeckFocusDirection direction) { + if (hosts.empty()) { + return "host-empty-state"; + } + + const auto current = std::ranges::find_if(hosts, [currentId](const DeckHostListItem& host) { + return host.id == currentId; + }); + + if (current == hosts.end()) { + return initialHostFocusTarget(hosts); + } + + const int verticalDelta = direction == DeckFocusDirection::Up ? -1 : direction == DeckFocusDirection::Down ? 1 : 0; + if (verticalDelta == 0) { + return current->id; + } + + const auto next = std::ranges::find_if(hosts, [&](const DeckHostListItem& host) { + return host.row == current->row + verticalDelta; + }); + + if (next != hosts.end()) { + return next->id; + } + + return current->id; +} + +DeckHostDetail resolveHostDetail(const std::vector& hosts, const std::string_view hostId) { + const auto host = std::ranges::find_if(hosts, [hostId](const DeckHostListItem& item) { + return item.id == hostId; + }); + + if (host == hosts.end()) { + return DeckHostDetail{ + .id = "host-detail-empty", + .displayName = "No demo host selected", + .statusLabel = "Select a demo host", + .subtitle = "Demo host detail only — not discovered from the network.", + }; + } + + return DeckHostDetail{ + .id = host->id, + .displayName = host->displayName, + .statusLabel = host->statusLabel, + .subtitle = "Demo host detail only — not discovered from the network.", + }; +} + +DeckLaunchIntentBoundary previewOnlyLaunchIntentBoundary() { + return DeckLaunchIntentBoundary{ + .kind = DeckLaunchIntentBoundaryKind::PreviewOnly, + .id = std::string(kPreviewBoundaryId), + .label = std::string(kPreviewBoundaryLabel), + .reason = std::string(kPreviewBoundaryReason), + .previewOnly = true, + .allowsNetwork = false, + .allowsProcessExecution = false, + .allowsMoonlight = false, + .allowsHostMutation = false, + }; +} + +DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game) { + return DeckLaunchIntent{ + .targetHostId = std::string(detail.id), + .targetHostName = std::string(detail.displayName), + .sampleGameId = game.id, + .gameTitle = game.name, + .streamLaunchMode = game.launchMode.recommendedMode.empty() ? std::string{"preview"} : game.launchMode.recommendedMode, + .steamLaunchMode = game.steamLaunch.recommendedMode.empty() ? std::string{"direct"} : game.steamLaunch.recommendedMode, + .boundary = previewOnlyLaunchIntentBoundary(), + .executable = false, + .safetyLabel = std::string(kPreviewStateLabel), + }; +} + +bool canExecuteLaunchIntent(const DeckLaunchIntent& intent) { + return intent.executable + && !intent.boundary.previewOnly + && intent.boundary.allowsNetwork + && intent.boundary.allowsProcessExecution + && intent.boundary.allowsMoonlight + && intent.boundary.allowsHostMutation; +} + +DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent) { + return DeckLaunchPreview{ + .text = "preview://nova-deck/launch?host=" + encodePreviewComponent(intent.targetHostId) + + "&game=" + encodePreviewComponent(intent.gameTitle) + + "&state=copy-preview-only", + .stateLabel = std::string(kPreviewStateLabel), + .boundaryId = intent.boundary.id, + .boundaryLabel = intent.boundary.label, + .copyOnly = true, + .executable = canExecuteLaunchIntent(intent), + .networkAllowed = intent.boundary.allowsNetwork, + .processExecutionAllowed = intent.boundary.allowsProcessExecution, + .moonlightAllowed = intent.boundary.allowsMoonlight, + .hostMutationAllowed = intent.boundary.allowsHostMutation, + }; +} + +DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& preview) { + const bool hasPreviewText = !preview.text.empty(); + return DeckLaunchPreviewCopyAction{ + .id = "host-detail-copy-preview", + .label = "Copy preview text", + .previewText = preview.text, + .idleStatusLabel = hasPreviewText ? std::string(kCopyIdleStatusLabel) : std::string(kCopyInertToast), + .successToast = std::string(kCopySuccessToast), + .inertToast = std::string(kCopyInertToast), + .enabled = hasPreviewText, + .copyOnly = true, + .uiLocalClipboardOnly = true, + .executable = false, + }; +} + +DeckLaunchPreviewCopyResult activateLaunchPreviewCopy(const DeckLaunchPreviewCopyAction& action) { + const bool canCopyPreview = action.enabled && !action.previewText.empty() && action.copyOnly && !action.executable; + const std::string status = canCopyPreview ? action.successToast : action.inertToast; + return DeckLaunchPreviewCopyResult{ + .previewText = canCopyPreview ? action.previewText : std::string{}, + .statusLabel = status, + .toastLabel = status, + .copied = canCopyPreview, + }; +} + +DeckLaunchPreviewCopyResult copyLaunchPreviewToLocalClipboard( + const DeckLaunchPreviewCopyAction& action, + DeckLocalClipboard& clipboard) { + const bool canCopyPreview = action.enabled && !action.previewText.empty() && action.copyOnly + && action.uiLocalClipboardOnly && !action.executable; + if (!canCopyPreview) { + return DeckLaunchPreviewCopyResult{ + .previewText = {}, + .statusLabel = action.inertToast, + .toastLabel = action.inertToast, + .copied = false, + }; + } + + const bool published = clipboard.publishPreviewText(action.previewText); + const std::string status = published ? action.successToast : action.inertToast; + return DeckLaunchPreviewCopyResult{ + .previewText = published ? action.previewText : std::string{}, + .statusLabel = status, + .toastLabel = status, + .copied = published, + }; +} + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail, const PolarisGameFixture& game) { + const auto launchIntent = resolveLaunchIntent(detail, game); + const auto preview = fakeLaunchCommandPreviewFor(launchIntent); + (void)preview; + return DeckLaunchCta{ + .id = "host-detail-launch-cta", + .label = "Launch preview only", + .helpText = "Display-only preview — not wired to launch, Moonlight, or a network backend.", + .previewStateLabel = preview.stateLabel, + .previewText = preview.text, + .enabled = false, + }; +} + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { + return inertLaunchCtaFor(detail, loadSamplePolarisGameFixture()); +} + +std::vector hostDetailFocusTargets( + const DeckHostDetail& detail, + const DeckLaunchCta& launchCta, + const DeckLaunchPreviewCopyAction& copyAction) { + return { + DeckFocusTarget{ + .id = "host-detail-panel", + .label = detail.displayName, + .row = 0, + .column = 0, + .initialFocus = true, + }, + DeckFocusTarget{ + .id = launchCta.id, + .label = launchCta.label, + .row = 1, + .column = 0, + .initialFocus = false, + }, + DeckFocusTarget{ + .id = copyAction.id, + .label = copyAction.label, + .row = 2, + .column = 0, + .initialFocus = false, + }, + }; +} + +std::string_view nextHostDetailFocusTarget( + const std::vector& targets, + const std::string_view currentId, + const DeckFocusDirection direction) { + const auto current = std::ranges::find_if(targets, [currentId](const DeckFocusTarget& target) { + return target.id == currentId; + }); + + if (current == targets.end()) { + return initialLibraryFocusTarget(targets); + } + + const int verticalDelta = direction == DeckFocusDirection::Up ? -1 : direction == DeckFocusDirection::Down ? 1 : 0; + if (verticalDelta == 0) { + return current->id; + } + + const auto next = std::ranges::find_if(targets, [&](const DeckFocusTarget& target) { + return target.row == current->row + verticalDelta && target.column == current->column; + }); + + if (next != targets.end()) { + return next->id; + } + + return current->id; +} + +bool isDeckNativeAspect(const int width, const int height) { + if (width <= 0 || height <= 0) { + return false; + } + + return width * 10 == height * 16; +} + +} // namespace nova::deck diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h new file mode 100644 index 00000000..01870c5a --- /dev/null +++ b/clients/deck/src/deck_layout.h @@ -0,0 +1,192 @@ +#pragma once + +#include +#include +#include + +namespace nova::deck { + +struct PolarisGameFixture; +struct PolarisGameLibraryFixture; + +struct DeckWindowProfile { + int width; + int height; + bool fullscreenPreferred; + std::string_view shellName; +}; + +struct DeckFocusTarget { + std::string_view id; + std::string_view label; + int row; + int column; + bool initialFocus; +}; + +struct DeckHostListItem { + std::string_view id; + std::string_view displayName; + std::string_view statusLabel; + int row; + bool initialFocus; +}; + +struct DeckHostDetail { + std::string_view id; + std::string_view displayName; + std::string_view statusLabel; + std::string_view subtitle; +}; + +struct DeckLibraryGameCard { + std::string id; + std::string title; + std::string sourceRuntimeLabel; + std::string launchModeLabel; + std::string installedLabel; + int row = 0; + bool initialFocus = false; +}; + +struct DeckLaunchCta { + std::string_view id; + std::string_view label; + std::string_view helpText; + std::string previewStateLabel; + std::string previewText; + bool enabled; +}; + +enum class DeckLaunchIntentBoundaryKind { + PreviewOnly, +}; + +struct DeckLaunchIntentBoundary { + DeckLaunchIntentBoundaryKind kind = DeckLaunchIntentBoundaryKind::PreviewOnly; + std::string id; + std::string label; + std::string reason; + bool previewOnly = true; + bool allowsNetwork = false; + bool allowsProcessExecution = false; + bool allowsMoonlight = false; + bool allowsHostMutation = false; +}; + +struct DeckLaunchIntent { + std::string targetHostId; + std::string targetHostName; + std::string sampleGameId; + std::string gameTitle; + std::string streamLaunchMode; + std::string steamLaunchMode; + DeckLaunchIntentBoundary boundary; + bool executable = false; + std::string safetyLabel; +}; + +struct DeckLaunchPreview { + std::string text; + std::string stateLabel; + std::string boundaryId; + std::string boundaryLabel; + bool copyOnly = true; + bool executable = false; + bool networkAllowed = false; + bool processExecutionAllowed = false; + bool moonlightAllowed = false; + bool hostMutationAllowed = false; +}; + +struct DeckLaunchPreviewCopyAction { + std::string_view id; + std::string_view label; + std::string previewText; + std::string idleStatusLabel; + std::string successToast; + std::string inertToast; + bool enabled = false; + bool copyOnly = true; + bool uiLocalClipboardOnly = true; + bool executable = false; +}; + +struct DeckLaunchPreviewCopyResult { + std::string previewText; + std::string statusLabel; + std::string toastLabel; + bool copied = false; +}; + +class DeckLocalClipboard { +public: + virtual ~DeckLocalClipboard() = default; + virtual bool publishPreviewText(std::string_view value) = 0; +}; + +enum class DeckFocusDirection { + Left, + Right, + Up, + Down, +}; + +DeckWindowProfile defaultWindowProfile(); + +std::vector defaultLibraryFocusTargets(); + +std::string_view initialLibraryFocusTarget(const std::vector& targets); + +std::string_view nextLibraryFocusTarget( + const std::vector& targets, + std::string_view currentId, + DeckFocusDirection direction); + +std::vector emptyHostListState(); + +std::vector demoHostListState(); + +std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library); + +std::string_view initialHostFocusTarget(const std::vector& hosts); + +std::string_view nextHostFocusTarget( + const std::vector& hosts, + std::string_view currentId, + DeckFocusDirection direction); + +DeckHostDetail resolveHostDetail(const std::vector& hosts, std::string_view hostId); + +DeckLaunchIntentBoundary previewOnlyLaunchIntentBoundary(); + +DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game); + +bool canExecuteLaunchIntent(const DeckLaunchIntent& intent); + +DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent); + +DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& preview); + +DeckLaunchPreviewCopyResult activateLaunchPreviewCopy(const DeckLaunchPreviewCopyAction& action); + +DeckLaunchPreviewCopyResult copyLaunchPreviewToLocalClipboard( + const DeckLaunchPreviewCopyAction& action, + DeckLocalClipboard& clipboard); + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail, const PolarisGameFixture& game); +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); + +std::vector hostDetailFocusTargets( + const DeckHostDetail& detail, + const DeckLaunchCta& launchCta, + const DeckLaunchPreviewCopyAction& copyAction); + +std::string_view nextHostDetailFocusTarget( + const std::vector& targets, + std::string_view currentId, + DeckFocusDirection direction); + +bool isDeckNativeAspect(int width, int height); + +} // namespace nova::deck diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp new file mode 100644 index 00000000..1ef46af7 --- /dev/null +++ b/clients/deck/src/main.cpp @@ -0,0 +1,289 @@ +#include "deck_layout.h" +#include "deck_gamepad.h" +#include "polaris_game_fixture.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef __linux__ +#include +#include +#include +#endif + +namespace { +class QtDeckGamepadBridge final : public QObject { + Q_OBJECT + Q_PROPERTY(bool available READ available NOTIFY availabilityChanged) +public: + explicit QtDeckGamepadBridge(QObject* parent = nullptr) + : QObject(parent) { + openDefaultDevice(); + } + + ~QtDeckGamepadBridge() override { +#ifdef __linux__ + if (gamepadFd_ >= 0) { + ::close(gamepadFd_); + gamepadFd_ = -1; + } +#endif + } + + [[nodiscard]] bool available() const { +#ifdef __linux__ + return gamepadFd_ >= 0; +#else + return false; +#endif + } + +signals: + void availabilityChanged(); + void primaryActionPressed(int activationCount); + +private: + void openDefaultDevice() { +#ifdef __linux__ + const QByteArray configuredDevice = qgetenv("NOVA_DECK_GAMEPAD_DEVICE"); + const QByteArray devicePath = configuredDevice.isEmpty() ? QByteArray("/dev/input/js0") : configuredDevice; + gamepadFd_ = ::open(devicePath.constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (gamepadFd_ < 0) { + return; + } + + notifier_ = new QSocketNotifier(gamepadFd_, QSocketNotifier::Read, this); + connect(notifier_, &QSocketNotifier::activated, this, [this]() { + readPendingJoystickEvents(); + }); + emit availabilityChanged(); +#endif + } + +#ifdef __linux__ + void readPendingJoystickEvents() { + js_event rawEvent{}; + for (;;) { + const ssize_t bytesRead = ::read(gamepadFd_, &rawEvent, sizeof(rawEvent)); + if (bytesRead == static_cast(sizeof(rawEvent))) { + const auto action = nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ + .timeMs = rawEvent.time, + .value = rawEvent.value, + .type = rawEvent.type, + .number = rawEvent.number, + }); + if (action == nova::deck::DeckGamepadAction::PrimaryPressed) { + ++primaryActivationCount_; + emit primaryActionPressed(primaryActivationCount_); + } + continue; + } + + if (bytesRead < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) { + return; + } + + notifier_->setEnabled(false); + ::close(gamepadFd_); + gamepadFd_ = -1; + emit availabilityChanged(); + return; + } + } + + int gamepadFd_ = -1; + QSocketNotifier* notifier_ = nullptr; +#else + void readPendingJoystickEvents() {} +#endif + int primaryActivationCount_ = 0; +}; + +class QtLocalClipboardBridge final : public QObject { + Q_OBJECT +public: + using QObject::QObject; + + Q_INVOKABLE bool copyPreviewText(const QString& text) { + if (text.isEmpty()) { + return false; + } + QClipboard* clipboard = QGuiApplication::clipboard(); + if (clipboard == nullptr) { + return false; + } + clipboard->setText(text, QClipboard::Clipboard); + return clipboard->text(QClipboard::Clipboard) == text; + } +}; + +QString toQString(const std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +QString toQString(const std::string& value) { + return QString::fromStdString(value); +} + +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("initialFocus", host.initialFocus); + model.append(item); + } + return model; +} + +QVariantMap toHostDetailModel(const nova::deck::DeckHostDetail& 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)); + return model; +} + +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 toLaunchCtaModel(const nova::deck::DeckLaunchCta& launchCta) { + QVariantMap model; + model.insert("id", toQString(launchCta.id)); + model.insert("label", toQString(launchCta.label)); + model.insert("helpText", toQString(launchCta.helpText)); + model.insert("previewStateLabel", toQString(launchCta.previewStateLabel)); + model.insert("previewText", toQString(launchCta.previewText)); + model.insert("enabled", launchCta.enabled); + return model; +} + +QString launchIntentBoundaryKindLabel(nova::deck::DeckLaunchIntentBoundaryKind kind) { + switch (kind) { + case nova::deck::DeckLaunchIntentBoundaryKind::PreviewOnly: + return QStringLiteral("preview_only"); + } + return QStringLiteral("unknown"); +} + +QVariantMap toLaunchIntentBoundaryModel(const nova::deck::DeckLaunchIntentBoundary& boundary) { + QVariantMap model; + model.insert("id", toQString(boundary.id)); + model.insert("kind", launchIntentBoundaryKindLabel(boundary.kind)); + model.insert("label", toQString(boundary.label)); + model.insert("reason", toQString(boundary.reason)); + model.insert("previewOnly", boundary.previewOnly); + model.insert("allowsNetwork", boundary.allowsNetwork); + model.insert("allowsProcessExecution", boundary.allowsProcessExecution); + model.insert("allowsMoonlight", boundary.allowsMoonlight); + model.insert("allowsHostMutation", boundary.allowsHostMutation); + return model; +} + +QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyAction& copyAction) { + QVariantMap model; + model.insert("id", toQString(copyAction.id)); + model.insert("label", toQString(copyAction.label)); + model.insert("previewText", toQString(copyAction.previewText)); + model.insert("idleStatusLabel", toQString(copyAction.idleStatusLabel)); + model.insert("successToast", toQString(copyAction.successToast)); + model.insert("inertToast", toQString(copyAction.inertToast)); + model.insert("enabled", copyAction.enabled); + model.insert("copyOnly", copyAction.copyOnly); + model.insert("uiLocalClipboardOnly", copyAction.uiLocalClipboardOnly); + model.insert("executable", copyAction.executable); + return model; +} +} // namespace + +int main(int argc, char *argv[]) { + QGuiApplication app(argc, argv); + + const auto profile = nova::deck::defaultWindowProfile(); + const auto sampleLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); + const auto libraryGames = nova::deck::libraryGameCardsFor(sampleLibrary); + const auto& selectedGame = sampleLibrary.games.front(); + const auto demoHosts = nova::deck::demoHostListState(); + const auto selectedHostDetail = nova::deck::resolveHostDetail(demoHosts, nova::deck::initialHostFocusTarget(demoHosts)); + const auto launchIntent = nova::deck::resolveLaunchIntent(selectedHostDetail, selectedGame); + const auto launchPreview = nova::deck::fakeLaunchCommandPreviewFor(launchIntent); + const auto launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail, selectedGame); + const auto launchPreviewCopyAction = nova::deck::copyLaunchPreviewActionFor(launchPreview); + + QtLocalClipboardBridge localClipboard; + QtDeckGamepadBridge gamepadBridge; + + 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("novaDemoHosts", toHostModel(demoHosts)); + engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); + engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); + engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary)); + engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); + engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard); + engine.rootContext()->setContextProperty("novaGamepad", &gamepadBridge); + engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(demoHosts))); + engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); + + const bool smokeExit = QCoreApplication::arguments().contains("--smoke-exit"); + + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreationFailed, + &app, + []() { QCoreApplication::exit(-1); }, + Qt::QueuedConnection + ); + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreated, + &app, + [smokeExit, &app](QObject *object) { + if (smokeExit && object != nullptr) { + QTimer::singleShot(0, &app, &QCoreApplication::quit); + } + }, + Qt::QueuedConnection + ); + + engine.loadFromModule("Nova.Deck", "Main"); + + return app.exec(); +} + +#include "main.moc" diff --git a/clients/deck/src/polaris_game_fixture.cpp b/clients/deck/src/polaris_game_fixture.cpp new file mode 100644 index 00000000..02321d89 --- /dev/null +++ b/clients/deck/src/polaris_game_fixture.cpp @@ -0,0 +1,338 @@ +#include "polaris_game_fixture.h" + +#include +#include +#include +#include +#include +#include + +#ifndef NOVA_DECK_SAMPLE_GAME_FIXTURE +#define NOVA_DECK_SAMPLE_GAME_FIXTURE "../fixtures/sample_polaris_game.json" +#endif + +#ifndef NOVA_DECK_SAMPLE_LIBRARY_FIXTURE +#define NOVA_DECK_SAMPLE_LIBRARY_FIXTURE "../fixtures/sample_polaris_library.json" +#endif + +namespace nova::deck { +namespace { + +std::string readTextFile(const std::filesystem::path& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("Unable to open Polaris fixture: " + path.string()); + } + + return std::string( + std::istreambuf_iterator(input), + std::istreambuf_iterator() + ); +} + +std::size_t findKey(const std::string& json, const std::string_view key, const std::size_t start = 0) { + const std::string needle = "\"" + std::string(key) + "\""; + const auto keyPos = json.find(needle, start); + if (keyPos == std::string::npos) { + throw std::runtime_error("Missing Polaris fixture key: " + std::string(key)); + } + + const auto colonPos = json.find(char(58), keyPos + needle.size()); + if (colonPos == std::string::npos) { + throw std::runtime_error("Malformed Polaris fixture key: " + std::string(key)); + } + + return colonPos + 1; +} + +std::size_t skipWhitespace(const std::string& json, std::size_t pos) { + while (pos < json.size() && (json[pos] == 32 || json[pos] == 10 || json[pos] == 13 || json[pos] == 9)) { + ++pos; + } + return pos; +} + +std::string readStringAt(const std::string& json, std::size_t pos) { + pos = skipWhitespace(json, pos); + if (pos >= json.size() || json[pos] != char(34)) { + throw std::runtime_error("Expected string in Polaris fixture"); + } + + std::string value; + bool escaped = false; + for (std::size_t index = pos + 1; index < json.size(); ++index) { + const char ch = json[index]; + if (escaped) { + value.push_back(ch); + escaped = false; + continue; + } + if (ch == char(92)) { + escaped = true; + continue; + } + if (ch == char(34)) { + return value; + } + value.push_back(ch); + } + + throw std::runtime_error("Unterminated string in Polaris fixture"); +} + +std::string readString(const std::string& json, const std::string_view key, const std::size_t start = 0) { + return readStringAt(json, findKey(json, key, start)); +} + +std::string readOptionalString(const std::string& json, const std::string_view key, const std::string_view fallback) { + try { + return readString(json, key); + } catch (const std::runtime_error&) { + return std::string(fallback); + } +} + +int readInt(const std::string& json, const std::string_view key) { + auto pos = skipWhitespace(json, findKey(json, key)); + const auto end = json.find_first_of(",}\n", pos); + int value = 0; + const auto* begin = json.data() + pos; + const auto* finish = json.data() + (end == std::string::npos ? json.size() : end); + const auto [ptr, error] = std::from_chars(begin, finish, value); + (void)ptr; + if (error != std::errc()) { + throw std::runtime_error("Invalid integer in Polaris fixture: " + std::string(key)); + } + return value; +} + +std::int64_t readInt64(const std::string& json, const std::string_view key) { + auto pos = skipWhitespace(json, findKey(json, key)); + const auto end = json.find_first_of(",}\n", pos); + std::int64_t value = 0; + const auto* begin = json.data() + pos; + const auto* finish = json.data() + (end == std::string::npos ? json.size() : end); + const auto [ptr, error] = std::from_chars(begin, finish, value); + (void)ptr; + if (error != std::errc()) { + throw std::runtime_error("Invalid integer in Polaris fixture: " + std::string(key)); + } + return value; +} + +bool readBool(const std::string& json, const std::string_view key, const std::size_t start = 0) { + const auto pos = skipWhitespace(json, findKey(json, key, start)); + if (json.compare(pos, 4, "true") == 0) { + return true; + } + if (json.compare(pos, 5, "false") == 0) { + return false; + } + throw std::runtime_error("Invalid boolean in Polaris fixture: " + std::string(key)); +} + +bool readOptionalBool(const std::string& json, const std::string_view key, const bool fallback) { + try { + return readBool(json, key); + } catch (const std::runtime_error&) { + return fallback; + } +} + +std::vector readStringArray(const std::string& json, const std::string_view key, const std::size_t start = 0) { + auto pos = skipWhitespace(json, findKey(json, key, start)); + if (pos >= json.size() || json[pos] != char(91)) { + throw std::runtime_error("Expected array in Polaris fixture: " + std::string(key)); + } + + std::vector values; + ++pos; + while (pos < json.size()) { + pos = skipWhitespace(json, pos); + if (pos < json.size() && json[pos] == char(93)) { + return values; + } + values.push_back(readStringAt(json, pos)); + pos = json.find_first_of(",]", pos + 1); + if (pos == std::string::npos) { + throw std::runtime_error("Unterminated array in Polaris fixture: " + std::string(key)); + } + if (json[pos] == char(44)) { + ++pos; + } + } + + throw std::runtime_error("Unterminated array in Polaris fixture: " + std::string(key)); +} + +std::size_t objectStart(const std::string& json, const std::string_view key) { + const auto pos = skipWhitespace(json, findKey(json, key)); + if (pos >= json.size() || json[pos] != char(123)) { + throw std::runtime_error("Expected object in Polaris fixture: " + std::string(key)); + } + return pos; +} + +std::size_t arrayStart(const std::string& json, const std::string_view key) { + const auto pos = skipWhitespace(json, findKey(json, key)); + if (pos >= json.size() || json[pos] != char(91)) { + throw std::runtime_error("Expected array in Polaris fixture: " + std::string(key)); + } + return pos; +} + +std::string readObjectAt(const std::string& json, std::size_t pos) { + pos = skipWhitespace(json, pos); + if (pos >= json.size() || json[pos] != char(123)) { + throw std::runtime_error("Expected object in Polaris fixture array"); + } + + int depth = 0; + bool inString = false; + bool escaped = false; + for (std::size_t index = pos; index < json.size(); ++index) { + const char ch = json[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch == char(92)) { + escaped = true; + } else if (ch == char(34)) { + inString = false; + } + continue; + } + + if (ch == char(34)) { + inString = true; + continue; + } + if (ch == char(123)) { + ++depth; + continue; + } + if (ch == char(125)) { + --depth; + if (depth == 0) { + return json.substr(pos, index - pos + 1); + } + } + } + + throw std::runtime_error("Unterminated object in Polaris fixture array"); +} + +std::vector readObjectArray(const std::string& json, const std::string_view key) { + std::vector objects; + std::size_t pos = arrayStart(json, key) + 1; + while (pos < json.size()) { + pos = skipWhitespace(json, pos); + if (pos < json.size() && json[pos] == char(93)) { + return objects; + } + + const auto objectJson = readObjectAt(json, pos); + objects.push_back(objectJson); + pos += objectJson.size(); + pos = skipWhitespace(json, pos); + if (pos < json.size() && json[pos] == char(44)) { + ++pos; + continue; + } + if (pos < json.size() && json[pos] == char(93)) { + return objects; + } + throw std::runtime_error("Malformed object array in Polaris fixture: " + std::string(key)); + } + + throw std::runtime_error("Unterminated object array in Polaris fixture: " + std::string(key)); +} + +PolarisGameFixture parsePolarisGameFixtureJson(const std::string& json) { + const auto launchModeStart = objectStart(json, "launch_mode"); + const auto steamLaunchStart = objectStart(json, "steam_launch"); + + PolarisGameFixture game; + game.id = readString(json, "id"); + game.appId = readInt(json, "app_id"); + game.name = readString(json, "name"); + game.source = readString(json, "source"); + game.launcherSource = readString(json, "launcher_source"); + game.launcherDetail = readString(json, "launcher_detail"); + game.platform = readString(json, "platform"); + game.runtime = readString(json, "runtime"); + game.platformLabel = readString(json, "platform_label"); + game.runtimeLabel = readString(json, "runtime_label"); + game.steamAppid = readString(json, "steam_appid"); + game.category = readString(json, "category"); + game.installed = readBool(json, "installed"); + game.coverUrl = readString(json, "cover_url"); + game.genres = readStringArray(json, "genres"); + game.lastLaunched = readInt64(json, "last_launched"); + game.mangohud = readBool(json, "mangohud"); + game.hdrSupported = readBool(json, "hdr_supported"); + + game.launchMode.preferredMode = readString(json, "preferred_mode", launchModeStart); + game.launchMode.recommendedMode = readString(json, "recommended_mode", launchModeStart); + game.launchMode.allowedModes = readStringArray(json, "allowed_modes", launchModeStart); + game.launchMode.modeReason = readString(json, "mode_reason", launchModeStart); + + game.steamLaunch.available = readBool(json, "available", steamLaunchStart); + game.steamLaunch.mode = readString(json, "mode", steamLaunchStart); + game.steamLaunch.recommendedMode = readString(json, "recommended_mode", steamLaunchStart); + game.steamLaunch.allowedModes = readStringArray(json, "allowed_modes", steamLaunchStart); + game.steamLaunch.modeReason = readString(json, "mode_reason", steamLaunchStart); + + return game; +} + +} // namespace + +std::filesystem::path samplePolarisGameFixturePath() { + if (const auto* overridePath = std::getenv("NOVA_DECK_SAMPLE_GAME_FIXTURE_PATH")) { + if (overridePath[0] != char(0)) { + return std::filesystem::path(overridePath); + } + } + return std::filesystem::path(NOVA_DECK_SAMPLE_GAME_FIXTURE); +} + +std::filesystem::path samplePolarisGameLibraryFixturePath() { + if (const auto* overridePath = std::getenv("NOVA_DECK_SAMPLE_LIBRARY_FIXTURE_PATH")) { + if (overridePath[0] != char(0)) { + return std::filesystem::path(overridePath); + } + } + return std::filesystem::path(NOVA_DECK_SAMPLE_LIBRARY_FIXTURE); +} + +PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path) { + return parsePolarisGameFixtureJson(readTextFile(path)); +} + +PolarisGameFixture loadSamplePolarisGameFixture() { + return loadPolarisGameFixture(samplePolarisGameFixturePath()); +} + +PolarisGameLibraryFixture loadPolarisGameLibraryFixture(const std::filesystem::path& path) { + const auto json = readTextFile(path); + PolarisGameLibraryFixture library; + library.sourceLabel = readOptionalString(json, "fixture_source", "Shared Polaris contract fixture"); + library.readOnly = readOptionalBool(json, "read_only", true); + + for (const auto& objectJson : readObjectArray(json, "games")) { + library.games.push_back(parsePolarisGameFixtureJson(objectJson)); + } + + if (library.games.empty()) { + throw std::runtime_error("Polaris game library fixture must contain at least one game: " + path.string()); + } + + return library; +} + +PolarisGameLibraryFixture loadSamplePolarisGameLibraryFixture() { + return loadPolarisGameLibraryFixture(samplePolarisGameLibraryFixturePath()); +} + +} // namespace nova::deck diff --git a/clients/deck/src/polaris_game_fixture.h b/clients/deck/src/polaris_game_fixture.h new file mode 100644 index 00000000..2cd15f4c --- /dev/null +++ b/clients/deck/src/polaris_game_fixture.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include + +namespace nova::deck { + +struct PolarisLaunchModeFixture { + std::string preferredMode; + std::string recommendedMode; + std::vector allowedModes; + std::string modeReason; +}; + +struct PolarisSteamLaunchFixture { + bool available = false; + std::string mode; + std::string recommendedMode; + std::vector allowedModes; + std::string modeReason; +}; + +struct PolarisGameFixture { + std::string id; + int appId = 0; + std::string name; + std::string source; + std::string launcherSource; + std::string launcherDetail; + std::string platform; + std::string runtime; + std::string platformLabel; + std::string runtimeLabel; + std::string steamAppid; + std::string category; + bool installed = false; + std::string coverUrl; + std::vector genres; + std::int64_t lastLaunched = 0; + bool mangohud = false; + bool hdrSupported = false; + PolarisLaunchModeFixture launchMode; + PolarisSteamLaunchFixture steamLaunch; +}; + +struct PolarisGameLibraryFixture { + std::string sourceLabel; + bool readOnly = true; + std::vector games; +}; + +std::filesystem::path samplePolarisGameFixturePath(); +std::filesystem::path samplePolarisGameLibraryFixturePath(); +PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path); +PolarisGameFixture loadSamplePolarisGameFixture(); +PolarisGameLibraryFixture loadPolarisGameLibraryFixture(const std::filesystem::path& path); +PolarisGameLibraryFixture loadSamplePolarisGameLibraryFixture(); + +} // namespace nova::deck diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp new file mode 100644 index 00000000..dd480871 --- /dev/null +++ b/clients/deck/tests/deck_layout_test.cpp @@ -0,0 +1,336 @@ +#include "deck_layout.h" +#include "deck_gamepad.h" +#include "polaris_game_fixture.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +bool containsIpv4AddressLike(const std::string& text) { + int dotSeparatedNumberRuns = 0; + bool inDigits = false; + for (const unsigned char ch : text) { + if (std::isdigit(ch)) { + inDigits = true; + continue; + } + if (ch == char(46) && inDigits) { + ++dotSeparatedNumberRuns; + inDigits = false; + if (dotSeparatedNumberRuns >= 3) { + return true; + } + continue; + } + dotSeparatedNumberRuns = 0; + inDigits = false; + } + return false; +} +std::string readTextFile(const char* path) { + std::ifstream stream(path); + assert(stream.good()); + return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); +} + +class RecordingLocalClipboard final : public nova::deck::DeckLocalClipboard { +public: + bool publishPreviewText(std::string_view value) override { + ++writeCount; + publishedText = std::string(value); + return allowWrite; + } + + bool allowWrite = true; + int writeCount = 0; + std::string publishedText; +}; +} // namespace + +int main() { + const auto profile = nova::deck::defaultWindowProfile(); + + assert(profile.width == 1280); + assert(profile.height == 800); + assert(profile.fullscreenPreferred); + assert(profile.shellName == std::string_view("Nova Deck")); + +#ifndef NOVA_DECK_MAIN_QML_SOURCE +#error "NOVA_DECK_MAIN_QML_SOURCE must point at the Deck QML shell for layout regression checks" +#endif + const auto mainQml = readTextFile(NOVA_DECK_MAIN_QML_SOURCE); + assert(mainQml.find("anchors.margins: 56") == std::string::npos); + assert(mainQml.find("Layout.preferredWidth: 480") == std::string::npos); + assert(mainQml.find("Layout.preferredWidth: 410") == std::string::npos); + assert(mainQml.find("property int previewCopyActivationCount: 0") != std::string::npos); + assert(mainQml.find("previewCopyActivationCount += 1") != std::string::npos); + assert(mainQml.find("A pressed #") != std::string::npos); + assert(mainQml.find("target: novaGamepad") != std::string::npos); + assert(mainQml.find("onPrimaryActionPressed") != std::string::npos); + assert(mainQml.find("novaLibraryGames") != std::string::npos); + assert(mainQml.find("libraryGameRepeater") != std::string::npos); + assert(mainQml.find("Read-only Polaris library") != std::string::npos); + assert(mainQml.find("novaLaunchIntentBoundary") != std::string::npos); + assert(mainQml.find("Typed launch boundary") != std::string::npos); + assert(mainQml.find("network/process/Moonlight blocked") != std::string::npos); + + assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ + .timeMs = 10, + .value = 1, + .type = nova::deck::kDeckGamepadButtonEvent, + .number = nova::deck::kDeckGamepadPrimaryButton, + }) == nova::deck::DeckGamepadAction::PrimaryPressed); + assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ + .timeMs = 11, + .value = 0, + .type = nova::deck::kDeckGamepadButtonEvent, + .number = nova::deck::kDeckGamepadPrimaryButton, + }) == nova::deck::DeckGamepadAction::None); + assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ + .timeMs = 12, + .value = 1, + .type = static_cast(nova::deck::kDeckGamepadButtonEvent | nova::deck::kDeckGamepadInitEvent), + .number = nova::deck::kDeckGamepadPrimaryButton, + }) == nova::deck::DeckGamepadAction::None); + assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ + .timeMs = 13, + .value = 1, + .type = nova::deck::kDeckGamepadButtonEvent, + .number = static_cast(nova::deck::kDeckGamepadPrimaryButton + 1), + }) == nova::deck::DeckGamepadAction::None); + + const auto focusTargets = nova::deck::defaultLibraryFocusTargets(); + assert(focusTargets.size() >= 2); + assert(focusTargets.front().id == std::string_view("sample-game-card")); + assert(focusTargets.front().initialFocus); + assert(nova::deck::initialLibraryFocusTarget(focusTargets) == std::string_view("sample-game-card")); + assert(nova::deck::nextLibraryFocusTarget(focusTargets, "sample-game-card", nova::deck::DeckFocusDirection::Right) + == std::string_view("details-placeholder")); + assert(nova::deck::nextLibraryFocusTarget(focusTargets, "details-placeholder", nova::deck::DeckFocusDirection::Left) + == std::string_view("sample-game-card")); + + const auto emptyHosts = nova::deck::emptyHostListState(); + assert(emptyHosts.empty()); + assert(nova::deck::initialHostFocusTarget(emptyHosts) == std::string_view("host-empty-state")); + assert(nova::deck::nextHostFocusTarget(emptyHosts, "missing-host", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-empty-state")); + + const auto demoHosts = nova::deck::demoHostListState(); + assert(demoHosts.size() >= 2); + assert(demoHosts[0].id == std::string_view("host-gaming-pc")); + assert(demoHosts[0].displayName == std::string_view("Gaming PC")); + assert(demoHosts[0].statusLabel == std::string_view("Ready for local demo")); + assert(demoHosts[1].id == std::string_view("host-living-room-pc")); + assert(demoHosts[1].displayName == std::string_view("Living Room PC")); + assert(nova::deck::initialHostFocusTarget(demoHosts) == std::string_view("host-gaming-pc")); + assert(nova::deck::nextHostFocusTarget(demoHosts, "host-gaming-pc", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-living-room-pc")); + assert(nova::deck::nextHostFocusTarget(demoHosts, "host-living-room-pc", nova::deck::DeckFocusDirection::Up) + == std::string_view("host-gaming-pc")); + assert(nova::deck::nextHostFocusTarget(demoHosts, "host-living-room-pc", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-living-room-pc")); + + const auto detail = nova::deck::resolveHostDetail(demoHosts, "host-gaming-pc"); + assert(detail.id == std::string_view("host-gaming-pc")); + assert(detail.displayName == std::string_view("Gaming PC")); + assert(detail.statusLabel == std::string_view("Ready for local demo")); + assert(detail.subtitle == std::string_view("Demo host detail only — not discovered from the network.")); + + const auto launchCta = nova::deck::inertLaunchCtaFor(detail); + assert(launchCta.id == std::string_view("host-detail-launch-cta")); + assert(launchCta.label == std::string_view("Launch preview only")); + assert(launchCta.helpText == std::string_view("Display-only preview — not wired to launch, Moonlight, or a network backend.")); + assert(!launchCta.enabled); + assert(launchCta.previewStateLabel == std::string_view("Preview only — not executable")); + assert(launchCta.previewText == std::string_view("preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&state=copy-preview-only")); + + const auto launchGame = nova::deck::loadSamplePolarisGameFixture(); + const auto launchIntent = nova::deck::resolveLaunchIntent(detail, launchGame); + assert(launchIntent.targetHostId == "host-gaming-pc"); + assert(launchIntent.targetHostName == "Gaming PC"); + assert(launchIntent.gameTitle == "Portal 2"); + assert(launchIntent.sampleGameId == "game-123"); + assert(launchIntent.streamLaunchMode == "headless"); + assert(launchIntent.steamLaunchMode == "direct"); + assert(launchIntent.boundary.kind == nova::deck::DeckLaunchIntentBoundaryKind::PreviewOnly); + assert(launchIntent.boundary.id == "deck-launch-preview-only"); + assert(launchIntent.boundary.label == "Preview-only typed intent boundary"); + assert(launchIntent.boundary.previewOnly); + assert(!launchIntent.boundary.allowsNetwork); + assert(!launchIntent.boundary.allowsProcessExecution); + assert(!launchIntent.boundary.allowsMoonlight); + assert(!launchIntent.boundary.allowsHostMutation); + assert(launchIntent.boundary.reason == "Deck shell may build copyable preview text, but launch execution is blocked."); + assert(!launchIntent.executable); + assert(launchIntent.safetyLabel == "Preview only — not executable"); + assert(!nova::deck::canExecuteLaunchIntent(launchIntent)); + + const auto commandPreview = nova::deck::fakeLaunchCommandPreviewFor(launchIntent); + assert(commandPreview.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&state=copy-preview-only"); + assert(commandPreview.stateLabel == "Preview only — not executable"); + assert(commandPreview.boundaryId == "deck-launch-preview-only"); + assert(commandPreview.boundaryLabel == "Preview-only typed intent boundary"); + assert(commandPreview.copyOnly); + assert(!commandPreview.executable); + assert(!commandPreview.networkAllowed); + assert(!commandPreview.processExecutionAllowed); + assert(!commandPreview.moonlightAllowed); + assert(!commandPreview.hostMutationAllowed); + assert(commandPreview.text.find("moonlight") == std::string::npos); + assert(commandPreview.text.find("http") == std::string::npos); + assert(commandPreview.text.find("ssh") == std::string::npos); + assert(commandPreview.text.find(";") == std::string::npos); + assert(commandPreview.text.find("&&") == std::string::npos); + assert(commandPreview.text.find("|") == std::string::npos); + assert(commandPreview.text.find(std::string{"/"} + "home/") == std::string::npos); + assert(commandPreview.text.find("Users") == std::string::npos); + + const auto copyAction = nova::deck::copyLaunchPreviewActionFor(commandPreview); + assert(copyAction.id == std::string_view("host-detail-copy-preview")); + assert(copyAction.label == std::string_view("Copy preview text")); + assert(copyAction.previewText == commandPreview.text); + assert(copyAction.idleStatusLabel == "Copy action is preview-only and not executable."); + assert(copyAction.successToast == "Preview text copied for inspection only — still not executable."); + assert(copyAction.inertToast == "No preview text to copy — preview-only action stayed inert."); + assert(copyAction.copyOnly); + assert(copyAction.uiLocalClipboardOnly); + assert(!copyAction.executable); + assert(copyAction.enabled); + assert(!containsIpv4AddressLike(copyAction.previewText)); + assert(copyAction.previewText.find(";") == std::string::npos); + assert(copyAction.previewText.find("&&") == std::string::npos); + assert(copyAction.previewText.find("|") == std::string::npos); + + RecordingLocalClipboard recordingClipboard; + const auto localClipboardResult = nova::deck::copyLaunchPreviewToLocalClipboard(copyAction, recordingClipboard); + assert(localClipboardResult.copied); + assert(localClipboardResult.previewText == commandPreview.text); + assert(localClipboardResult.statusLabel == copyAction.successToast); + assert(localClipboardResult.toastLabel == copyAction.successToast); + assert(recordingClipboard.writeCount == 1); + assert(recordingClipboard.publishedText == commandPreview.text); + + const auto copiedResult = nova::deck::activateLaunchPreviewCopy(copyAction); + assert(copiedResult.copied); + assert(copiedResult.previewText == commandPreview.text); + assert(copiedResult.statusLabel == "Preview text copied for inspection only — still not executable."); + assert(copiedResult.toastLabel == "Preview text copied for inspection only — still not executable."); + assert(copiedResult.statusLabel.find("Preview") != std::string::npos); + assert(copiedResult.statusLabel.find("not executable") != std::string::npos); + assert(copiedResult.previewText.find("moonlight") == std::string::npos); + assert(copiedResult.previewText.find("http") == std::string::npos); + assert(copiedResult.previewText.find("ssh") == std::string::npos); + assert(copiedResult.previewText.find(";") == std::string::npos); + assert(copiedResult.previewText.find("&&") == std::string::npos); + assert(copiedResult.previewText.find("|") == std::string::npos); + assert(!containsIpv4AddressLike(copiedResult.previewText)); + + const auto emptyCopyAction = nova::deck::copyLaunchPreviewActionFor(nova::deck::DeckLaunchPreview{}); + assert(emptyCopyAction.previewText.empty()); + assert(!emptyCopyAction.enabled); + assert(emptyCopyAction.copyOnly); + assert(emptyCopyAction.uiLocalClipboardOnly); + assert(!emptyCopyAction.executable); + assert(emptyCopyAction.idleStatusLabel == "No preview text to copy — preview-only action stayed inert."); + assert(emptyCopyAction.inertToast == "No preview text to copy — preview-only action stayed inert."); + + RecordingLocalClipboard inertRecordingClipboard; + const auto inertLocalClipboardResult = nova::deck::copyLaunchPreviewToLocalClipboard(emptyCopyAction, inertRecordingClipboard); + assert(!inertLocalClipboardResult.copied); + assert(inertLocalClipboardResult.previewText.empty()); + assert(inertLocalClipboardResult.statusLabel == emptyCopyAction.inertToast); + assert(inertLocalClipboardResult.toastLabel == emptyCopyAction.inertToast); + assert(inertRecordingClipboard.writeCount == 0); + assert(inertRecordingClipboard.publishedText.empty()); + + const auto inertCopiedResult = nova::deck::activateLaunchPreviewCopy(emptyCopyAction); + assert(!inertCopiedResult.copied); + assert(inertCopiedResult.previewText.empty()); + assert(inertCopiedResult.statusLabel == "No preview text to copy — preview-only action stayed inert."); + assert(inertCopiedResult.toastLabel == "No preview text to copy — preview-only action stayed inert."); + + const auto detailFocus = nova::deck::hostDetailFocusTargets(detail, launchCta, copyAction); + assert(detailFocus.size() == 3); + assert(detailFocus[0].id == std::string_view("host-detail-panel")); + assert(detailFocus[1].id == std::string_view("host-detail-launch-cta")); + assert(detailFocus[2].id == std::string_view("host-detail-copy-preview")); + assert(detailFocus[2].label == std::string_view("Copy preview text")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-panel", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-detail-launch-cta")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-launch-cta", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-detail-copy-preview")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-copy-preview", nova::deck::DeckFocusDirection::Up) + == std::string_view("host-detail-launch-cta")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-launch-cta", nova::deck::DeckFocusDirection::Up) + == std::string_view("host-detail-panel")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-copy-preview", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-detail-copy-preview")); + + assert(nova::deck::isDeckNativeAspect(1280, 800)); + assert(nova::deck::isDeckNativeAspect(2560, 1600)); + assert(!nova::deck::isDeckNativeAspect(1920, 1080)); + assert(!nova::deck::isDeckNativeAspect(0, 800)); + + const auto compiledFixturePath = nova::deck::samplePolarisGameFixturePath(); + assert(!compiledFixturePath.empty()); + setenv("NOVA_DECK_SAMPLE_GAME_FIXTURE_PATH", compiledFixturePath.c_str(), 1); + assert(nova::deck::samplePolarisGameFixturePath() == compiledFixturePath); + + const auto libraryFixturePath = nova::deck::samplePolarisGameLibraryFixturePath(); + assert(!libraryFixturePath.empty()); + setenv("NOVA_DECK_SAMPLE_LIBRARY_FIXTURE_PATH", libraryFixturePath.c_str(), 1); + assert(nova::deck::samplePolarisGameLibraryFixturePath() == libraryFixturePath); + + const auto library = nova::deck::loadSamplePolarisGameLibraryFixture(); + unsetenv("NOVA_DECK_SAMPLE_LIBRARY_FIXTURE_PATH"); + assert(library.readOnly); + assert(library.sourceLabel == "Shared Polaris contract fixture"); + assert(library.games.size() == 2); + assert(library.games[0].name == "Portal 2"); + assert(library.games[1].name == "Hades"); + assert(library.games[1].steamAppid == "1145360"); + + const auto libraryCards = nova::deck::libraryGameCardsFor(library); + assert(libraryCards.size() == 2); + assert(libraryCards[0].id == "game-123"); + assert(libraryCards[0].title == "Portal 2"); + assert(libraryCards[0].sourceRuntimeLabel == "Steam · Linux · Proton"); + assert(libraryCards[0].launchModeLabel == "Stream: headless · Steam: direct"); + assert(libraryCards[0].initialFocus); + assert(libraryCards[1].id == "game-456"); + assert(libraryCards[1].title == "Hades"); + assert(libraryCards[1].sourceRuntimeLabel == "Steam · Linux · Proton"); + assert(libraryCards[1].launchModeLabel == "Stream: virtual_display · Steam: big-picture"); + assert(!libraryCards[1].initialFocus); + + const auto hadesLaunchCta = nova::deck::inertLaunchCtaFor(detail, library.games[1]); + assert(hadesLaunchCta.previewText == std::string_view("preview://nova-deck/launch?host=host-gaming-pc&game=Hades&state=copy-preview-only")); + assert(!hadesLaunchCta.enabled); + + const auto game = nova::deck::loadSamplePolarisGameFixture(); + unsetenv("NOVA_DECK_SAMPLE_GAME_FIXTURE_PATH"); + assert(game.id == "game-123"); + assert(game.appId == 456); + assert(game.name == "Portal 2"); + assert(game.source == "steam"); + assert(game.platform == "linux"); + assert(game.runtime == "proton"); + assert(game.steamAppid == "620"); + assert(game.installed); + assert(game.genres.size() == 2); + assert(game.genres[0] == "Action"); + assert(game.genres[1] == "Puzzle"); + assert(game.launchMode.preferredMode == "virtual_display"); + assert(game.launchMode.recommendedMode == "headless"); + assert(game.launchMode.allowedModes.size() == 2); + assert(game.steamLaunch.available); + assert(game.steamLaunch.mode == "big-picture"); + assert(game.steamLaunch.recommendedMode == "direct"); + + return 0; +} diff --git a/docs/plans/2026-06-12-steam-deck-port-first-slice.md b/docs/plans/2026-06-12-steam-deck-port-first-slice.md new file mode 100644 index 00000000..af51d32b --- /dev/null +++ b/docs/plans/2026-06-12-steam-deck-port-first-slice.md @@ -0,0 +1,133 @@ +# Steam Deck Port First Slice Plan + +**Goal:** start Nova's native Steam Deck port with a buildable client scaffold, a strict platform boundary, and a small shared-core extraction path that does not destabilize Android. + +**Architecture:** Nova should not try to ship the Android APK on SteamOS. The Deck client lives under `clients/deck/`, uses a native Linux shell, and consumes shared models/contracts as they are extracted from the Android reference implementation. Android remains the shipping client while Deck work proves one vertical slice at a time. + +**Tech Stack:** CMake, C++20, Qt 6/QML for the experimental Deck shell, existing `moonlight-common-c` as the streaming-protocol anchor, and focused JVM/Android tests for extracted shared contracts. + +--- + +## Current repo facts + +- `docs/steam_deck_native_port_study.md` already recommends a native SteamOS client in `clients/deck/` instead of direct Android porting. +- `docs/multi_platform_monorepo.md` defines `clients/deck/`, `clients/ios/`, `shared/models/`, `shared/polaris/`, and `shared/stream-core/` as the long-term repo shape. +- `shared/` currently contains intent docs only. It is not a buildable shared module yet. +- Android Polaris contracts currently live in `app/src/main/java/com/papi/nova/api/` and are the first candidates for shared extraction. + +## Non-goals for this slice + +- No public release promise for Steam Deck yet. +- No Android feature parity claim. +- No Waydroid/APK-on-Deck path as the product plan. +- No invasive refactor of Android streaming code before a Deck-native shell and backend spike prove direction. + +## Task 1: Add native Deck scaffold + +**Objective:** create the first buildable `clients/deck/` project without touching Android behavior. + +**Files:** +- Create: `clients/deck/CMakeLists.txt` +- Create: `clients/deck/README.md` +- Create: `clients/deck/src/deck_layout.h` +- Create: `clients/deck/src/deck_layout.cpp` +- Create: `clients/deck/tests/deck_layout_test.cpp` + +**Steps:** +1. Add a CMake project that builds a tiny `nova_deck_core` library. +2. Add a focused test for Deck defaults: 1280x800, fullscreen-preferred, `Nova Deck` shell name, and 16:10 aspect detection. +3. Watch the test fail before implementing the core. +4. Implement the minimal core constants and aspect helper. +5. Build and run the CTest target on Linux. + +**Gate:** + +```bash +cmake -S clients/deck -B build/deck -DNOVA_DECK_BUILD_QT_SHELL=OFF +cmake --build build/deck +ctest --test-dir build/deck --output-on-failure +``` + +## Task 2: Add minimal Qt/QML shell proof + +**Objective:** prove the native shell can boot as a Deck-shaped fullscreen-oriented app. + +**Files:** +- Modify: `clients/deck/CMakeLists.txt` +- Create: `clients/deck/src/main.cpp` +- Create: `clients/deck/qml/Main.qml` + +**Steps:** +1. Add an optional Qt 6 Quick executable named `nova-deck`. +2. Bind the C++ Deck defaults into the window size/title. +3. Render a simple controller-first placeholder: title, status, and first-actions column. +4. Keep Qt optional so CI or contributor machines without Qt can still build `nova_deck_core` and tests. +5. Build on Linux with Qt installed. + +**Gate:** + +```bash +cmake -S clients/deck -B build/deck +cmake --build build/deck +ctest --test-dir build/deck --output-on-failure +``` + +## Task 3: Extract first Polaris contract candidate + +**Objective:** identify and extract one small pure contract from Android into shared code without changing runtime behavior. + +**Files:** +- Candidate source: `app/src/main/java/com/papi/nova/api/PolarisGame.kt` +- Candidate shared target: `shared/polaris/` or a future Gradle module under that path +- Candidate tests: existing `app/src/test/java/com/papi/nova/...` plus new shared tests once the module exists + +**Steps:** +1. Start with a pure behavior such as Steam launch mode normalization or launch-mode availability. +2. Write a failing test around the shared API shape. +3. Move only the pure model/helper logic. Do not move Android context, bitmap, OkHttp, certificate store, or UI references. +4. Keep Android importing the extracted contract so Android behavior stays stable. +5. Run focused Android unit tests and the shared test. + +**Gate:** + +```bash +./gradlew -PnovaAbis=x86_64 :app:testNonRoot_gameDebugUnitTest +``` + +## Task 4: Choose Deck streaming backend path with evidence + +**Objective:** answer whether to build directly on `moonlight-common-c` or adapt an existing Linux Moonlight client before investing in UI polish. + +**Files:** +- Update: `docs/steam_deck_native_port_study.md` +- Optional create: `clients/deck/spikes/streaming-backend-notes.md` + +**Steps:** +1. Spike direct `moonlight-common-c` integration feasibility for Linux decode/audio/input handoff. +2. Spike existing Linux Moonlight client adaptation cost. +3. Record build dependencies, code ownership risks, latency/control tradeoffs, and how each path preserves Nova's Polaris-aware UX. +4. Pick the backend for the next vertical slice. + +**Gate:** written decision with one recommended path and rejected alternatives. + +## Task 5: First real Deck vertical slice + +**Objective:** make the Deck shell do one meaningful Nova thing before building a giant beautiful empty canoe. + +**Scope:** manual host add or mocked local host list first, then Polaris capabilities/library probe once pairing material is available. + +**Steps:** +1. Add a Deck `HostDiscovery`/`HostStore` boundary. +2. Add a fake/in-memory host provider for UI and test work. +3. Render a controller-first host list at 1280x800. +4. Add a no-network test path so layout/control work can run on CI. +5. Only then wire live LAN discovery or pairing. + +**Gate:** Deck app opens to a host list or explicit empty state with controller-friendly actions. + +## Verification discipline + +- Android must remain buildable after every shared extraction. +- Deck core must keep a no-Qt test path for fast verification. +- Linux shell verification should run on an actual Linux host, not only macOS. +- Steam Deck hardware smoke is required before calling anything more than a scaffold/spike.