Skip to content

Commit e6a9fcc

Browse files
authored
feat(deck): add Moonlight handoff preflight preview
Adds a local-only Deck Moonlight handoff preflight model and visible Nova Deck review panel. Keeps runtime boundaries closed: no Moonlight/Sunshine/Steam/game launch, no discovery/pairing/credentials, no live runtime calls, no network/process execution path. Verified via PR CI, Linux/SteamOS Deck tests, and private staged Deck/Game Mode smoke.
1 parent ccb65dd commit e6a9fcc

8 files changed

Lines changed: 865 additions & 9 deletions

clients/deck/CMakeLists.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ add_library(nova_deck_core
2323
src/deck_gamepad.cpp
2424
src/deck_layout.cpp
2525
src/polaris_game_fixture.cpp
26+
src/stream/deck_moonlight_handoff_preflight.cpp
2627
src/stream/deck_stream_core.cpp
2728
src/stream/deck_stream_media_adapters.cpp
2829
)
@@ -79,6 +80,16 @@ if(BUILD_TESTING)
7980
target_link_libraries(nova_deck_stream_media_adapters_test PRIVATE nova_deck_core)
8081
add_test(NAME nova_deck_stream_media_adapters_test COMMAND nova_deck_stream_media_adapters_test)
8182
83+
add_executable(nova_deck_moonlight_handoff_preflight_test
84+
tests/deck_moonlight_handoff_preflight_test.cpp
85+
)
86+
target_link_libraries(nova_deck_moonlight_handoff_preflight_test PRIVATE nova_deck_core)
87+
add_test(NAME nova_deck_moonlight_handoff_preflight_test COMMAND nova_deck_moonlight_handoff_preflight_test)
88+
89+
add_test(NAME nova_deck_moonlight_handoff_source_guard_test
90+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_moonlight_handoff_source_guard_test.py
91+
)
92+
8293
add_test(NAME nova_deck_gamemode_capture_harness_test
8394
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_gamemode_capture_test.py
8495
)

clients/deck/qml/Main.qml

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ ApplicationWindow {
1919
readonly property int sampleCardWidth: 392
2020
readonly property int detailColumnWidth: 424
2121
readonly property int hostCardHeight: 104
22-
readonly property int detailPanelHeight: 184
23-
readonly property int launchPreviewHeight: 258
22+
readonly property int detailPanelHeight: 150
23+
readonly property int launchPreviewHeight: 400
2424
readonly property int hostTextWidth: hostColumnWidth - 40
2525
readonly property int sampleTextWidth: sampleCardWidth - 48
2626
readonly property int detailTextWidth: detailColumnWidth - 48
@@ -32,8 +32,13 @@ ApplicationWindow {
3232
property string selectedLaunchPreviewText: novaSelectedLaunchPreviewText
3333
property var launchPreviewCopyAction: novaLaunchPreviewCopyAction
3434
property var launchIntentPreview: novaLaunchIntentPreview
35+
property var moonlightHandoffPreflight: novaMoonlightHandoffPreflight
3536
property string selectedLaunchPublicCopy: launchIntentPreview.publicCopy
3637
property string selectedStreamLifecycleCopy: launchIntentPreview.streamLifecycleCopy
38+
property string selectedMoonlightHandoffCopy: moonlightHandoffPreflight.publicPreviewCopy
39+
property string selectedMoonlightHandoffArgvPreview: moonlightHandoffPreflight.argvPreview
40+
property string selectedMoonlightHandoffFocusCopy: moonlightHandoffPreflight.focusFallbackCopy
41+
property string selectedMoonlightHandoffConfidence: moonlightHandoffPreflight.focusConfidence
3742

3843
function selectedHostSubtitle() {
3944
return "Selected host only — not discovered from the network."
@@ -43,6 +48,36 @@ ApplicationWindow {
4348
return encodeURIComponent(value === undefined || value === null ? "" : String(value))
4449
}
4550

51+
function moonlightHandoffRuntimeGatesClosed() {
52+
return moonlightHandoffPreflight.safeToRender
53+
&& !moonlightHandoffPreflight.executable
54+
&& !moonlightHandoffPreflight.allowsNetwork
55+
&& !moonlightHandoffPreflight.allowsProcessExecution
56+
&& !moonlightHandoffPreflight.allowsMoonlight
57+
&& !moonlightHandoffPreflight.allowsHostMutation
58+
}
59+
60+
function refreshMoonlightHandoffPreflightBinding(hostName, gameTitle) {
61+
moonlightHandoffPreflight = novaMoonlightHandoffPreflightBridge.resolve(
62+
hostName,
63+
gameTitle,
64+
novaLibraryReadOnly,
65+
novaLibraryGames.length > 0)
66+
const canRenderMoonlightHandoff = moonlightHandoffRuntimeGatesClosed()
67+
selectedMoonlightHandoffCopy = canRenderMoonlightHandoff
68+
? moonlightHandoffPreflight.publicPreviewCopy
69+
: "Moonlight handoff preview blocked until safe public copy is available. Nothing will launch yet."
70+
selectedMoonlightHandoffArgvPreview = canRenderMoonlightHandoff
71+
? moonlightHandoffPreflight.argvPreview
72+
: "Typed argv plan unavailable until the preflight is safe to render."
73+
selectedMoonlightHandoffFocusCopy = canRenderMoonlightHandoff
74+
? moonlightHandoffPreflight.focusFallbackCopy
75+
: "Return behavior withheld until the preflight is safe to render."
76+
selectedMoonlightHandoffConfidence = canRenderMoonlightHandoff
77+
? moonlightHandoffPreflight.focusConfidence
78+
: "blocked_static"
79+
}
80+
4681
function refreshLaunchPreviewBinding() {
4782
const hostId = selectedHostForPreview && selectedHostForPreview.id
4883
? selectedHostForPreview.id
@@ -72,6 +107,7 @@ ApplicationWindow {
72107
+ "&state=noop-preview"
73108
selectedLaunchPublicCopy = "Review " + gameTitle + " on " + hostName + " via " + steamCopy + ". Safe preview only; no game or stream starts."
74109
selectedStreamLifecycleCopy = "Safe preview of " + gameTitle + " on " + hostName + "; stream remains not started."
110+
refreshMoonlightHandoffPreflightBinding(hostName, gameTitle)
75111
launchPreviewCopyAction = {
76112
"id": novaLaunchPreviewCopyAction.id,
77113
"label": novaLaunchPreviewCopyAction.label,
@@ -616,12 +652,63 @@ ApplicationWindow {
616652
wrapMode: Text.WordWrap
617653
}
618654

619-
Label {
655+
Rectangle {
656+
id: moonlightHandoffPanel
657+
objectName: "moonlight-handoff-panel"
620658
Layout.preferredWidth: detailTextWidth
621-
text: "Exact preview details stay behind Copy preview details — copy locally to inspect the preview URI."
622-
color: "#7C88B8"
623-
font.pixelSize: 12
624-
wrapMode: Text.WordWrap
659+
Layout.preferredHeight: 118
660+
radius: 14
661+
color: "#101A30"
662+
border.color: "#7C73FF"
663+
border.width: 2
664+
665+
ColumnLayout {
666+
anchors.fill: parent
667+
anchors.margins: 12
668+
spacing: 3
669+
670+
Label {
671+
text: "Moonlight handoff preview — Nothing will launch yet"
672+
color: "#E9ECFF"
673+
font.pixelSize: 14
674+
font.bold: true
675+
}
676+
677+
Label {
678+
Layout.preferredWidth: detailTextWidth - 24
679+
text: selectedMoonlightHandoffCopy
680+
color: "#C9F0D4"
681+
font.pixelSize: 11
682+
wrapMode: Text.WordWrap
683+
}
684+
685+
Label {
686+
Layout.preferredWidth: detailTextWidth - 24
687+
text: "Typed argv plan · redacted host selector · " + selectedMoonlightHandoffArgvPreview
688+
color: "#B8C2F0"
689+
font.pixelSize: 10
690+
wrapMode: Text.WordWrap
691+
}
692+
693+
Label {
694+
Layout.preferredWidth: detailTextWidth - 24
695+
text: moonlightHandoffRuntimeGatesClosed()
696+
? "Runtime gates: network off · process off · Moonlight off · host mutation off"
697+
: "Runtime gate failed — review blocked"
698+
color: "#FFDDA8"
699+
font.pixelSize: 10
700+
font.bold: true
701+
wrapMode: Text.WordWrap
702+
}
703+
704+
Label {
705+
Layout.preferredWidth: detailTextWidth - 24
706+
text: "Focus return: unproven_static"
707+
color: "#7C88B8"
708+
font.pixelSize: 10
709+
wrapMode: Text.WordWrap
710+
}
711+
}
625712
}
626713

627714
Button {
@@ -642,12 +729,20 @@ ApplicationWindow {
642729
onClicked: activateLaunchPreviewCopyFromController()
643730
}
644731

732+
Label {
733+
Layout.preferredWidth: detailTextWidth
734+
text: "Exact preview details stay behind Copy preview details — copy locally to inspect the preview URI."
735+
color: "#7C88B8"
736+
font.pixelSize: 10
737+
wrapMode: Text.WordWrap
738+
}
739+
645740
Label {
646741
id: copyStatusLabel
647742
Layout.preferredWidth: detailTextWidth
648-
text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify. A Copy preview saves this safe plan locally for inspection."
743+
text: launchPreviewCopyAction.idleStatusLabel + " · A Copy preview saves this safe plan locally for inspection."
649744
color: "#FFDDA8"
650-
font.pixelSize: 14
745+
font.pixelSize: 10
651746
wrapMode: Text.WordWrap
652747
}
653748
}

clients/deck/src/main.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "deck_layout.h"
22
#include "deck_gamepad.h"
33
#include "polaris_game_fixture.h"
4+
#include "stream/deck_moonlight_handoff_preflight.h"
45

56
#include <QClipboard>
67
#include <QSocketNotifier>
@@ -246,6 +247,115 @@ QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyActi
246247
model.insert("executable", copyAction.executable);
247248
return model;
248249
}
250+
251+
QString moonlightHandoffVerdictLabel(const nova::deck::stream::DeckMoonlightHandoffVerdict verdict) {
252+
using nova::deck::stream::DeckMoonlightHandoffVerdict;
253+
switch (verdict) {
254+
case DeckMoonlightHandoffVerdict::ReadyForReview:
255+
return QStringLiteral("ready_for_review");
256+
case DeckMoonlightHandoffVerdict::BlockedStatic:
257+
return QStringLiteral("blocked_static");
258+
case DeckMoonlightHandoffVerdict::ForbiddenRuntimeBoundary:
259+
return QStringLiteral("forbidden_runtime_boundary");
260+
}
261+
return QStringLiteral("unknown");
262+
}
263+
264+
QString moonlightHandoffSurfaceLabel(const nova::deck::stream::DeckMoonlightHandoffSurface surface) {
265+
using nova::deck::stream::DeckMoonlightHandoffSurface;
266+
switch (surface) {
267+
case DeckMoonlightHandoffSurface::MoonlightQtCli:
268+
return QStringLiteral("moonlight_qt_cli");
269+
case DeckMoonlightHandoffSurface::HostAppSnapshot:
270+
return QStringLiteral("host_app_snapshot");
271+
case DeckMoonlightHandoffSurface::DesktopEntry:
272+
return QStringLiteral("desktop_entry");
273+
case DeckMoonlightHandoffSurface::FlatpakIdentity:
274+
return QStringLiteral("flatpak_identity");
275+
case DeckMoonlightHandoffSurface::SteamShortcut:
276+
return QStringLiteral("steam_shortcut");
277+
case DeckMoonlightHandoffSurface::CustomUri:
278+
return QStringLiteral("custom_uri");
279+
case DeckMoonlightHandoffSurface::NovaOwnedCommonCFuture:
280+
return QStringLiteral("nova_owned_common_c_future");
281+
case DeckMoonlightHandoffSurface::Unsupported:
282+
return QStringLiteral("unsupported");
283+
}
284+
return QStringLiteral("unknown");
285+
}
286+
287+
QVariantList toStringListModel(const std::vector<std::string>& values) {
288+
QVariantList model;
289+
for (const auto& value : values) {
290+
model.append(toQString(value));
291+
}
292+
return model;
293+
}
294+
295+
QString argvPreviewFor(const std::vector<std::string>& tokens) {
296+
if (tokens.size() == 4) {
297+
return QStringLiteral("Typed argv plan: app token + stream action + redacted host selector + ")
298+
+ toQString(tokens[3]);
299+
}
300+
return QStringLiteral("Typed argv plan unavailable until the preflight is ready for review.");
301+
}
302+
303+
QVariantMap toMoonlightHandoffPreflightModel(
304+
const nova::deck::stream::DeckMoonlightHandoffPreflightResult& result) {
305+
QVariantMap model;
306+
model.insert("verdict", moonlightHandoffVerdictLabel(result.verdict));
307+
model.insert("candidateSurface", moonlightHandoffSurfaceLabel(result.candidatePlan.surface));
308+
model.insert("publicPreviewCopy", toQString(result.publicPreviewCopy));
309+
model.insert("publicSummary", toQString(result.candidatePlan.publicSummary));
310+
model.insert("argvTokens", toStringListModel(result.candidatePlan.argvTokens));
311+
model.insert("argvTokenCount", static_cast<int>(result.candidatePlan.argvTokens.size()));
312+
model.insert("argvPreview", argvPreviewFor(result.candidatePlan.argvTokens));
313+
model.insert("sourceSurface", toQString(result.focusReturnPlan.sourceSurface));
314+
model.insert("intendedReturnTarget", toQString(result.focusReturnPlan.intendedReturnTarget));
315+
model.insert("focusFallbackCopy", toQString(result.focusReturnPlan.fallbackCopy));
316+
model.insert("focusConfidence", toQString(result.focusReturnPlan.confidence));
317+
model.insert("safeToRender", result.safeToRender);
318+
model.insert("executable", result.executable);
319+
model.insert("allowsNetwork", result.allowsNetwork);
320+
model.insert("allowsProcessExecution", result.allowsProcessExecution);
321+
model.insert("allowsMoonlight", result.allowsMoonlight);
322+
model.insert("allowsHostMutation", result.allowsHostMutation);
323+
return model;
324+
}
325+
326+
nova::deck::stream::DeckMoonlightHandoffPreflightResult resolveMoonlightHandoffPreflightFor(
327+
const QString& hostDisplayNamePublic,
328+
const QString& gameTitlePublic,
329+
const bool hasSafeSnapshot,
330+
const bool appPresentInSnapshot) {
331+
return nova::deck::stream::resolveDeckMoonlightHandoffPreflight(
332+
nova::deck::stream::DeckMoonlightHandoffPreflightRequest{
333+
.hostDisplayNamePublic = hostDisplayNamePublic.toStdString(),
334+
.gameTitlePublic = gameTitlePublic.toStdString(),
335+
.privateHostSelectorRedactedForDebug = "redacted-host-selector",
336+
.requestedSurface = nova::deck::stream::DeckMoonlightHandoffSurface::MoonlightQtCli,
337+
.hasSafeSnapshot = hasSafeSnapshot,
338+
.appPresentInSnapshot = appPresentInSnapshot,
339+
});
340+
}
341+
342+
class QtMoonlightHandoffPreflightBridge final : public QObject {
343+
Q_OBJECT
344+
public:
345+
using QObject::QObject;
346+
347+
Q_INVOKABLE QVariantMap resolve(
348+
const QString& hostDisplayNamePublic,
349+
const QString& gameTitlePublic,
350+
const bool hasSafeSnapshot,
351+
const bool appPresentInSnapshot) const {
352+
return toMoonlightHandoffPreflightModel(resolveMoonlightHandoffPreflightFor(
353+
hostDisplayNamePublic,
354+
gameTitlePublic,
355+
hasSafeSnapshot,
356+
appPresentInSnapshot));
357+
}
358+
};
249359
} // namespace
250360

251361
int main(int argc, char *argv[]) {
@@ -268,8 +378,15 @@ int main(int argc, char *argv[]) {
268378
const auto& launchPreviewCopyAction = selectedBinding.copyAction;
269379

270380
QtLocalClipboardBridge localClipboard;
381+
QtMoonlightHandoffPreflightBridge moonlightHandoffBridge;
271382
QtDeckGamepadBridge gamepadBridge;
272383

384+
const auto initialMoonlightHandoffPreflight = resolveMoonlightHandoffPreflightFor(
385+
toQString(selectedBinding.selectedHostName),
386+
toQString(selectedBinding.selectedGameTitle),
387+
sampleLibrary.readOnly,
388+
!sampleLibrary.games.empty());
389+
273390
QQmlApplicationEngine engine;
274391
engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName));
275392
engine.rootContext()->setContextProperty("novaDeckWidth", profile.width);
@@ -286,6 +403,8 @@ int main(int argc, char *argv[]) {
286403
engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary));
287404
engine.rootContext()->setContextProperty("novaLaunchIntentPreview", toLaunchIntentPreviewModel(launchIntent, streamIntent));
288405
engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction));
406+
engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflight", toMoonlightHandoffPreflightModel(initialMoonlightHandoffPreflight));
407+
engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflightBridge", &moonlightHandoffBridge);
289408
engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard);
290409
engine.rootContext()->setContextProperty("novaGamepad", &gamepadBridge);
291410
engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(libraryHosts)));

0 commit comments

Comments
 (0)