diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index d716d5f1..a382e1ff 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -29,6 +29,9 @@ ApplicationWindow { property var selectedGameForPreview: novaSelectedGameCard property string selectedLaunchPreviewText: novaSelectedLaunchPreviewText property var launchPreviewCopyAction: novaLaunchPreviewCopyAction + property var launchIntentPreview: novaLaunchIntentPreview + property string selectedLaunchPublicCopy: launchIntentPreview.publicCopy + property string selectedStreamLifecycleCopy: launchIntentPreview.streamLifecycleCopy function selectedHostSubtitle() { return "Read-only host detail only — not discovered from the network." @@ -42,14 +45,31 @@ ApplicationWindow { const hostId = selectedHostForPreview && selectedHostForPreview.id ? selectedHostForPreview.id : "host-empty-state" + const hostName = selectedHostForPreview && selectedHostForPreview.displayName + ? selectedHostForPreview.displayName + : "No host selected" const gameTitle = selectedGameForPreview && selectedGameForPreview.title ? selectedGameForPreview.title : "No game selected" + const launchModeLabel = selectedGameForPreview && selectedGameForPreview.launchModeLabel + ? selectedGameForPreview.launchModeLabel + : "Stream: preview · Steam: direct" + const streamMode = launchModeLabel.indexOf("virtual_display") >= 0 ? "virtual_display" + : launchModeLabel.indexOf("headless") >= 0 ? "headless" + : "preview" + const steamMode = launchModeLabel.indexOf("big-picture") >= 0 ? "steam-big-picture" : "steam-direct" + const steamCopy = steamMode === "steam-big-picture" ? "Steam Big Picture" : "Steam direct" selectedLaunchPreviewText = "preview://nova-deck/launch?host=" + previewComponent(hostId) + "&game=" + previewComponent(gameTitle) - + "&state=copy-preview-only" + + "&mode=" + + steamMode + + "&stream=" + + previewComponent(streamMode) + + "&state=noop-preview" + selectedLaunchPublicCopy = "Preview " + gameTitle + " on " + hostName + " via " + steamCopy + "; no launch will run." + selectedStreamLifecycleCopy = "Preview stream for " + gameTitle + " on " + hostName + " remains noop_preview/not_started." launchPreviewCopyAction = { "id": novaLaunchPreviewCopyAction.id, "label": novaLaunchPreviewCopyAction.label, @@ -570,6 +590,22 @@ ApplicationWindow { wrapMode: Text.WordWrap } + Label { + Layout.preferredWidth: detailTextWidth + text: selectedLaunchPublicCopy + color: "#C9F0D4" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Label { + Layout.preferredWidth: detailTextWidth + text: selectedStreamLifecycleCopy + color: "#A8B0D8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + Label { Layout.preferredWidth: detailTextWidth text: selectedLaunchPreviewText diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index f0ee9bbe..cbad4ca4 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -383,17 +383,114 @@ DeckLaunchIntentBoundary previewOnlyLaunchIntentBoundary() { }; } +DeckLaunchMode launchModeFor(const PolarisGameFixture& game) { + const std::string mode = game.steamLaunch.recommendedMode.empty() ? "direct" : game.steamLaunch.recommendedMode; + if (mode == "direct") { + return DeckLaunchMode::SteamDirect; + } + if (mode == "big-picture") { + return DeckLaunchMode::SteamBigPicture; + } + return DeckLaunchMode::UnsupportedPreview; +} + +std::string launchModeIdFor(const DeckLaunchMode mode) { + if (mode == DeckLaunchMode::SteamDirect) { + return "steam-direct"; + } + if (mode == DeckLaunchMode::SteamBigPicture) { + return "steam-big-picture"; + } + return "unsupported-preview"; +} + +std::string launchModeCopyFor(const DeckLaunchMode mode) { + if (mode == DeckLaunchMode::SteamDirect) { + return "Steam direct"; + } + if (mode == DeckLaunchMode::SteamBigPicture) { + return "Steam Big Picture"; + } + return "unsupported preview"; +} + +DeckStreamProfilePreview streamProfileFor(const PolarisGameFixture& game) { + const std::string id = game.launchMode.recommendedMode.empty() ? "preview" : game.launchMode.recommendedMode; + return DeckStreamProfilePreview{ + .id = id, + .displayName = id == "headless" ? std::string{"Headless preview"} + : id == "virtual_display" ? std::string{"Virtual display preview"} + : std::string{"Preview profile"}, + .virtualDisplayRecommended = id == "virtual_display", + .headlessRecommended = id == "headless", + }; +} + DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game) { + const auto mode = launchModeFor(game); + const auto streamProfile = streamProfileFor(game); + const std::string hostId(detail.id); + const std::string hostName(detail.displayName); + const std::string gameTitle = game.name.empty() ? std::string{"Untitled game"} : game.name; + const auto boundary = previewOnlyLaunchIntentBoundary(); + const std::string uri = "preview://nova-deck/launch?host=" + encodePreviewComponent(hostId) + + "&game=" + encodePreviewComponent(gameTitle) + + "&mode=" + launchModeIdFor(mode) + + "&stream=" + encodePreviewComponent(streamProfile.id) + + "&state=noop-preview"; return DeckLaunchIntent{ - .targetHostId = std::string(detail.id), - .targetHostName = std::string(detail.displayName), + .targetHostId = hostId, + .targetHostName = hostName, .sampleGameId = game.id, - .gameTitle = game.name, - .streamLaunchMode = game.launchMode.recommendedMode.empty() ? std::string{"preview"} : game.launchMode.recommendedMode, + .gameTitle = gameTitle, + .streamLaunchMode = streamProfile.id, .steamLaunchMode = game.steamLaunch.recommendedMode.empty() ? std::string{"direct"} : game.steamLaunch.recommendedMode, - .boundary = previewOnlyLaunchIntentBoundary(), + .boundary = boundary, .executable = false, .safetyLabel = std::string(kPreviewStateLabel), + .host = DeckHostIdentity{ + .id = hostId, + .displayName = hostName, + .addressClass = hostId == "host-detail-empty" ? DeckHostAddressClass::UnknownUnavailable : DeckHostAddressClass::DemoOnly, + .addressLabel = "redacted preview host", + }, + .game = DeckGameIdentity{ + .identityKind = game.steamAppid.empty() ? DeckGameIdentityKind::LibraryFixture : DeckGameIdentityKind::SteamApp, + .libraryId = game.id, + .title = gameTitle, + .appId = game.appId, + .steamAppId = game.steamAppid, + }, + .launchMode = mode, + .streamProfile = streamProfile, + .preflight = DeckPreflightFailureState{ + .state = mode == DeckLaunchMode::UnsupportedPreview ? DeckPreflightState::UnsupportedLaunchMode : DeckPreflightState::ReadyPreview, + .reason = "Preflight-only preview; no backend, launch, or stream session starts.", + }, + .privacy = DeckPreviewPrivacyPolicy{ + .redactionPolicy = DeckPreviewRedactionPolicy::PublicSafe, + .publicSafeCopyOnly = true, + .localPrivateArtRedacted = true, + }, + .safety = DeckPreviewSafetyBooleans{}, + .publicPreviewCopy = "Preview " + gameTitle + " on " + hostName + " via " + launchModeCopyFor(mode) + "; no launch will run.", + .inertPreviewUri = uri, + }; +} + +DeckStreamIntent resolveStreamIntent(const DeckLaunchIntent& intent) { + return DeckStreamIntent{ + .provider = DeckStreamProvider::PreviewOnly, + .action = DeckStreamAction::NoopPreview, + .session = DeckStreamSessionPreview{ + .state = DeckStreamSessionState::NotStarted, + .reason = "not_started: preview-only boundary never opens a stream session", + }, + .lifecycle = DeckStreamLifecycle::PreflightOnly, + .recovery = DeckStreamRecovery::UserReviewRequired, + .privacy = intent.privacy, + .safety = intent.safety, + .publicCopy = "Preview stream for " + intent.gameTitle + " on " + intent.targetHostName + " remains noop_preview/not_started.", }; } @@ -440,9 +537,7 @@ bool canExecuteLaunchIntent(const DeckLaunchIntent& intent) { DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent) { return DeckLaunchPreview{ - .text = "preview://nova-deck/launch?host=" + encodePreviewComponent(intent.targetHostId) - + "&game=" + encodePreviewComponent(intent.gameTitle) - + "&state=copy-preview-only", + .text = intent.inertPreviewUri, .stateLabel = std::string(kPreviewStateLabel), .boundaryId = intent.boundary.id, .boundaryLabel = intent.boundary.label, diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 6d7727ec..473ad8ea 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -74,6 +74,78 @@ struct DeckLaunchIntentBoundary { bool allowsHostMutation = false; }; +enum class DeckHostAddressClass { + DemoOnly, + SnapshotOnly, + UnknownUnavailable, +}; + +enum class DeckGameIdentityKind { + SteamApp, + LibraryFixture, + Unknown, +}; + +enum class DeckLaunchMode { + SteamDirect, + SteamBigPicture, + UnsupportedPreview, +}; + +enum class DeckPreflightState { + ReadyPreview, + HostUnavailable, + HostBusy, + PairingNeeded, + UnsupportedLaunchMode, +}; + +enum class DeckPreviewRedactionPolicy { + PublicSafe, + LocalPrivateRedacted, +}; + +struct DeckHostIdentity { + std::string id; + std::string displayName; + DeckHostAddressClass addressClass = DeckHostAddressClass::DemoOnly; + std::string addressLabel; +}; + +struct DeckGameIdentity { + DeckGameIdentityKind identityKind = DeckGameIdentityKind::Unknown; + std::string libraryId; + std::string title; + int appId = 0; + std::string steamAppId; +}; + +struct DeckStreamProfilePreview { + std::string id; + std::string displayName; + bool virtualDisplayRecommended = false; + bool headlessRecommended = false; +}; + +struct DeckPreflightFailureState { + DeckPreflightState state = DeckPreflightState::ReadyPreview; + std::string reason; +}; + +struct DeckPreviewPrivacyPolicy { + DeckPreviewRedactionPolicy redactionPolicy = DeckPreviewRedactionPolicy::PublicSafe; + bool publicSafeCopyOnly = true; + bool localPrivateArtRedacted = true; +}; + +struct DeckPreviewSafetyBooleans { + bool allowsNetwork = false; + bool allowsProcessExecution = false; + bool allowsMoonlight = false; + bool allowsHostMutation = false; + bool executable = false; +}; + struct DeckLaunchIntent { std::string targetHostId; std::string targetHostName; @@ -84,6 +156,51 @@ struct DeckLaunchIntent { DeckLaunchIntentBoundary boundary; bool executable = false; std::string safetyLabel; + DeckHostIdentity host; + DeckGameIdentity game; + DeckLaunchMode launchMode = DeckLaunchMode::UnsupportedPreview; + DeckStreamProfilePreview streamProfile; + DeckPreflightFailureState preflight; + DeckPreviewPrivacyPolicy privacy; + DeckPreviewSafetyBooleans safety; + std::string publicPreviewCopy; + std::string inertPreviewUri; +}; + +enum class DeckStreamProvider { + PreviewOnly, +}; + +enum class DeckStreamAction { + NoopPreview, +}; + +enum class DeckStreamSessionState { + NotStarted, +}; + +enum class DeckStreamLifecycle { + PreflightOnly, +}; + +enum class DeckStreamRecovery { + UserReviewRequired, +}; + +struct DeckStreamSessionPreview { + DeckStreamSessionState state = DeckStreamSessionState::NotStarted; + std::string reason; +}; + +struct DeckStreamIntent { + DeckStreamProvider provider = DeckStreamProvider::PreviewOnly; + DeckStreamAction action = DeckStreamAction::NoopPreview; + DeckStreamSessionPreview session; + DeckStreamLifecycle lifecycle = DeckStreamLifecycle::PreflightOnly; + DeckStreamRecovery recovery = DeckStreamRecovery::UserReviewRequired; + DeckPreviewPrivacyPolicy privacy; + DeckPreviewSafetyBooleans safety; + std::string publicCopy; }; struct DeckLaunchPreview { @@ -184,6 +301,8 @@ DeckLaunchIntentBoundary previewOnlyLaunchIntentBoundary(); DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game); +DeckStreamIntent resolveStreamIntent(const DeckLaunchIntent& intent); + DeckLaunchPreviewBinding resolveLaunchPreviewBinding( const std::vector& hosts, const PolarisGameLibraryFixture& library, diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 71df9d27..08ec7e4f 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -213,6 +213,25 @@ QVariantMap toLaunchIntentBoundaryModel(const nova::deck::DeckLaunchIntentBounda return model; } +QVariantMap toLaunchIntentPreviewModel( + const nova::deck::DeckLaunchIntent& intent, + const nova::deck::DeckStreamIntent& streamIntent) { + QVariantMap model; + model.insert("hostId", toQString(intent.host.id)); + model.insert("hostDisplayName", toQString(intent.host.displayName)); + model.insert("gameId", toQString(intent.game.libraryId)); + model.insert("gameTitle", toQString(intent.game.title)); + model.insert("streamProfileId", toQString(intent.streamProfile.id)); + model.insert("streamProfileLabel", toQString(intent.streamProfile.displayName)); + model.insert("preflightReason", toQString(intent.preflight.reason)); + model.insert("publicCopy", toQString(intent.publicPreviewCopy)); + model.insert("inertPreviewUri", toQString(intent.inertPreviewUri)); + model.insert("streamLifecycleCopy", toQString(streamIntent.publicCopy)); + model.insert("noopPreview", true); + model.insert("notStarted", true); + return model; +} + QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyAction& copyAction) { QVariantMap model; model.insert("id", toQString(copyAction.id)); @@ -244,6 +263,7 @@ int main(int argc, char *argv[]) { initialGameId); const auto& selectedHostDetail = selectedBinding.hostDetail; const auto& launchIntent = selectedBinding.intent; + const auto streamIntent = nova::deck::resolveStreamIntent(launchIntent); const auto& launchCta = selectedBinding.launchCta; const auto& launchPreviewCopyAction = selectedBinding.copyAction; @@ -264,6 +284,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaSelectedLaunchPreviewText", toQString(selectedBinding.preview.text)); engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary)); + engine.rootContext()->setContextProperty("novaLaunchIntentPreview", toLaunchIntentPreviewModel(launchIntent, streamIntent)); engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); engine.rootContext()->setContextProperty("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 7743e893..a2c37b5a 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -89,6 +89,10 @@ int main() { assert(mainQml.find("Read-only snapshot loaded") != std::string::npos); assert(mainQml.find("Snapshot unavailable in this preview shell") != std::string::npos); assert(mainQml.find("A copies the preview URI locally only") != std::string::npos); + assert(mainQml.find("novaLaunchIntentPreview") != std::string::npos); + assert(mainQml.find("selectedLaunchPublicCopy") != std::string::npos); + assert(mainQml.find("selectedStreamLifecycleCopy") != std::string::npos); + assert(mainQml.find("state=copy-preview-only") == std::string::npos); assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, @@ -173,7 +177,7 @@ int main() { 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")); + assert(launchCta.previewText == std::string_view("preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&mode=steam-direct&stream=headless&state=noop-preview")); const auto launchGame = nova::deck::loadSamplePolarisGameFixture(); const auto launchIntent = nova::deck::resolveLaunchIntent(detail, launchGame); @@ -194,8 +198,33 @@ int main() { 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(launchIntent.host.addressClass == nova::deck::DeckHostAddressClass::DemoOnly); + assert(launchIntent.host.id == "host-gaming-pc"); + assert(launchIntent.host.displayName == "Gaming PC"); + assert(launchIntent.game.identityKind == nova::deck::DeckGameIdentityKind::SteamApp); + assert(launchIntent.game.libraryId == "game-123"); + assert(launchIntent.game.steamAppId == "620"); + assert(launchIntent.launchMode == nova::deck::DeckLaunchMode::SteamDirect); + assert(launchIntent.streamProfile.id == "headless"); + assert(launchIntent.streamProfile.displayName == "Headless preview"); + assert(launchIntent.preflight.state == nova::deck::DeckPreflightState::ReadyPreview); + assert(launchIntent.privacy.redactionPolicy == nova::deck::DeckPreviewRedactionPolicy::PublicSafe); + assert(launchIntent.publicPreviewCopy == "Preview Portal 2 on Gaming PC via Steam direct; no launch will run."); + assert(launchIntent.inertPreviewUri == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&mode=steam-direct&stream=headless&state=noop-preview"); assert(!nova::deck::canExecuteLaunchIntent(launchIntent)); + const auto streamIntent = nova::deck::resolveStreamIntent(launchIntent); + assert(streamIntent.provider == nova::deck::DeckStreamProvider::PreviewOnly); + assert(streamIntent.action == nova::deck::DeckStreamAction::NoopPreview); + assert(streamIntent.session.state == nova::deck::DeckStreamSessionState::NotStarted); + assert(streamIntent.lifecycle == nova::deck::DeckStreamLifecycle::PreflightOnly); + assert(streamIntent.recovery == nova::deck::DeckStreamRecovery::UserReviewRequired); + assert(streamIntent.privacy.redactionPolicy == nova::deck::DeckPreviewRedactionPolicy::PublicSafe); + assert(streamIntent.publicCopy == "Preview stream for Portal 2 on Gaming PC remains noop_preview/not_started."); + assert(!streamIntent.safety.allowsNetwork); + assert(!streamIntent.safety.allowsProcessExecution); + assert(!streamIntent.safety.allowsMoonlight); + const auto previewLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( demoHosts, @@ -216,7 +245,7 @@ int main() { assert(selectedBinding.intent.steamLaunchMode == "big-picture"); assert(!selectedBinding.intent.executable); assert(!nova::deck::canExecuteLaunchIntent(selectedBinding.intent)); - assert(selectedBinding.preview.text == "preview://nova-deck/launch?host=host-living-room-pc&game=Hades&state=copy-preview-only"); + assert(selectedBinding.preview.text == "preview://nova-deck/launch?host=host-living-room-pc&game=Hades&mode=steam-big-picture&stream=virtual_display&state=noop-preview"); assert(selectedBinding.preview.copyOnly); assert(!selectedBinding.preview.executable); assert(!selectedBinding.preview.networkAllowed); @@ -237,10 +266,10 @@ int main() { "missing-game"); assert(fallbackBinding.selectedHostId == "host-gaming-pc"); assert(fallbackBinding.selectedGameId == "game-123"); - assert(fallbackBinding.preview.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&state=copy-preview-only"); + assert(fallbackBinding.preview.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&mode=steam-direct&stream=headless&state=noop-preview"); 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.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&mode=steam-direct&stream=headless&state=noop-preview"); assert(commandPreview.stateLabel == "Preview only — not executable"); assert(commandPreview.boundaryId == "deck-launch-preview-only"); assert(commandPreview.boundaryLabel == "Preview-only typed intent boundary"); @@ -390,7 +419,7 @@ int main() { == std::string_view("game-empty-state")); 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.previewText == std::string_view("preview://nova-deck/launch?host=host-gaming-pc&game=Hades&mode=steam-big-picture&stream=virtual_display&state=noop-preview")); assert(!hadesLaunchCta.enabled); const auto game = nova::deck::loadSamplePolarisGameFixture();