From 0f07bbf9195923f191caf3b34501cd9445b4c10b Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:31:13 -0400 Subject: [PATCH 01/15] feat(deck): refresh Steam Deck scaffold on shared Polaris master --- clients/deck/CMakeLists.txt | 73 +++++++ clients/deck/README.md | 45 ++++- .../deck/fixtures/sample_polaris_game.json | 33 +++ clients/deck/qml/Main.qml | 137 +++++++++++++ clients/deck/src/deck_layout.cpp | 22 ++ clients/deck/src/deck_layout.h | 18 ++ clients/deck/src/main.cpp | 64 ++++++ clients/deck/src/polaris_game_fixture.cpp | 190 ++++++++++++++++++ clients/deck/src/polaris_game_fixture.h | 52 +++++ clients/deck/tests/deck_layout_test.cpp | 40 ++++ .../2026-06-12-steam-deck-port-first-slice.md | 133 ++++++++++++ 11 files changed, 800 insertions(+), 7 deletions(-) create mode 100644 clients/deck/CMakeLists.txt create mode 100644 clients/deck/fixtures/sample_polaris_game.json create mode 100644 clients/deck/qml/Main.qml create mode 100644 clients/deck/src/deck_layout.cpp create mode 100644 clients/deck/src/deck_layout.h create mode 100644 clients/deck/src/main.cpp create mode 100644 clients/deck/src/polaris_game_fixture.cpp create mode 100644 clients/deck/src/polaris_game_fixture.h create mode 100644 clients/deck/tests/deck_layout_test.cpp create mode 100644 docs/plans/2026-06-12-steam-deck-port-first-slice.md diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt new file mode 100644 index 00000000..7797cdd5 --- /dev/null +++ b/clients/deck/CMakeLists.txt @@ -0,0 +1,73 @@ +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_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\" +) + +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) + 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) + 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..d710f4a5 100644 --- a/clients/deck/README.md +++ b/clients/deck/README.md @@ -1,8 +1,13 @@ # 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. Planned role: @@ -11,15 +16,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/qml/Main.qml b/clients/deck/qml/Main.qml new file mode 100644 index 00000000..9bc1be54 --- /dev/null +++ b/clients/deck/qml/Main.qml @@ -0,0 +1,137 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + + width: novaDeckWidth + height: novaDeckHeight + visible: true + title: novaDeckShellName + color: "#070B18" + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: "#111936" } + GradientStop { position: 1.0; color: "#070B18" } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 56 + spacing: 24 + + 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: 24 + + Rectangle { + Layout.preferredWidth: 480 + Layout.preferredHeight: 220 + radius: 22 + color: "#151D39" + border.color: "#7C73FF" + border.width: 3 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 10 + + Label { + text: "Shared Polaris DTO fixture" + color: "#7C88B8" + font.pixelSize: 18 + } + + Label { + text: novaSampleGameName + color: "#E9ECFF" + font.pixelSize: 34 + font.bold: true + } + + Label { + text: novaSampleGameSource + " · " + novaSampleGameRuntime + color: "#B8C2F0" + font.pixelSize: 20 + } + + Label { + text: "Stream: " + novaSampleGameLaunchMode + " · Steam: " + novaSampleGameSteamMode + color: "#A8B0D8" + font.pixelSize: 18 + } + } + } + + ColumnLayout { + spacing: 12 + + Label { + text: "Controller placeholder scope" + color: "#E9ECFF" + font.pixelSize: 28 + font.bold: true + } + + Repeater { + model: [ + "Library card shape is visible at Deck resolution", + "Focus and A button details are reserved for the next slice", + "Shell stays library-first and avoids streamer side effects" + ] + + delegate: Rectangle { + Layout.preferredWidth: 650 + Layout.preferredHeight: 56 + radius: 14 + color: "#1B2445" + border.color: index === 0 ? "#7C73FF" : "#39466F" + border.width: 2 + + Label { + anchors.centerIn: parent + text: modelData + color: "#E9ECFF" + font.pixelSize: 20 + } + } + } + } + } + + 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_layout.cpp b/clients/deck/src/deck_layout.cpp new file mode 100644 index 00000000..645828ff --- /dev/null +++ b/clients/deck/src/deck_layout.cpp @@ -0,0 +1,22 @@ +#include "deck_layout.h" + +namespace nova::deck { + +DeckWindowProfile defaultWindowProfile() { + return DeckWindowProfile{ + .width = 1280, + .height = 800, + .fullscreenPreferred = true, + .shellName = "Nova Deck", + }; +} + +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..b53e7f77 --- /dev/null +++ b/clients/deck/src/deck_layout.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace nova::deck { + +struct DeckWindowProfile { + int width; + int height; + bool fullscreenPreferred; + std::string_view shellName; +}; + +DeckWindowProfile defaultWindowProfile(); + +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..f50db02e --- /dev/null +++ b/clients/deck/src/main.cpp @@ -0,0 +1,64 @@ +#include "deck_layout.h" +#include "polaris_game_fixture.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +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); +} +} // namespace + +int main(int argc, char *argv[]) { + QGuiApplication app(argc, argv); + + const auto profile = nova::deck::defaultWindowProfile(); + const auto sampleGame = nova::deck::loadSamplePolarisGameFixture(); + + 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("novaSampleGameName", toQString(sampleGame.name)); + engine.rootContext()->setContextProperty("novaSampleGameSource", toQString(sampleGame.source)); + engine.rootContext()->setContextProperty("novaSampleGameRuntime", toQString(sampleGame.runtime)); + engine.rootContext()->setContextProperty("novaSampleGameLaunchMode", toQString(sampleGame.launchMode.recommendedMode)); + engine.rootContext()->setContextProperty("novaSampleGameSteamMode", toQString(sampleGame.steamLaunch.recommendedMode)); + + 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(); +} diff --git a/clients/deck/src/polaris_game_fixture.cpp b/clients/deck/src/polaris_game_fixture.cpp new file mode 100644 index 00000000..b4bbaa81 --- /dev/null +++ b/clients/deck/src/polaris_game_fixture.cpp @@ -0,0 +1,190 @@ +#include "polaris_game_fixture.h" + +#include +#include +#include +#include +#include + +#ifndef NOVA_DECK_SAMPLE_GAME_FIXTURE +#define NOVA_DECK_SAMPLE_GAME_FIXTURE "../fixtures/sample_polaris_game.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 game 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 game 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 game 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 game fixture"); + } + + const auto end = json.find(char(34), pos + 1); + if (end == std::string::npos) { + throw std::runtime_error("Unterminated string in Polaris game fixture"); + } + + return json.substr(pos + 1, end - pos - 1); +} + +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)); +} + +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 game 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 game 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 game fixture: " + std::string(key)); +} + +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 game 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 game fixture: " + std::string(key)); + } + if (json[pos] == char(44)) { + ++pos; + } + } + + throw std::runtime_error("Unterminated array in Polaris game 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 game fixture: " + std::string(key)); + } + return pos; +} + +} // namespace + +std::filesystem::path samplePolarisGameFixturePath() { + return std::filesystem::path(NOVA_DECK_SAMPLE_GAME_FIXTURE); +} + +PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path) { + const auto json = readTextFile(path); + 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; +} + +PolarisGameFixture loadSamplePolarisGameFixture() { + return loadPolarisGameFixture(samplePolarisGameFixturePath()); +} + +} // 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..2b1437e0 --- /dev/null +++ b/clients/deck/src/polaris_game_fixture.h @@ -0,0 +1,52 @@ +#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; +}; + +std::filesystem::path samplePolarisGameFixturePath(); +PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path); +PolarisGameFixture loadSamplePolarisGameFixture(); + +} // 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..9e2e1105 --- /dev/null +++ b/clients/deck/tests/deck_layout_test.cpp @@ -0,0 +1,40 @@ +#include "deck_layout.h" +#include "polaris_game_fixture.h" + +#include +#include + +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")); + + 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 game = nova::deck::loadSamplePolarisGameFixture(); + 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. From 482e9c0c8ca5a3ae6b10fc269d9be646c1287a15 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:21:15 -0400 Subject: [PATCH 02/15] feat(deck): add controller focus navigation smoke --- clients/deck/qml/Main.qml | 220 ++++++++++++++---------- clients/deck/src/deck_layout.cpp | 63 +++++++ clients/deck/src/deck_layout.h | 25 +++ clients/deck/tests/deck_layout_test.cpp | 10 ++ 4 files changed, 226 insertions(+), 92 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 9bc1be54..302e7721 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -19,119 +19,155 @@ ApplicationWindow { } } - ColumnLayout { + FocusScope { + id: libraryFocusScope anchors.fill: parent - anchors.margins: 56 - spacing: 24 - - Label { - text: novaDeckShellName - color: "#E9ECFF" - font.pixelSize: 48 - font.bold: true - } + focus: true + Component.onCompleted: sampleGameCard.forceActiveFocus() - Label { - text: "Controller-first Steam Deck shell scaffold" - color: "#A8B0D8" - font.pixelSize: 24 - } + ColumnLayout { + anchors.fill: parent + anchors.margins: 56 + spacing: 24 - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 2 - color: "#7C73FF" - opacity: 0.65 - } + Label { + text: novaDeckShellName + color: "#E9ECFF" + font.pixelSize: 48 + font.bold: true + } - RowLayout { - Layout.fillWidth: true - spacing: 24 + Label { + text: "Controller-first Steam Deck shell scaffold" + color: "#A8B0D8" + font.pixelSize: 24 + } Rectangle { - Layout.preferredWidth: 480 - Layout.preferredHeight: 220 - radius: 22 - color: "#151D39" - border.color: "#7C73FF" - border.width: 3 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 24 - spacing: 10 - - Label { - text: "Shared Polaris DTO fixture" - color: "#7C88B8" - font.pixelSize: 18 - } + Layout.fillWidth: true + Layout.preferredHeight: 2 + color: "#7C73FF" + opacity: 0.65 + } - Label { - text: novaSampleGameName - color: "#E9ECFF" - font.pixelSize: 34 - font.bold: true - } + RowLayout { + Layout.fillWidth: true + spacing: 24 + + Rectangle { + id: sampleGameCard + objectName: "sample-game-card" + Layout.preferredWidth: 480 + Layout.preferredHeight: 220 + radius: 22 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#7C73FF" + border.width: activeFocus ? 5 : 3 + focus: true + activeFocusOnTab: true + KeyNavigation.right: detailsPlaceholder + Keys.onRightPressed: detailsPlaceholder.forceActiveFocus() + Keys.onDownPressed: detailsPlaceholder.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 10 - Label { - text: novaSampleGameSource + " · " + novaSampleGameRuntime - color: "#B8C2F0" - font.pixelSize: 20 - } + Label { + text: "Shared Polaris DTO fixture" + color: "#7C88B8" + font.pixelSize: 18 + } - Label { - text: "Stream: " + novaSampleGameLaunchMode + " · Steam: " + novaSampleGameSteamMode - color: "#A8B0D8" - font.pixelSize: 18 - } - } - } + Label { + text: novaSampleGameName + color: "#E9ECFF" + font.pixelSize: 34 + font.bold: true + } - ColumnLayout { - spacing: 12 + Label { + text: novaSampleGameSource + " · " + novaSampleGameRuntime + color: "#B8C2F0" + font.pixelSize: 20 + } - Label { - text: "Controller placeholder scope" - color: "#E9ECFF" - font.pixelSize: 28 - font.bold: true + Label { + text: "Stream: " + novaSampleGameLaunchMode + " · Steam: " + novaSampleGameSteamMode + color: "#A8B0D8" + font.pixelSize: 18 + } + } } - Repeater { - model: [ - "Library card shape is visible at Deck resolution", - "Focus and A button details are reserved for the next slice", - "Shell stays library-first and avoids streamer side effects" - ] + ColumnLayout { + spacing: 12 - delegate: Rectangle { + Rectangle { + id: detailsPlaceholder + objectName: "details-placeholder" Layout.preferredWidth: 650 - Layout.preferredHeight: 56 - radius: 14 - color: "#1B2445" - border.color: index === 0 ? "#7C73FF" : "#39466F" - border.width: 2 - - Label { - anchors.centerIn: parent - text: modelData - color: "#E9ECFF" - font.pixelSize: 20 + Layout.preferredHeight: 220 + radius: 22 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + focus: true + activeFocusOnTab: true + KeyNavigation.left: sampleGameCard + Keys.onLeftPressed: sampleGameCard.forceActiveFocus() + Keys.onUpPressed: sampleGameCard.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 12 + + Label { + text: "Controller placeholder scope" + color: "#E9ECFF" + font.pixelSize: 28 + font.bold: true + } + + Repeater { + model: [ + "Initial focus starts on the library card", + "D-pad right moves focus to this details placeholder", + "D-pad left returns focus to the library card" + ] + + delegate: Rectangle { + Layout.preferredWidth: 594 + Layout.preferredHeight: 44 + radius: 14 + color: "#1B2445" + border.color: index === 0 ? "#7C73FF" : "#39466F" + border.width: 2 + + Label { + anchors.centerIn: parent + text: modelData + color: "#E9ECFF" + font.pixelSize: 18 + } + } + } } } } } - } - Item { Layout.fillHeight: true } + 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 + 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_layout.cpp b/clients/deck/src/deck_layout.cpp index 645828ff..db5949c4 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -1,5 +1,7 @@ #include "deck_layout.h" +#include + namespace nova::deck { DeckWindowProfile defaultWindowProfile() { @@ -11,6 +13,67 @@ DeckWindowProfile defaultWindowProfile() { }; } +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; +} + bool isDeckNativeAspect(const int width, const int height) { if (width <= 0 || height <= 0) { return false; diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index b53e7f77..72ce2156 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace nova::deck { @@ -11,8 +12,32 @@ struct DeckWindowProfile { std::string_view shellName; }; +struct DeckFocusTarget { + std::string_view id; + std::string_view label; + int row; + int column; + bool initialFocus; +}; + +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); + bool isDeckNativeAspect(int width, int height); } // namespace nova::deck diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 9e2e1105..f7e16e34 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -12,6 +12,16 @@ int main() { assert(profile.fullscreenPreferred); assert(profile.shellName == std::string_view("Nova Deck")); + 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")); + assert(nova::deck::isDeckNativeAspect(1280, 800)); assert(nova::deck::isDeckNativeAspect(2560, 1600)); assert(!nova::deck::isDeckNativeAspect(1920, 1080)); From f456b3cfa35f29607898a19bf576d57d68d6f753 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:20:22 -0400 Subject: [PATCH 03/15] feat(deck): add fake host list states --- clients/deck/qml/Main.qml | 127 ++++++++++++++++++++++-- clients/deck/src/deck_layout.cpp | 71 +++++++++++++ clients/deck/src/deck_layout.h | 19 ++++ clients/deck/src/main.cpp | 19 ++++ clients/deck/tests/deck_layout_test.cpp | 21 ++++ 5 files changed, 250 insertions(+), 7 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 302e7721..64fcafa2 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -23,7 +23,13 @@ ApplicationWindow { id: libraryFocusScope anchors.fill: parent focus: true - Component.onCompleted: sampleGameCard.forceActiveFocus() + Component.onCompleted: Qt.callLater(function() { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + }) ColumnLayout { anchors.fill: parent @@ -54,6 +60,106 @@ ApplicationWindow { Layout.fillWidth: true spacing: 24 + ColumnLayout { + Layout.preferredWidth: 480 + spacing: 12 + + 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: 480 + Layout.preferredHeight: visible ? 132 : 0 + radius: 20 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + focus: visible + activeFocusOnTab: visible + KeyNavigation.right: sampleGameCard + Keys.onRightPressed: sampleGameCard.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 8 + + Label { + text: "No demo hosts yet" + color: "#E9ECFF" + font.pixelSize: 24 + font.bold: true + } + + Label { + text: "Empty host state is focusable and deterministic." + color: "#A8B0D8" + font.pixelSize: 17 + } + } + } + + Repeater { + id: hostRepeater + model: novaDemoHosts + + delegate: Rectangle { + required property int index + required property var modelData + + objectName: modelData.id + Layout.preferredWidth: 480 + Layout.preferredHeight: 116 + radius: 20 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#7C73FF" + border.width: activeFocus ? 5 : 3 + focus: modelData.initialFocus + activeFocusOnTab: true + KeyNavigation.right: sampleGameCard + Keys.onRightPressed: sampleGameCard.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: 20 + spacing: 6 + + Label { + text: modelData.displayName + color: "#E9ECFF" + font.pixelSize: 26 + font.bold: true + } + + Label { + text: modelData.statusLabel + color: "#B8C2F0" + font.pixelSize: 18 + } + } + } + } + } + Rectangle { id: sampleGameCard objectName: "sample-game-card" @@ -68,6 +174,13 @@ ApplicationWindow { KeyNavigation.right: detailsPlaceholder Keys.onRightPressed: detailsPlaceholder.forceActiveFocus() Keys.onDownPressed: detailsPlaceholder.forceActiveFocus() + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } ColumnLayout { anchors.fill: parent @@ -107,7 +220,7 @@ ApplicationWindow { Rectangle { id: detailsPlaceholder objectName: "details-placeholder" - Layout.preferredWidth: 650 + Layout.preferredWidth: 410 Layout.preferredHeight: 220 radius: 22 color: activeFocus ? "#202B55" : "#151D39" @@ -133,13 +246,13 @@ ApplicationWindow { Repeater { model: [ - "Initial focus starts on the library card", - "D-pad right moves focus to this details placeholder", - "D-pad left returns focus to the library card" + "Initial focus starts on the first demo host", + "D-pad down moves through host rows", + "D-pad right enters the library/details lane" ] delegate: Rectangle { - Layout.preferredWidth: 594 + Layout.preferredWidth: 354 Layout.preferredHeight: 44 radius: 14 color: "#1B2445" @@ -150,7 +263,7 @@ ApplicationWindow { anchors.centerIn: parent text: modelData color: "#E9ECFF" - font.pixelSize: 18 + font.pixelSize: 16 } } } diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index db5949c4..9d4e8cb6 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -74,6 +74,77 @@ std::string_view nextLibraryFocusTarget( 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::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; +} + bool isDeckNativeAspect(const int width, const int height) { if (width <= 0 || height <= 0) { return false; diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 72ce2156..5efe12a7 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -20,6 +20,14 @@ struct DeckFocusTarget { bool initialFocus; }; +struct DeckHostListItem { + std::string_view id; + std::string_view displayName; + std::string_view statusLabel; + int row; + bool initialFocus; +}; + enum class DeckFocusDirection { Left, Right, @@ -38,6 +46,17 @@ std::string_view nextLibraryFocusTarget( std::string_view currentId, DeckFocusDirection direction); +std::vector emptyHostListState(); + +std::vector demoHostListState(); + +std::string_view initialHostFocusTarget(const std::vector& hosts); + +std::string_view nextHostFocusTarget( + const std::vector& hosts, + 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 index f50db02e..4fd628dd 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include @@ -18,6 +20,19 @@ QString toQString(const std::string_view value) { 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; +} } // namespace int main(int argc, char *argv[]) { @@ -25,6 +40,7 @@ int main(int argc, char *argv[]) { const auto profile = nova::deck::defaultWindowProfile(); const auto sampleGame = nova::deck::loadSamplePolarisGameFixture(); + const auto demoHosts = nova::deck::demoHostListState(); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); @@ -36,6 +52,9 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaSampleGameRuntime", toQString(sampleGame.runtime)); engine.rootContext()->setContextProperty("novaSampleGameLaunchMode", toQString(sampleGame.launchMode.recommendedMode)); engine.rootContext()->setContextProperty("novaSampleGameSteamMode", toQString(sampleGame.steamLaunch.recommendedMode)); + engine.rootContext()->setContextProperty("novaDemoHosts", toHostModel(demoHosts)); + 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"); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index f7e16e34..ee371cbf 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -22,6 +22,27 @@ int main() { 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")); + assert(nova::deck::isDeckNativeAspect(1280, 800)); assert(nova::deck::isDeckNativeAspect(2560, 1600)); assert(!nova::deck::isDeckNativeAspect(1920, 1080)); From bdf0d8e96111a6edcc1ead271433b2dbd530b718 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 09:24:42 -0400 Subject: [PATCH 04/15] feat(deck): add inert host launch affordance --- clients/deck/qml/Main.qml | 121 ++++++++++++++++-------- clients/deck/src/deck_layout.cpp | 78 +++++++++++++++ clients/deck/src/deck_layout.h | 25 +++++ clients/deck/src/main.cpp | 22 +++++ clients/deck/tests/deck_layout_test.cpp | 21 ++++ 5 files changed, 230 insertions(+), 37 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 64fcafa2..eee1aba4 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -83,8 +83,8 @@ ApplicationWindow { border.width: activeFocus ? 5 : 2 focus: visible activeFocusOnTab: visible - KeyNavigation.right: sampleGameCard - Keys.onRightPressed: sampleGameCard.forceActiveFocus() + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() ColumnLayout { anchors.fill: parent @@ -123,8 +123,8 @@ ApplicationWindow { border.width: activeFocus ? 5 : 3 focus: modelData.initialFocus activeFocusOnTab: true - KeyNavigation.right: sampleGameCard - Keys.onRightPressed: sampleGameCard.forceActiveFocus() + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() Keys.onDownPressed: { const next = hostRepeater.itemAt(index + 1) if (next !== null) { @@ -171,9 +171,9 @@ ApplicationWindow { border.width: activeFocus ? 5 : 3 focus: true activeFocusOnTab: true - KeyNavigation.right: detailsPlaceholder - Keys.onRightPressed: detailsPlaceholder.forceActiveFocus() - Keys.onDownPressed: detailsPlaceholder.forceActiveFocus() + KeyNavigation.right: hostDetailPanel + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + Keys.onDownPressed: hostDetailPanel.forceActiveFocus() Keys.onLeftPressed: { if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() @@ -218,8 +218,8 @@ ApplicationWindow { spacing: 12 Rectangle { - id: detailsPlaceholder - objectName: "details-placeholder" + id: hostDetailPanel + objectName: "host-detail-panel" Layout.preferredWidth: 410 Layout.preferredHeight: 220 radius: 22 @@ -228,44 +228,91 @@ ApplicationWindow { border.width: activeFocus ? 5 : 2 focus: true activeFocusOnTab: true - KeyNavigation.left: sampleGameCard - Keys.onLeftPressed: sampleGameCard.forceActiveFocus() - Keys.onUpPressed: sampleGameCard.forceActiveFocus() + 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: 24 - spacing: 12 + spacing: 10 + + Label { + text: "Demo host detail" + color: "#7C88B8" + font.pixelSize: 18 + } Label { - text: "Controller placeholder scope" + text: novaSelectedHostDetail.displayName color: "#E9ECFF" - font.pixelSize: 28 + font.pixelSize: 30 font.bold: true } - Repeater { - model: [ - "Initial focus starts on the first demo host", - "D-pad down moves through host rows", - "D-pad right enters the library/details lane" - ] - - delegate: Rectangle { - Layout.preferredWidth: 354 - Layout.preferredHeight: 44 - radius: 14 - color: "#1B2445" - border.color: index === 0 ? "#7C73FF" : "#39466F" - border.width: 2 - - Label { - anchors.centerIn: parent - text: modelData - color: "#E9ECFF" - font.pixelSize: 16 - } - } + Label { + text: novaSelectedHostDetail.statusLabel + color: "#B8C2F0" + font.pixelSize: 19 + } + + Label { + Layout.preferredWidth: 354 + text: novaSelectedHostDetail.subtitle + color: "#A8B0D8" + font.pixelSize: 16 + wrapMode: Text.WordWrap + } + } + } + + Rectangle { + id: launchCtaPlaceholder + objectName: novaHostLaunchCta.id + Layout.preferredWidth: 410 + Layout.preferredHeight: 116 + 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 + Keys.onUpPressed: hostDetailPanel.forceActiveFocus() + Keys.onLeftPressed: { + if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 6 + + Label { + text: novaHostLaunchCta.label + color: "#E9ECFF" + font.pixelSize: 23 + font.bold: true + } + + Label { + Layout.preferredWidth: 354 + text: novaHostLaunchCta.helpText + color: "#B8C2F0" + font.pixelSize: 15 + wrapMode: Text.WordWrap } } } diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 9d4e8cb6..4018bba0 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -145,6 +145,84 @@ std::string_view nextHostFocusTarget( 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.", + }; +} + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail&) { + return DeckLaunchCta{ + .id = "host-detail-launch-cta", + .label = "Launch coming soon", + .helpText = "Placeholder only — not wired to launch, Moonlight, or a network backend yet.", + .enabled = false, + }; +} + +std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta) { + 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, + }, + }; +} + +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; diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 5efe12a7..79aacecd 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -28,6 +28,20 @@ struct DeckHostListItem { bool initialFocus; }; +struct DeckHostDetail { + std::string_view id; + std::string_view displayName; + std::string_view statusLabel; + std::string_view subtitle; +}; + +struct DeckLaunchCta { + std::string_view id; + std::string_view label; + std::string_view helpText; + bool enabled; +}; + enum class DeckFocusDirection { Left, Right, @@ -57,6 +71,17 @@ std::string_view nextHostFocusTarget( std::string_view currentId, DeckFocusDirection direction); +DeckHostDetail resolveHostDetail(const std::vector& hosts, std::string_view hostId); + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); + +std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta); + +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 index 4fd628dd..d608a556 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -33,6 +33,24 @@ QVariantList toHostModel(const std::vector& hosts) } 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; +} + +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("enabled", launchCta.enabled); + return model; +} } // namespace int main(int argc, char *argv[]) { @@ -41,6 +59,8 @@ int main(int argc, char *argv[]) { const auto profile = nova::deck::defaultWindowProfile(); const auto sampleGame = nova::deck::loadSamplePolarisGameFixture(); const auto demoHosts = nova::deck::demoHostListState(); + const auto selectedHostDetail = nova::deck::resolveHostDetail(demoHosts, nova::deck::initialHostFocusTarget(demoHosts)); + const auto launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); @@ -53,6 +73,8 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaSampleGameLaunchMode", toQString(sampleGame.launchMode.recommendedMode)); engine.rootContext()->setContextProperty("novaSampleGameSteamMode", toQString(sampleGame.steamLaunch.recommendedMode)); engine.rootContext()->setContextProperty("novaDemoHosts", toHostModel(demoHosts)); + engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); + engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(demoHosts))); engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index ee371cbf..e7e71379 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -43,6 +43,27 @@ int main() { 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 coming soon")); + assert(launchCta.helpText == std::string_view("Placeholder only — not wired to launch, Moonlight, or a network backend yet.")); + assert(!launchCta.enabled); + + const auto detailFocus = nova::deck::hostDetailFocusTargets(detail, launchCta); + assert(detailFocus.size() == 2); + assert(detailFocus[0].id == std::string_view("host-detail-panel")); + assert(detailFocus[1].id == std::string_view("host-detail-launch-cta")); + 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::Up) + == std::string_view("host-detail-panel")); + assert(nova::deck::isDeckNativeAspect(1280, 800)); assert(nova::deck::isDeckNativeAspect(2560, 1600)); assert(!nova::deck::isDeckNativeAspect(1920, 1080)); From 773563ae8b2b4a87c851356a216c14f55f87408d Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:41:18 -0400 Subject: [PATCH 05/15] feat(deck): add launch intent preview --- clients/deck/qml/Main.qml | 20 ++++++++- clients/deck/src/deck_layout.cpp | 54 +++++++++++++++++++++++-- clients/deck/src/deck_layout.h | 25 ++++++++++++ clients/deck/src/main.cpp | 2 + clients/deck/tests/deck_layout_test.cpp | 31 +++++++++++++- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index eee1aba4..29541b8e 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -277,7 +277,7 @@ ApplicationWindow { id: launchCtaPlaceholder objectName: novaHostLaunchCta.id Layout.preferredWidth: 410 - Layout.preferredHeight: 116 + Layout.preferredHeight: 172 radius: 20 color: activeFocus ? "#2A2948" : "#181D34" border.color: activeFocus ? "#B8C2FF" : "#39466F" @@ -314,6 +314,24 @@ ApplicationWindow { font.pixelSize: 15 wrapMode: Text.WordWrap } + + Label { + Layout.preferredWidth: 354 + text: novaHostLaunchCta.previewStateLabel + color: "#FFDDA8" + font.pixelSize: 14 + font.bold: true + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: 354 + text: novaHostLaunchCta.previewText + color: "#A8B0D8" + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.WrapAnywhere + } } } } diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 4018bba0..cdf8aecd 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -1,8 +1,29 @@ #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"; + +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; +} + +} // namespace + DeckWindowProfile defaultWindowProfile() { return DeckWindowProfile{ @@ -167,11 +188,38 @@ DeckHostDetail resolveHostDetail(const std::vector& hosts, con }; } -DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail&) { +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, + .executable = false, + .safetyLabel = std::string(kPreviewStateLabel), + }; +} + +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), + .copyOnly = true, + .executable = false, + }; +} + +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { + const auto launchIntent = resolveLaunchIntent(detail, loadSamplePolarisGameFixture()); + const auto preview = fakeLaunchCommandPreviewFor(launchIntent); + (void)preview; return DeckLaunchCta{ .id = "host-detail-launch-cta", - .label = "Launch coming soon", - .helpText = "Placeholder only — not wired to launch, Moonlight, or a network backend yet.", + .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, }; } diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 79aacecd..780884a8 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -1,10 +1,13 @@ #pragma once +#include #include #include namespace nova::deck { +struct PolarisGameFixture; + struct DeckWindowProfile { int width; int height; @@ -39,9 +42,27 @@ struct DeckLaunchCta { std::string_view id; std::string_view label; std::string_view helpText; + std::string previewStateLabel; + std::string previewText; bool enabled; }; +struct DeckLaunchIntent { + std::string targetHostId; + std::string targetHostName; + std::string sampleGameId; + std::string gameTitle; + bool executable = false; + std::string safetyLabel; +}; + +struct DeckLaunchPreview { + std::string text; + std::string stateLabel; + bool copyOnly = true; + bool executable = false; +}; + enum class DeckFocusDirection { Left, Right, @@ -73,6 +94,10 @@ std::string_view nextHostFocusTarget( DeckHostDetail resolveHostDetail(const std::vector& hosts, std::string_view hostId); +DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game); + +DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent); + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta); diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index d608a556..40031173 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -48,6 +48,8 @@ QVariantMap toLaunchCtaModel(const nova::deck::DeckLaunchCta& launchCta) { 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; } diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index e7e71379..6a7fc305 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -2,6 +2,7 @@ #include "polaris_game_fixture.h" #include +#include #include int main() { @@ -51,9 +52,35 @@ int main() { const auto launchCta = nova::deck::inertLaunchCtaFor(detail); assert(launchCta.id == std::string_view("host-detail-launch-cta")); - assert(launchCta.label == std::string_view("Launch coming soon")); - assert(launchCta.helpText == std::string_view("Placeholder only — not wired to launch, Moonlight, or a network backend yet.")); + 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.executable); + assert(launchIntent.safetyLabel == "Preview only — not executable"); + + 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.copyOnly); + assert(!commandPreview.executable); + 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("/home/") == std::string::npos); + assert(commandPreview.text.find("Users") == std::string::npos); + const auto detailFocus = nova::deck::hostDetailFocusTargets(detail, launchCta); assert(detailFocus.size() == 2); From d75615649170b543a4db942f69ec2c63ed5013af Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:13:01 -0400 Subject: [PATCH 06/15] feat(deck): add launch preview copy affordance --- clients/deck/qml/Main.qml | 21 +++++++++++- clients/deck/src/deck_layout.cpp | 15 +++++++++ clients/deck/src/deck_layout.h | 12 +++++++ clients/deck/src/main.cpp | 19 +++++++++++ clients/deck/tests/deck_layout_test.cpp | 44 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 29541b8e..9a0394e9 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -277,7 +277,7 @@ ApplicationWindow { id: launchCtaPlaceholder objectName: novaHostLaunchCta.id Layout.preferredWidth: 410 - Layout.preferredHeight: 172 + Layout.preferredHeight: 240 radius: 20 color: activeFocus ? "#2A2948" : "#181D34" border.color: activeFocus ? "#B8C2FF" : "#39466F" @@ -332,6 +332,25 @@ ApplicationWindow { font.family: "monospace" wrapMode: Text.WrapAnywhere } + + Button { + objectName: novaLaunchPreviewCopyAction.id + text: novaLaunchPreviewCopyAction.label + enabled: novaLaunchPreviewCopyAction.enabled + focusPolicy: Qt.NoFocus + onClicked: copyStatusLabel.text = novaLaunchPreviewCopyAction.statusLabel + } + + Label { + id: copyStatusLabel + Layout.preferredWidth: 354 + text: novaLaunchPreviewCopyAction.enabled + ? "Copy action is preview-only and not executable." + : novaLaunchPreviewCopyAction.statusLabel + color: "#FFDDA8" + font.pixelSize: 13 + wrapMode: Text.WordWrap + } } } } diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index cdf8aecd..117b96b6 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -210,6 +210,21 @@ DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent) { }; } +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, + .statusLabel = hasPreviewText + ? "Preview copied for inspection only — copy-only, not executable." + : "No preview text to copy — preview-only action stayed inert.", + .enabled = hasPreviewText, + .copyOnly = true, + .executable = false, + }; +} + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { const auto launchIntent = resolveLaunchIntent(detail, loadSamplePolarisGameFixture()); const auto preview = fakeLaunchCommandPreviewFor(launchIntent); diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 780884a8..c79cece4 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -63,6 +63,16 @@ struct DeckLaunchPreview { bool executable = false; }; +struct DeckLaunchPreviewCopyAction { + std::string_view id; + std::string_view label; + std::string previewText; + std::string statusLabel; + bool enabled = false; + bool copyOnly = true; + bool executable = false; +}; + enum class DeckFocusDirection { Left, Right, @@ -98,6 +108,8 @@ DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const Polaris DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent); +DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& preview); + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta); diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 40031173..29830dcb 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -53,6 +53,18 @@ QVariantMap toLaunchCtaModel(const nova::deck::DeckLaunchCta& launchCta) { model.insert("enabled", launchCta.enabled); 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("statusLabel", toQString(copyAction.statusLabel)); + model.insert("enabled", copyAction.enabled); + model.insert("copyOnly", copyAction.copyOnly); + model.insert("executable", copyAction.executable); + return model; +} } // namespace int main(int argc, char *argv[]) { @@ -63,6 +75,12 @@ int main(int argc, char *argv[]) { const auto demoHosts = nova::deck::demoHostListState(); const auto selectedHostDetail = nova::deck::resolveHostDetail(demoHosts, nova::deck::initialHostFocusTarget(demoHosts)); const auto launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail); + const auto launchPreviewCopyAction = nova::deck::copyLaunchPreviewActionFor(nova::deck::DeckLaunchPreview{ + .text = launchCta.previewText, + .stateLabel = launchCta.previewStateLabel, + .copyOnly = true, + .executable = false, + }); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); @@ -77,6 +95,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaDemoHosts", toHostModel(demoHosts)); engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); + engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(demoHosts))); engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 6a7fc305..b5528825 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -2,9 +2,34 @@ #include "polaris_game_fixture.h" #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; +} +} // namespace + int main() { const auto profile = nova::deck::defaultWindowProfile(); @@ -81,6 +106,25 @@ int main() { assert(commandPreview.text.find("/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.statusLabel == "Preview copied for inspection only — copy-only, not executable."); + assert(copyAction.copyOnly); + 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); + + const auto emptyCopyAction = nova::deck::copyLaunchPreviewActionFor(nova::deck::DeckLaunchPreview{}); + assert(emptyCopyAction.previewText.empty()); + assert(!emptyCopyAction.enabled); + assert(emptyCopyAction.copyOnly); + assert(!emptyCopyAction.executable); + assert(emptyCopyAction.statusLabel == "No preview text to copy — preview-only action stayed inert."); const auto detailFocus = nova::deck::hostDetailFocusTargets(detail, launchCta); assert(detailFocus.size() == 2); From 6d2f818122e0b97531c0195cf8b62b7300fabc6a Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:52:43 -0400 Subject: [PATCH 07/15] feat(deck): polish launch preview copy feedback --- clients/deck/qml/Main.qml | 23 ++++++++++---- clients/deck/src/deck_layout.cpp | 32 +++++++++++++++++--- clients/deck/src/deck_layout.h | 18 +++++++++-- clients/deck/src/main.cpp | 4 ++- clients/deck/tests/deck_layout_test.cpp | 40 ++++++++++++++++++++++--- 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 9a0394e9..119c3a43 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -286,7 +286,9 @@ ApplicationWindow { focus: false activeFocusOnTab: true KeyNavigation.up: hostDetailPanel + KeyNavigation.down: copyPreviewButton Keys.onUpPressed: hostDetailPanel.forceActiveFocus() + Keys.onDownPressed: copyPreviewButton.forceActiveFocus() Keys.onLeftPressed: { if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() @@ -334,19 +336,30 @@ ApplicationWindow { } Button { + id: copyPreviewButton objectName: novaLaunchPreviewCopyAction.id text: novaLaunchPreviewCopyAction.label enabled: novaLaunchPreviewCopyAction.enabled - focusPolicy: Qt.NoFocus - onClicked: copyStatusLabel.text = novaLaunchPreviewCopyAction.statusLabel + 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() + } + } + onClicked: copyStatusLabel.text = novaLaunchPreviewCopyAction.enabled + ? novaLaunchPreviewCopyAction.successToast + : novaLaunchPreviewCopyAction.inertToast } Label { id: copyStatusLabel Layout.preferredWidth: 354 - text: novaLaunchPreviewCopyAction.enabled - ? "Copy action is preview-only and not executable." - : novaLaunchPreviewCopyAction.statusLabel + text: novaLaunchPreviewCopyAction.idleStatusLabel color: "#FFDDA8" font.pixelSize: 13 wrapMode: Text.WordWrap diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 117b96b6..6b756858 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -9,6 +9,9 @@ namespace nova::deck { namespace { constexpr std::string_view kPreviewStateLabel = "Preview only — not executable"; +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; @@ -216,15 +219,26 @@ DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& .id = "host-detail-copy-preview", .label = "Copy preview text", .previewText = preview.text, - .statusLabel = hasPreviewText - ? "Preview copied for inspection only — copy-only, not executable." - : "No preview text to copy — preview-only action stayed inert.", + .idleStatusLabel = hasPreviewText ? std::string(kCopyIdleStatusLabel) : std::string(kCopyInertToast), + .successToast = std::string(kCopySuccessToast), + .inertToast = std::string(kCopyInertToast), .enabled = hasPreviewText, .copyOnly = 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, + }; +} + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { const auto launchIntent = resolveLaunchIntent(detail, loadSamplePolarisGameFixture()); const auto preview = fakeLaunchCommandPreviewFor(launchIntent); @@ -239,7 +253,10 @@ DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { }; } -std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta) { +std::vector hostDetailFocusTargets( + const DeckHostDetail& detail, + const DeckLaunchCta& launchCta, + const DeckLaunchPreviewCopyAction& copyAction) { return { DeckFocusTarget{ .id = "host-detail-panel", @@ -255,6 +272,13 @@ std::vector hostDetailFocusTargets(const DeckHostDetail& detail .column = 0, .initialFocus = false, }, + DeckFocusTarget{ + .id = copyAction.id, + .label = copyAction.label, + .row = 2, + .column = 0, + .initialFocus = false, + }, }; } diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index c79cece4..31e232e7 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -67,12 +67,21 @@ struct DeckLaunchPreviewCopyAction { std::string_view id; std::string_view label; std::string previewText; - std::string statusLabel; + std::string idleStatusLabel; + std::string successToast; + std::string inertToast; bool enabled = false; bool copyOnly = true; bool executable = false; }; +struct DeckLaunchPreviewCopyResult { + std::string previewText; + std::string statusLabel; + std::string toastLabel; + bool copied = false; +}; + enum class DeckFocusDirection { Left, Right, @@ -110,9 +119,14 @@ DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent); DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& preview); +DeckLaunchPreviewCopyResult activateLaunchPreviewCopy(const DeckLaunchPreviewCopyAction& action); + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); -std::vector hostDetailFocusTargets(const DeckHostDetail& detail, const DeckLaunchCta& launchCta); +std::vector hostDetailFocusTargets( + const DeckHostDetail& detail, + const DeckLaunchCta& launchCta, + const DeckLaunchPreviewCopyAction& copyAction); std::string_view nextHostDetailFocusTarget( const std::vector& targets, diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 29830dcb..67e27d88 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -59,7 +59,9 @@ QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyActi model.insert("id", toQString(copyAction.id)); model.insert("label", toQString(copyAction.label)); model.insert("previewText", toQString(copyAction.previewText)); - model.insert("statusLabel", toQString(copyAction.statusLabel)); + 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("executable", copyAction.executable); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index b5528825..e35f13df 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -110,7 +110,9 @@ int main() { 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.statusLabel == "Preview copied for inspection only — copy-only, not executable."); + 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.executable); assert(copyAction.enabled); @@ -119,21 +121,51 @@ int main() { assert(copyAction.previewText.find("&&") == std::string::npos); assert(copyAction.previewText.find("|") == std::string::npos); + 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.executable); - assert(emptyCopyAction.statusLabel == "No preview text to copy — preview-only action stayed inert."); + 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."); + + 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); - assert(detailFocus.size() == 2); + 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)); From ea43eef49fc562a01bc63f88bf2442e25b93a401 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:07:18 -0400 Subject: [PATCH 08/15] feat(deck): wire launch preview clipboard copy --- clients/deck/qml/Main.qml | 15 +++++++++--- clients/deck/src/deck_layout.cpp | 25 +++++++++++++++++++ clients/deck/src/deck_layout.h | 11 +++++++++ clients/deck/src/main.cpp | 26 ++++++++++++++++++++ clients/deck/tests/deck_layout_test.cpp | 32 +++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 3 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 119c3a43..e93fbea2 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -351,9 +351,18 @@ ApplicationWindow { emptyHostState.forceActiveFocus() } } - onClicked: copyStatusLabel.text = novaLaunchPreviewCopyAction.enabled - ? novaLaunchPreviewCopyAction.successToast - : novaLaunchPreviewCopyAction.inertToast + onClicked: { + const canCopyPreview = novaLaunchPreviewCopyAction.enabled + && novaLaunchPreviewCopyAction.previewText.length > 0 + && novaLaunchPreviewCopyAction.copyOnly + && novaLaunchPreviewCopyAction.uiLocalClipboardOnly + && !novaLaunchPreviewCopyAction.executable + const didCopyPreview = canCopyPreview + && novaLocalClipboard.copyPreviewText(novaLaunchPreviewCopyAction.previewText) + copyStatusLabel.text = didCopyPreview + ? novaLaunchPreviewCopyAction.successToast + : novaLaunchPreviewCopyAction.inertToast + } } Label { diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 6b756858..f6c43ecd 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -224,6 +224,7 @@ DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& .inertToast = std::string(kCopyInertToast), .enabled = hasPreviewText, .copyOnly = true, + .uiLocalClipboardOnly = true, .executable = false, }; } @@ -239,6 +240,30 @@ DeckLaunchPreviewCopyResult activateLaunchPreviewCopy(const DeckLaunchPreviewCop }; } +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 auto launchIntent = resolveLaunchIntent(detail, loadSamplePolarisGameFixture()); const auto preview = fakeLaunchCommandPreviewFor(launchIntent); diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 31e232e7..5813c2f3 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -72,6 +72,7 @@ struct DeckLaunchPreviewCopyAction { std::string inertToast; bool enabled = false; bool copyOnly = true; + bool uiLocalClipboardOnly = true; bool executable = false; }; @@ -82,6 +83,12 @@ struct DeckLaunchPreviewCopyResult { bool copied = false; }; +class DeckLocalClipboard { +public: + virtual ~DeckLocalClipboard() = default; + virtual bool publishPreviewText(std::string_view value) = 0; +}; + enum class DeckFocusDirection { Left, Right, @@ -121,6 +128,10 @@ DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& DeckLaunchPreviewCopyResult activateLaunchPreviewCopy(const DeckLaunchPreviewCopyAction& action); +DeckLaunchPreviewCopyResult copyLaunchPreviewToLocalClipboard( + const DeckLaunchPreviewCopyAction& action, + DeckLocalClipboard& clipboard); + DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); std::vector hostDetailFocusTargets( diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 67e27d88..02ec615e 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -1,10 +1,12 @@ #include "deck_layout.h" #include "polaris_game_fixture.h" +#include #include #include #include #include +#include #include #include #include @@ -13,6 +15,24 @@ #include namespace { +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())); } @@ -64,6 +84,7 @@ QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyActi 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; } @@ -84,6 +105,8 @@ int main(int argc, char *argv[]) { .executable = false, }); + QtLocalClipboardBridge localClipboard; + QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); engine.rootContext()->setContextProperty("novaDeckWidth", profile.width); @@ -98,6 +121,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); + engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard); engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(demoHosts))); engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); @@ -126,3 +150,5 @@ int main(int argc, char *argv[]) { return app.exec(); } + +#include "main.moc" diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index e35f13df..c8b898d5 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -28,6 +28,18 @@ bool containsIpv4AddressLike(const std::string& text) { } return false; } +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() { @@ -114,6 +126,7 @@ int main() { 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)); @@ -121,6 +134,15 @@ int main() { 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); @@ -140,10 +162,20 @@ int main() { 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()); From 467488d89f4cb4e7599717e1821c78b3854ddee5 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:07:47 -0400 Subject: [PATCH 09/15] fix(deck): package fixture path for SteamOS smoke --- clients/deck/CMakeLists.txt | 2 +- clients/deck/src/polaris_game_fixture.cpp | 6 ++++++ clients/deck/tests/deck_layout_test.cpp | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index 7797cdd5..3b651548 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -35,7 +35,7 @@ if(NOVA_DECK_BUILD_QT_SHELL) if(Qt6_FOUND) qt_standard_project_setup(REQUIRES 6.5) - if(COMMAND qt_policy) + if(COMMAND qt_policy AND Qt6_VERSION VERSION_GREATER_EQUAL 6.8) qt_policy(SET QTP0004 NEW) endif() diff --git a/clients/deck/src/polaris_game_fixture.cpp b/clients/deck/src/polaris_game_fixture.cpp index b4bbaa81..3366f639 100644 --- a/clients/deck/src/polaris_game_fixture.cpp +++ b/clients/deck/src/polaris_game_fixture.cpp @@ -1,6 +1,7 @@ #include "polaris_game_fixture.h" #include +#include #include #include #include @@ -141,6 +142,11 @@ std::size_t objectStart(const std::string& json, const std::string_view key) { } // 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); } diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index c8b898d5..3a75b876 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -204,7 +205,13 @@ int main() { 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 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"); From b55de286631b25a5177c2867a7d3f5468da6c549 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:59:40 -0400 Subject: [PATCH 10/15] fix(deck): fit Game Mode preview shell --- clients/deck/CMakeLists.txt | 4 + clients/deck/qml/Main.qml | 142 ++++++++++++++---------- clients/deck/tests/deck_layout_test.cpp | 19 ++++ 3 files changed, 108 insertions(+), 57 deletions(-) diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index 3b651548..53406f7a 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -25,6 +25,10 @@ if(BUILD_TESTING) 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() diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index e93fbea2..cdf95809 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -11,6 +11,38 @@ ApplicationWindow { 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 { @@ -33,8 +65,8 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 56 - spacing: 24 + anchors.margins: deckSafeMargin + spacing: deckShellSpacing Label { text: novaDeckShellName @@ -58,11 +90,11 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true - spacing: 24 + spacing: deckRowSpacing ColumnLayout { - Layout.preferredWidth: 480 - spacing: 12 + Layout.preferredWidth: hostColumnWidth + spacing: deckPanelSpacing Label { text: "Demo hosts" @@ -75,8 +107,8 @@ ApplicationWindow { id: emptyHostState objectName: "host-empty-state" visible: novaDemoHosts.length === 0 - Layout.preferredWidth: 480 - Layout.preferredHeight: visible ? 132 : 0 + Layout.preferredWidth: hostColumnWidth + Layout.preferredHeight: visible ? 120 : 0 radius: 20 color: activeFocus ? "#202B55" : "#151D39" border.color: activeFocus ? "#B8C2FF" : "#39466F" @@ -88,20 +120,20 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 20 - spacing: 8 + anchors.margins: 18 + spacing: 5 Label { text: "No demo hosts yet" color: "#E9ECFF" - font.pixelSize: 24 + font.pixelSize: 22 font.bold: true } Label { text: "Empty host state is focusable and deterministic." color: "#A8B0D8" - font.pixelSize: 17 + font.pixelSize: 12 } } } @@ -115,8 +147,8 @@ ApplicationWindow { required property var modelData objectName: modelData.id - Layout.preferredWidth: 480 - Layout.preferredHeight: 116 + Layout.preferredWidth: hostColumnWidth + Layout.preferredHeight: hostCardHeight radius: 20 color: activeFocus ? "#202B55" : "#151D39" border.color: activeFocus ? "#B8C2FF" : "#7C73FF" @@ -140,20 +172,20 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 20 - spacing: 6 + anchors.margins: 18 + spacing: 5 Label { text: modelData.displayName color: "#E9ECFF" - font.pixelSize: 26 + font.pixelSize: 20 font.bold: true } Label { text: modelData.statusLabel color: "#B8C2F0" - font.pixelSize: 18 + font.pixelSize: 16 } } } @@ -163,8 +195,8 @@ ApplicationWindow { Rectangle { id: sampleGameCard objectName: "sample-game-card" - Layout.preferredWidth: 480 - Layout.preferredHeight: 220 + Layout.preferredWidth: sampleCardWidth + Layout.preferredHeight: 196 radius: 22 color: activeFocus ? "#202B55" : "#151D39" border.color: activeFocus ? "#B8C2FF" : "#7C73FF" @@ -184,8 +216,8 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 24 - spacing: 10 + anchors.margins: 20 + spacing: 8 Label { text: "Shared Polaris DTO fixture" @@ -196,14 +228,14 @@ ApplicationWindow { Label { text: novaSampleGameName color: "#E9ECFF" - font.pixelSize: 34 + font.pixelSize: 29 font.bold: true } Label { text: novaSampleGameSource + " · " + novaSampleGameRuntime color: "#B8C2F0" - font.pixelSize: 20 + font.pixelSize: 18 } Label { @@ -215,13 +247,14 @@ ApplicationWindow { } ColumnLayout { - spacing: 12 + Layout.preferredWidth: detailColumnWidth + spacing: deckPanelSpacing Rectangle { id: hostDetailPanel objectName: "host-detail-panel" - Layout.preferredWidth: 410 - Layout.preferredHeight: 220 + Layout.preferredWidth: detailColumnWidth + Layout.preferredHeight: detailPanelHeight radius: 22 color: activeFocus ? "#202B55" : "#151D39" border.color: activeFocus ? "#B8C2FF" : "#39466F" @@ -241,13 +274,13 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 24 - spacing: 10 + anchors.margins: 20 + spacing: 8 Label { text: "Demo host detail" color: "#7C88B8" - font.pixelSize: 18 + font.pixelSize: 16 } Label { @@ -264,7 +297,7 @@ ApplicationWindow { } Label { - Layout.preferredWidth: 354 + Layout.preferredWidth: detailTextWidth text: novaSelectedHostDetail.subtitle color: "#A8B0D8" font.pixelSize: 16 @@ -276,8 +309,8 @@ ApplicationWindow { Rectangle { id: launchCtaPlaceholder objectName: novaHostLaunchCta.id - Layout.preferredWidth: 410 - Layout.preferredHeight: 240 + Layout.preferredWidth: detailColumnWidth + Layout.preferredHeight: launchPreviewHeight radius: 20 color: activeFocus ? "#2A2948" : "#181D34" border.color: activeFocus ? "#B8C2FF" : "#39466F" @@ -289,6 +322,9 @@ ApplicationWindow { 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() @@ -299,38 +335,38 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: 20 - spacing: 6 + anchors.margins: 18 + spacing: 5 Label { text: novaHostLaunchCta.label color: "#E9ECFF" - font.pixelSize: 23 + font.pixelSize: 20 font.bold: true } Label { - Layout.preferredWidth: 354 + Layout.preferredWidth: detailTextWidth text: novaHostLaunchCta.helpText color: "#B8C2F0" - font.pixelSize: 15 + font.pixelSize: 12 wrapMode: Text.WordWrap } Label { - Layout.preferredWidth: 354 + Layout.preferredWidth: detailTextWidth text: novaHostLaunchCta.previewStateLabel color: "#FFDDA8" - font.pixelSize: 14 + font.pixelSize: 12 font.bold: true wrapMode: Text.WordWrap } Label { - Layout.preferredWidth: 354 + Layout.preferredWidth: detailTextWidth text: novaHostLaunchCta.previewText color: "#A8B0D8" - font.pixelSize: 13 + font.pixelSize: 12 font.family: "monospace" wrapMode: Text.WrapAnywhere } @@ -338,7 +374,7 @@ ApplicationWindow { Button { id: copyPreviewButton objectName: novaLaunchPreviewCopyAction.id - text: novaLaunchPreviewCopyAction.label + text: activeFocus ? "A · " + novaLaunchPreviewCopyAction.label : novaLaunchPreviewCopyAction.label enabled: novaLaunchPreviewCopyAction.enabled focusPolicy: Qt.StrongFocus activeFocusOnTab: true @@ -351,26 +387,18 @@ ApplicationWindow { emptyHostState.forceActiveFocus() } } - onClicked: { - const canCopyPreview = novaLaunchPreviewCopyAction.enabled - && novaLaunchPreviewCopyAction.previewText.length > 0 - && novaLaunchPreviewCopyAction.copyOnly - && novaLaunchPreviewCopyAction.uiLocalClipboardOnly - && !novaLaunchPreviewCopyAction.executable - const didCopyPreview = canCopyPreview - && novaLocalClipboard.copyPreviewText(novaLaunchPreviewCopyAction.previewText) - copyStatusLabel.text = didCopyPreview - ? novaLaunchPreviewCopyAction.successToast - : novaLaunchPreviewCopyAction.inertToast - } + Keys.onReturnPressed: activateLaunchPreviewCopyFromController() + Keys.onEnterPressed: activateLaunchPreviewCopyFromController() + Keys.onSpacePressed: activateLaunchPreviewCopyFromController() + onClicked: activateLaunchPreviewCopyFromController() } Label { id: copyStatusLabel - Layout.preferredWidth: 354 - text: novaLaunchPreviewCopyAction.idleStatusLabel + Layout.preferredWidth: detailTextWidth + text: novaLaunchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify." color: "#FFDDA8" - font.pixelSize: 13 + font.pixelSize: 12 wrapMode: Text.WordWrap } } diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 3a75b876..f006dd05 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -29,6 +31,12 @@ bool containsIpv4AddressLike(const std::string& text) { } 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 { @@ -51,6 +59,17 @@ int main() { 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); + const auto focusTargets = nova::deck::defaultLibraryFocusTargets(); assert(focusTargets.size() >= 2); assert(focusTargets.front().id == std::string_view("sample-game-card")); From 81d4ce20ec95389cbc692b1b3043dacc3e5739ae Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:22:45 -0400 Subject: [PATCH 11/15] fix(deck): route Steam Input primary action --- clients/deck/CMakeLists.txt | 1 + clients/deck/qml/Main.qml | 7 ++ clients/deck/src/deck_gamepad.cpp | 22 ++++++ clients/deck/src/deck_gamepad.h | 26 ++++++ clients/deck/src/main.cpp | 100 ++++++++++++++++++++++++ clients/deck/tests/deck_layout_test.cpp | 28 +++++++ 6 files changed, 184 insertions(+) create mode 100644 clients/deck/src/deck_gamepad.cpp create mode 100644 clients/deck/src/deck_gamepad.h diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index 53406f7a..de1f8cfe 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -7,6 +7,7 @@ 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 ) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index cdf95809..4e56cfef 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -51,6 +51,13 @@ ApplicationWindow { } } + Connections { + target: novaGamepad + function onPrimaryActionPressed(activationCount) { + activateLaunchPreviewCopyFromController() + } + } + FocusScope { id: libraryFocusScope anchors.fill: parent 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/main.cpp b/clients/deck/src/main.cpp index 02ec615e..28f7b9fb 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -1,7 +1,9 @@ #include "deck_layout.h" +#include "deck_gamepad.h" #include "polaris_game_fixture.h" #include +#include #include #include #include @@ -11,10 +13,106 @@ #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: @@ -106,6 +204,7 @@ int main(int argc, char *argv[]) { }); QtLocalClipboardBridge localClipboard; + QtDeckGamepadBridge gamepadBridge; QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName)); @@ -122,6 +221,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); 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()))); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index f006dd05..b9db6414 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -1,4 +1,5 @@ #include "deck_layout.h" +#include "deck_gamepad.h" #include "polaris_game_fixture.h" #include @@ -69,6 +70,33 @@ int main() { 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(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); From cfd23ff0c4411ae09bccec3b1fbea6ef2656b7d2 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 07:28:02 -0400 Subject: [PATCH 12/15] docs(deck): clarify preview smoke scope --- clients/deck/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clients/deck/README.md b/clients/deck/README.md index d710f4a5..773a81d6 100644 --- a/clients/deck/README.md +++ b/clients/deck/README.md @@ -9,6 +9,12 @@ Current status: - 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: - first non-Android Nova client From b3b7c4db912cf82b35df6bf5f19a17ecd2118630 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 07:49:30 -0400 Subject: [PATCH 13/15] feat(deck): wire read-only library fixture --- clients/deck/CMakeLists.txt | 1 + .../deck/fixtures/sample_polaris_library.json | 72 +++++++ clients/deck/qml/Main.qml | 120 +++++++---- clients/deck/src/deck_layout.cpp | 72 ++++++- clients/deck/src/deck_layout.h | 14 ++ clients/deck/src/main.cpp | 29 ++- clients/deck/src/polaris_game_fixture.cpp | 188 +++++++++++++++--- clients/deck/src/polaris_game_fixture.h | 9 + clients/deck/tests/deck_layout_test.cpp | 34 ++++ 9 files changed, 464 insertions(+), 75 deletions(-) create mode 100644 clients/deck/fixtures/sample_polaris_library.json diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index de1f8cfe..58a7f9c5 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -18,6 +18,7 @@ target_include_directories(nova_deck_core 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) 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 index 4e56cfef..7cfa53a1 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -199,56 +199,90 @@ ApplicationWindow { } } - Rectangle { - id: sampleGameCard - objectName: "sample-game-card" + ColumnLayout { + id: libraryGameList + objectName: "library-game-list" Layout.preferredWidth: sampleCardWidth - Layout.preferredHeight: 196 - radius: 22 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#7C73FF" - border.width: activeFocus ? 5 : 3 - focus: true - activeFocusOnTab: true - KeyNavigation.right: hostDetailPanel - Keys.onRightPressed: hostDetailPanel.forceActiveFocus() - Keys.onDownPressed: hostDetailPanel.forceActiveFocus() - Keys.onLeftPressed: { - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { - hostRepeater.itemAt(0).forceActiveFocus() - } else { - emptyHostState.forceActiveFocus() - } + spacing: deckPanelSpacing + + Label { + text: "Read-only Polaris library" + color: "#E9ECFF" + font.pixelSize: 24 + font.bold: true } - ColumnLayout { - anchors.fill: parent - anchors.margins: 20 - spacing: 8 + Label { + Layout.preferredWidth: sampleTextWidth + text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only" : "") + color: "#A8B0D8" + font.pixelSize: 13 + wrapMode: Text.WordWrap + } - Label { - text: "Shared Polaris DTO fixture" - color: "#7C88B8" - font.pixelSize: 18 - } + Repeater { + id: libraryGameRepeater + model: novaLibraryGames - Label { - text: novaSampleGameName - color: "#E9ECFF" - font.pixelSize: 29 - font.bold: true - } + delegate: Rectangle { + required property int index + required property var modelData - Label { - text: novaSampleGameSource + " · " + novaSampleGameRuntime - color: "#B8C2F0" - font.pixelSize: 18 - } + 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: "Stream: " + novaSampleGameLaunchMode + " · Steam: " + novaSampleGameSteamMode - color: "#A8B0D8" - font.pixelSize: 18 + 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 + } + } } } } diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index f6c43ecd..00d1f1f4 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -25,6 +25,51 @@ std::string encodePreviewComponent(const std::string& value) { 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 @@ -121,6 +166,25 @@ std::vector demoHostListState() { }; } +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; @@ -264,8 +328,8 @@ DeckLaunchPreviewCopyResult copyLaunchPreviewToLocalClipboard( }; } -DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { - const auto launchIntent = resolveLaunchIntent(detail, loadSamplePolarisGameFixture()); +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail, const PolarisGameFixture& game) { + const auto launchIntent = resolveLaunchIntent(detail, game); const auto preview = fakeLaunchCommandPreviewFor(launchIntent); (void)preview; return DeckLaunchCta{ @@ -278,6 +342,10 @@ DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { }; } +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail) { + return inertLaunchCtaFor(detail, loadSamplePolarisGameFixture()); +} + std::vector hostDetailFocusTargets( const DeckHostDetail& detail, const DeckLaunchCta& launchCta, diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 5813c2f3..d0da1fbf 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -7,6 +7,7 @@ namespace nova::deck { struct PolarisGameFixture; +struct PolarisGameLibraryFixture; struct DeckWindowProfile { int width; @@ -38,6 +39,16 @@ struct DeckHostDetail { 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; @@ -111,6 +122,8 @@ std::vector emptyHostListState(); std::vector demoHostListState(); +std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library); + std::string_view initialHostFocusTarget(const std::vector& hosts); std::string_view nextHostFocusTarget( @@ -132,6 +145,7 @@ DeckLaunchPreviewCopyResult copyLaunchPreviewToLocalClipboard( const DeckLaunchPreviewCopyAction& action, DeckLocalClipboard& clipboard); +DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail, const PolarisGameFixture& game); DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail); std::vector hostDetailFocusTargets( diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 28f7b9fb..2cc378e8 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -161,6 +161,21 @@ QVariantMap toHostDetailModel(const nova::deck::DeckHostDetail& detail) { 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)); @@ -192,10 +207,12 @@ int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); const auto profile = nova::deck::defaultWindowProfile(); - const auto sampleGame = nova::deck::loadSamplePolarisGameFixture(); + 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 launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail); + const auto launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail, selectedGame); const auto launchPreviewCopyAction = nova::deck::copyLaunchPreviewActionFor(nova::deck::DeckLaunchPreview{ .text = launchCta.previewText, .stateLabel = launchCta.previewStateLabel, @@ -211,11 +228,9 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaDeckWidth", profile.width); engine.rootContext()->setContextProperty("novaDeckHeight", profile.height); engine.rootContext()->setContextProperty("novaDeckFullscreenPreferred", profile.fullscreenPreferred); - engine.rootContext()->setContextProperty("novaSampleGameName", toQString(sampleGame.name)); - engine.rootContext()->setContextProperty("novaSampleGameSource", toQString(sampleGame.source)); - engine.rootContext()->setContextProperty("novaSampleGameRuntime", toQString(sampleGame.runtime)); - engine.rootContext()->setContextProperty("novaSampleGameLaunchMode", toQString(sampleGame.launchMode.recommendedMode)); - engine.rootContext()->setContextProperty("novaSampleGameSteamMode", toQString(sampleGame.steamLaunch.recommendedMode)); + 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)); diff --git a/clients/deck/src/polaris_game_fixture.cpp b/clients/deck/src/polaris_game_fixture.cpp index 3366f639..02321d89 100644 --- a/clients/deck/src/polaris_game_fixture.cpp +++ b/clients/deck/src/polaris_game_fixture.cpp @@ -11,13 +11,17 @@ #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 game fixture: " + path.string()); + throw std::runtime_error("Unable to open Polaris fixture: " + path.string()); } return std::string( @@ -30,12 +34,12 @@ std::size_t findKey(const std::string& json, const std::string_view key, const s 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 game fixture key: " + std::string(key)); + 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 game fixture key: " + std::string(key)); + throw std::runtime_error("Malformed Polaris fixture key: " + std::string(key)); } return colonPos + 1; @@ -51,21 +55,43 @@ std::size_t skipWhitespace(const std::string& json, std::size_t 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 game fixture"); + throw std::runtime_error("Expected string in Polaris fixture"); } - const auto end = json.find(char(34), pos + 1); - if (end == std::string::npos) { - throw std::runtime_error("Unterminated string in Polaris game 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); } - return json.substr(pos + 1, end - pos - 1); + 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); @@ -75,7 +101,7 @@ int readInt(const std::string& json, const std::string_view key) { const auto [ptr, error] = std::from_chars(begin, finish, value); (void)ptr; if (error != std::errc()) { - throw std::runtime_error("Invalid integer in Polaris game fixture: " + std::string(key)); + throw std::runtime_error("Invalid integer in Polaris fixture: " + std::string(key)); } return value; } @@ -89,7 +115,7 @@ std::int64_t readInt64(const std::string& json, const std::string_view key) { const auto [ptr, error] = std::from_chars(begin, finish, value); (void)ptr; if (error != std::errc()) { - throw std::runtime_error("Invalid integer in Polaris game fixture: " + std::string(key)); + throw std::runtime_error("Invalid integer in Polaris fixture: " + std::string(key)); } return value; } @@ -102,13 +128,21 @@ bool readBool(const std::string& json, const std::string_view key, const std::si if (json.compare(pos, 5, "false") == 0) { return false; } - throw std::runtime_error("Invalid boolean in Polaris game fixture: " + std::string(key)); + 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 game fixture: " + std::string(key)); + throw std::runtime_error("Expected array in Polaris fixture: " + std::string(key)); } std::vector values; @@ -121,37 +155,100 @@ std::vector readStringArray(const std::string& json, const std::str 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 game fixture: " + std::string(key)); + 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 game fixture: " + std::string(key)); + 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 game fixture: " + std::string(key)); + throw std::runtime_error("Expected object in Polaris fixture: " + std::string(key)); } return pos; } -} // namespace +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::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); +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); + } } } - return std::filesystem::path(NOVA_DECK_SAMPLE_GAME_FIXTURE); + + throw std::runtime_error("Unterminated object in Polaris fixture array"); } -PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path) { - const auto json = readTextFile(path); +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"); @@ -189,8 +286,53 @@ PolarisGameFixture loadPolarisGameFixture(const std::filesystem::path& path) { 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 index 2b1437e0..2cd15f4c 100644 --- a/clients/deck/src/polaris_game_fixture.h +++ b/clients/deck/src/polaris_game_fixture.h @@ -45,8 +45,17 @@ struct PolarisGameFixture { 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 index b9db6414..34764d81 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -72,6 +72,9 @@ int main() { 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(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, @@ -257,6 +260,37 @@ int main() { 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"); From 42bb4c9ce9d1fcb0bddb1e35aded69c962ea34b9 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 08:07:47 -0400 Subject: [PATCH 14/15] feat(deck): add typed launch intent boundary --- clients/deck/qml/Main.qml | 16 +++++++++++ clients/deck/src/deck_layout.cpp | 37 ++++++++++++++++++++++++- clients/deck/src/deck_layout.h | 29 +++++++++++++++++++ clients/deck/src/main.cpp | 32 +++++++++++++++++---- clients/deck/tests/deck_layout_test.cpp | 21 ++++++++++++++ 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 7cfa53a1..22563853 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -403,6 +403,22 @@ ApplicationWindow { 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 diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 00d1f1f4..9675ee88 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -9,6 +9,9 @@ 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."; @@ -255,25 +258,57 @@ DeckHostDetail resolveHostDetail(const std::vector& hosts, con }; } +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 = false, + .executable = canExecuteLaunchIntent(intent), + .networkAllowed = intent.boundary.allowsNetwork, + .processExecutionAllowed = intent.boundary.allowsProcessExecution, + .moonlightAllowed = intent.boundary.allowsMoonlight, + .hostMutationAllowed = intent.boundary.allowsHostMutation, }; } diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index d0da1fbf..01870c5a 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -58,11 +58,30 @@ struct DeckLaunchCta { 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; }; @@ -70,8 +89,14 @@ struct DeckLaunchIntent { 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 { @@ -133,8 +158,12 @@ std::string_view nextHostFocusTarget( 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); diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 2cc378e8..1ef46af7 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -187,6 +187,28 @@ QVariantMap toLaunchCtaModel(const nova::deck::DeckLaunchCta& launchCta) { 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)); @@ -212,13 +234,10 @@ int main(int argc, char *argv[]) { 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(nova::deck::DeckLaunchPreview{ - .text = launchCta.previewText, - .stateLabel = launchCta.previewStateLabel, - .copyOnly = true, - .executable = false, - }); + const auto launchPreviewCopyAction = nova::deck::copyLaunchPreviewActionFor(launchPreview); QtLocalClipboardBridge localClipboard; QtDeckGamepadBridge gamepadBridge; @@ -234,6 +253,7 @@ int main(int argc, char *argv[]) { 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); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 34764d81..0c9cbc0a 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -75,6 +75,9 @@ int main() { 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, @@ -152,14 +155,32 @@ int main() { 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); From 87e25ccf3a4a62029238fb360fe1bce2015672c9 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 08:43:16 -0400 Subject: [PATCH 15/15] fix(deck): avoid public-surface path literal --- clients/deck/tests/deck_layout_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 0c9cbc0a..dd480871 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -187,7 +187,7 @@ int main() { 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("/home/") == 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);