Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions clients/deck/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions clients/deck/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,34 @@ QVariantList toStringListModel(const std::vector<std::string>& 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<nova::deck::stream::DeckMoonlightReadinessCheck>& 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<std::string>& tokens) {
if (tokens.size() == 4) {
return QStringLiteral("Typed argv plan: app token + stream action + redacted host selector + ")
Expand All @@ -310,6 +338,7 @@ QVariantMap toMoonlightHandoffPreflightModel(
model.insert("argvTokens", toStringListModel(result.candidatePlan.argvTokens));
model.insert("argvTokenCount", static_cast<int>(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));
Expand Down
70 changes: 70 additions & 0 deletions clients/deck/src/stream/deck_moonlight_handoff_preflight.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <regex>
#include <string>
#include <string_view>
#include <utility>

namespace nova::deck::stream {

Expand Down Expand Up @@ -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<DeckMoonlightReadinessCheck> readinessChecksFor(
const DeckMoonlightHandoffPreflightRequest& request) {
std::vector<DeckMoonlightReadinessCheck> 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
Expand All @@ -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;
}

Expand Down
14 changes: 14 additions & 0 deletions clients/deck/src/stream/deck_moonlight_handoff_preflight.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -72,6 +85,7 @@ struct DeckMoonlightHandoffPreflightResult {
bool safeToRender = false;
DeckMoonlightHandoffCandidatePlan candidatePlan;
DeckMoonlightFocusReturnPlan focusReturnPlan;
std::vector<DeckMoonlightReadinessCheck> readinessChecks;
std::string publicPreviewCopy;
std::vector<DeckMoonlightHandoffBlockReason> blockedReasons;
};
Expand Down
6 changes: 6 additions & 0 deletions clients/deck/tests/deck_layout_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions clients/deck/tests/deck_moonlight_handoff_preflight_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<DeckMoonlightHandoffPreflightRequest>);
Expand All @@ -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");
}

{
Expand Down Expand Up @@ -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");
}

{
Expand All @@ -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");
}

{
Expand Down Expand Up @@ -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;
Expand Down
Loading