From 9a47c6c0f110170c0f33de57134e15ef34cef23c Mon Sep 17 00:00:00 2001 From: papi-ux Date: Fri, 19 Jun 2026 22:08:38 -0400 Subject: [PATCH] feat(deck): show Moonlight readiness checklist preview --- clients/deck/qml/Main.qml | 81 ++++++++++++++++++- clients/deck/src/main.cpp | 29 +++++++ .../deck_moonlight_handoff_preflight.cpp | 70 ++++++++++++++++ .../stream/deck_moonlight_handoff_preflight.h | 14 ++++ clients/deck/tests/deck_layout_test.cpp | 6 ++ .../deck_moonlight_handoff_preflight_test.cpp | 42 ++++++++++ 6 files changed, 240 insertions(+), 2 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 1d865094..0a9ceb89 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -20,7 +20,7 @@ ApplicationWindow { readonly property int detailColumnWidth: 424 readonly property int hostCardHeight: 104 readonly property int detailPanelHeight: 132 - readonly property int launchPreviewHeight: 400 + readonly property int launchPreviewHeight: 424 readonly property int hostTextWidth: hostColumnWidth - 40 readonly property int sampleTextWidth: sampleCardWidth - 48 readonly property int detailTextWidth: detailColumnWidth - 48 @@ -39,6 +39,43 @@ ApplicationWindow { property string selectedMoonlightHandoffArgvPreview: moonlightHandoffPreflight.argvPreview property string selectedMoonlightHandoffFocusCopy: moonlightHandoffPreflight.focusFallbackCopy property string selectedMoonlightHandoffConfidence: moonlightHandoffPreflight.focusConfidence + readonly property var selectedMoonlightReadinessChecks: moonlightHandoffPreflight.readinessChecks ? moonlightHandoffPreflight.readinessChecks : [] + + function readinessStatusColor(status) { + if (status === "passed") { + return "#8AFFC1" + } + if (status === "blocked") { + return "#FFDDA8" + } + return "#B8C2F0" + } + + function readinessStatusCopy(status) { + if (status === "passed") { + return "Ready" + } + if (status === "blocked") { + return "Blocked" + } + return "Review" + } + + function readinessShortLabel(id, label) { + if (id === "safe-snapshot") { + return "Snap" + } + if (id === "app-snapshot") { + return "App" + } + if (id === "typed-argv") { + return "Argv" + } + if (id === "focus-return") { + return "Focus" + } + return label + } function selectedHostSubtitle() { return "Selected host only — not discovered from the network." @@ -686,7 +723,7 @@ ApplicationWindow { id: moonlightHandoffPanel objectName: "moonlight-handoff-panel" Layout.preferredWidth: detailTextWidth - Layout.preferredHeight: 178 + Layout.preferredHeight: 202 radius: 16 color: "#101A30" border.color: "#7C73FF" @@ -794,6 +831,46 @@ ApplicationWindow { } } + RowLayout { + objectName: "moonlight-readiness-row" + Layout.preferredWidth: detailTextWidth - 24 + spacing: 5 + + Label { + Layout.preferredWidth: 48 + text: "Checks" + color: "#7C88B8" + font.pixelSize: 9 + font.bold: true + maximumLineCount: 2 + elide: Text.ElideRight + } + + Repeater { + model: selectedMoonlightReadinessChecks + + Rectangle { + objectName: "moonlight-readiness-chip" + Layout.preferredWidth: 72 + Layout.preferredHeight: 22 + radius: 11 + color: modelData.status === "blocked" ? "#3A2224" : "#151D39" + border.color: readinessStatusColor(modelData.status) + border.width: 1 + + Label { + anchors.centerIn: parent + text: readinessShortLabel(modelData.id, modelData.label) + " " + readinessStatusCopy(modelData.status) + color: readinessStatusColor(modelData.status) + font.pixelSize: 8 + font.bold: true + maximumLineCount: 1 + elide: Text.ElideRight + } + } + } + } + RowLayout { objectName: "moonlight-plan-row" Layout.preferredWidth: detailTextWidth - 24 diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 9eb51b43..7e2d5bd5 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -292,6 +292,34 @@ QVariantList toStringListModel(const std::vector& values) { return model; } +QString moonlightReadinessStatusLabel( + const nova::deck::stream::DeckMoonlightReadinessCheckStatus status) { + using nova::deck::stream::DeckMoonlightReadinessCheckStatus; + switch (status) { + case DeckMoonlightReadinessCheckStatus::Passed: + return QStringLiteral("passed"); + case DeckMoonlightReadinessCheckStatus::Blocked: + return QStringLiteral("blocked"); + case DeckMoonlightReadinessCheckStatus::ReviewOnly: + return QStringLiteral("review_only"); + } + return QStringLiteral("unknown"); +} + +QVariantList toMoonlightReadinessCheckModel( + const std::vector& checks) { + QVariantList model; + for (const auto& check : checks) { + QVariantMap item; + item.insert("id", toQString(check.id)); + item.insert("label", toQString(check.label)); + item.insert("detail", toQString(check.detail)); + item.insert("status", moonlightReadinessStatusLabel(check.status)); + model.append(item); + } + return model; +} + QString argvPreviewFor(const std::vector& tokens) { if (tokens.size() == 4) { return QStringLiteral("Typed argv plan: app token + stream action + redacted host selector + ") @@ -310,6 +338,7 @@ QVariantMap toMoonlightHandoffPreflightModel( model.insert("argvTokens", toStringListModel(result.candidatePlan.argvTokens)); model.insert("argvTokenCount", static_cast(result.candidatePlan.argvTokens.size())); model.insert("argvPreview", argvPreviewFor(result.candidatePlan.argvTokens)); + model.insert("readinessChecks", toMoonlightReadinessCheckModel(result.readinessChecks)); model.insert("sourceSurface", toQString(result.focusReturnPlan.sourceSurface)); model.insert("intendedReturnTarget", toQString(result.focusReturnPlan.intendedReturnTarget)); model.insert("focusFallbackCopy", toQString(result.focusReturnPlan.fallbackCopy)); diff --git a/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp b/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp index 8515dbaf..1b6e2833 100644 --- a/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp +++ b/clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace nova::deck::stream { @@ -84,6 +85,74 @@ bool isUnsafeArgvToken(const std::string& value) { || containsUnsafeSecretLikeText(lowerCopy(value)); } +DeckMoonlightReadinessCheck readinessCheck( + std::string id, + std::string label, + DeckMoonlightReadinessCheckStatus status, + std::string detail) { + return DeckMoonlightReadinessCheck{ + .id = std::move(id), + .label = std::move(label), + .detail = std::move(detail), + .status = status, + }; +} + +std::vector readinessChecksFor( + const DeckMoonlightHandoffPreflightRequest& request) { + std::vector checks; + checks.reserve(4); + + checks.push_back(readinessCheck( + "safe-snapshot", + "Safe snapshot", + request.hasSafeSnapshot ? DeckMoonlightReadinessCheckStatus::Passed : DeckMoonlightReadinessCheckStatus::Blocked, + request.hasSafeSnapshot + ? "Read-only host snapshot is available for local review." + : "Needs safe host snapshot before typed handoff review.")); + + checks.push_back(readinessCheck( + "app-snapshot", + "App in snapshot", + request.appPresentInSnapshot ? DeckMoonlightReadinessCheckStatus::Passed : DeckMoonlightReadinessCheckStatus::Blocked, + request.appPresentInSnapshot + ? "Game appears in snapshot for local review." + : "Game missing from snapshot; review stays blocked.")); + + const auto privateHostSelector = isBlank(request.privateHostSelectorRedactedForDebug) + ? std::string{"redacted-host-selector"} + : request.privateHostSelectorRedactedForDebug; + DeckMoonlightReadinessCheckStatus argvStatus = DeckMoonlightReadinessCheckStatus::Passed; + std::string argvDetail = "Typed argv preview is redacted and copy-only."; + if (!request.hasSafeSnapshot) { + argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; + argvDetail = "Snapshot gate must pass first; no typed handoff review yet."; + } else if (!request.appPresentInSnapshot) { + argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; + argvDetail = "App snapshot gate must pass first; no typed handoff review yet."; + } else if (request.requestedSurface != DeckMoonlightHandoffSurface::MoonlightQtCli) { + argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; + argvDetail = "Moonlight Qt CLI surface is required for typed handoff review."; + } else if (isUnsafePublicText(request.hostDisplayNamePublic) || isUnsafePublicText(request.gameTitlePublic) + || isUnsafeArgvToken(privateHostSelector)) { + argvStatus = DeckMoonlightReadinessCheckStatus::Blocked; + argvDetail = "Typed argv preview is not public-safe; review stays blocked."; + } + checks.push_back(readinessCheck( + "typed-argv", + "Typed argv", + argvStatus, + argvDetail)); + + checks.push_back(readinessCheck( + "focus-return", + "Focus return", + DeckMoonlightReadinessCheckStatus::ReviewOnly, + "Focus return remains unproven_static until a later approved runtime check.")); + + return checks; +} + DeckMoonlightFocusReturnPlan focusReturnPlanFor(const DeckMoonlightHandoffPreflightRequest& request) { const auto target = (!isBlank(request.hostDisplayNamePublic) && !isBlank(request.gameTitlePublic)) ? request.hostDisplayNamePublic + " / " + request.gameTitlePublic @@ -99,6 +168,7 @@ DeckMoonlightFocusReturnPlan focusReturnPlanFor(const DeckMoonlightHandoffPrefli DeckMoonlightHandoffPreflightResult baseResult(const DeckMoonlightHandoffPreflightRequest& request) { DeckMoonlightHandoffPreflightResult result; result.focusReturnPlan = focusReturnPlanFor(request); + result.readinessChecks = readinessChecksFor(request); return result; } diff --git a/clients/deck/src/stream/deck_moonlight_handoff_preflight.h b/clients/deck/src/stream/deck_moonlight_handoff_preflight.h index 6c8e8228..7f5690ea 100644 --- a/clients/deck/src/stream/deck_moonlight_handoff_preflight.h +++ b/clients/deck/src/stream/deck_moonlight_handoff_preflight.h @@ -62,6 +62,19 @@ struct DeckMoonlightFocusReturnPlan { std::string confidence; }; +enum class DeckMoonlightReadinessCheckStatus { + Passed, + Blocked, + ReviewOnly, +}; + +struct DeckMoonlightReadinessCheck { + std::string id; + std::string label; + std::string detail; + DeckMoonlightReadinessCheckStatus status = DeckMoonlightReadinessCheckStatus::ReviewOnly; +}; + struct DeckMoonlightHandoffPreflightResult { DeckMoonlightHandoffVerdict verdict = DeckMoonlightHandoffVerdict::BlockedStatic; bool executable = false; @@ -72,6 +85,7 @@ struct DeckMoonlightHandoffPreflightResult { bool safeToRender = false; DeckMoonlightHandoffCandidatePlan candidatePlan; DeckMoonlightFocusReturnPlan focusReturnPlan; + std::vector readinessChecks; std::string publicPreviewCopy; std::vector blockedReasons; }; diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 9029db40..d92ae7ac 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -108,6 +108,12 @@ int main() { assert(mainQml.find("objectName: \"moonlight-handoff-panel\"") != std::string::npos); assert(mainQml.find("objectName: \"moonlight-handoff-title-row\"") != std::string::npos); assert(mainQml.find("objectName: \"moonlight-safety-chip-row\"") != std::string::npos); + assert(mainQml.find("objectName: \"moonlight-readiness-row\"") != std::string::npos); + assert(mainQml.find("Checks") != std::string::npos); + assert(mainQml.find("moonlightHandoffPreflight.readinessChecks") != std::string::npos); + assert(mainQml.find("readonly property var selectedMoonlightReadinessChecks") != std::string::npos); + assert(mainQml.find("function readinessStatusColor") != std::string::npos); + assert(mainQml.find("function readinessStatusCopy") != std::string::npos); assert(mainQml.find("objectName: \"moonlight-plan-row\"") != std::string::npos); assert(mainQml.find("objectName: \"moonlight-runtime-gates-line\"") != std::string::npos); assert(mainQml.find("objectName: \"moonlight-runtime-gate-chip\"") != std::string::npos); diff --git a/clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp b/clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp index b9ec5b3e..8f391c26 100644 --- a/clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp +++ b/clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp @@ -17,6 +17,8 @@ using nova::deck::stream::DeckMoonlightHandoffPreflightRequest; using nova::deck::stream::DeckMoonlightHandoffPreflightResult; using nova::deck::stream::DeckMoonlightHandoffSurface; using nova::deck::stream::DeckMoonlightHandoffVerdict; +using nova::deck::stream::DeckMoonlightReadinessCheck; +using nova::deck::stream::DeckMoonlightReadinessCheckStatus; using nova::deck::stream::resolveDeckMoonlightHandoffPreflight; DeckMoonlightHandoffPreflightRequest validRequest( @@ -75,6 +77,34 @@ void assertFocusReturnUnproven(const DeckMoonlightFocusReturnPlan& plan) { assert(contains(plan.fallbackCopy, "later approved launch")); } +const DeckMoonlightReadinessCheck& readinessCheck( + const DeckMoonlightHandoffPreflightResult& result, + const std::string_view id) { + const auto match = std::find_if( + result.readinessChecks.begin(), + result.readinessChecks.end(), + [&](const DeckMoonlightReadinessCheck& check) { + return check.id == id; + }); + assert(match != result.readinessChecks.end()); + return *match; +} + +void assertReadinessCheck( + const DeckMoonlightHandoffPreflightResult& result, + const std::string_view id, + const DeckMoonlightReadinessCheckStatus status, + const std::string_view detailNeedle) { + const auto& check = readinessCheck(result, id); + assert(check.status == status); + assert(!check.label.empty()); + assert(contains(check.detail, detailNeedle)); + assert(!contains(check.detail, "moonlight://")); + assert(!contains(check.detail, "http://")); + assert(!contains(check.detail, "https://")); + assert(!contains(check.detail, "ssh")); +} + } // namespace static_assert(std::is_default_constructible_v); @@ -100,6 +130,11 @@ int main() { assert(!contains(result.publicPreviewCopy, "redacted-host-selector")); assertFocusReturnUnproven(result.focusReturnPlan); assert(result.blockedReasons.empty()); + assert(result.readinessChecks.size() == 4); + assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Read-only host snapshot"); + assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Game appears in snapshot"); + assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Passed, "Typed argv preview is redacted"); + assertReadinessCheck(result, "focus-return", DeckMoonlightReadinessCheckStatus::ReviewOnly, "Focus return remains unproven_static"); } { @@ -130,6 +165,9 @@ int main() { assert(hasReason(result, DeckMoonlightHandoffBlockReason::FocusReturnUnprovenStatic)); assert(contains(result.publicPreviewCopy, "cannot verify Moonlight readiness")); assertFocusReturnUnproven(result.focusReturnPlan); + assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Blocked, "Needs safe host snapshot"); + assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Game appears in snapshot"); + assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "Snapshot gate must pass first"); } { @@ -139,6 +177,9 @@ int main() { assertBlockedStatic(result); assert(hasReason(result, DeckMoonlightHandoffBlockReason::AppNotInSnapshot)); assert(hasReason(result, DeckMoonlightHandoffBlockReason::HostPairingUnprovenStatic)); + assertReadinessCheck(result, "safe-snapshot", DeckMoonlightReadinessCheckStatus::Passed, "Read-only host snapshot"); + assertReadinessCheck(result, "app-snapshot", DeckMoonlightReadinessCheckStatus::Blocked, "Game missing from snapshot"); + assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "App snapshot gate must pass first"); } { @@ -207,6 +248,7 @@ int main() { assertBlockedStatic(result); assert(hasReason(result, DeckMoonlightHandoffBlockReason::UnsafeArgvToken)); assert(!contains(result.publicPreviewCopy, "host selector; launch")); + assertReadinessCheck(result, "typed-argv", DeckMoonlightReadinessCheckStatus::Blocked, "Typed argv preview is not public-safe"); } return 0;