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
11 changes: 11 additions & 0 deletions clients/deck/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ add_library(nova_deck_core
src/deck_gamepad.cpp
src/deck_layout.cpp
src/polaris_game_fixture.cpp
src/stream/deck_moonlight_handoff_preflight.cpp
src/stream/deck_stream_core.cpp
src/stream/deck_stream_media_adapters.cpp
)
Expand Down Expand Up @@ -79,6 +80,16 @@ if(BUILD_TESTING)
target_link_libraries(nova_deck_stream_media_adapters_test PRIVATE nova_deck_core)
add_test(NAME nova_deck_stream_media_adapters_test COMMAND nova_deck_stream_media_adapters_test)

add_executable(nova_deck_moonlight_handoff_preflight_test
tests/deck_moonlight_handoff_preflight_test.cpp
)
target_link_libraries(nova_deck_moonlight_handoff_preflight_test PRIVATE nova_deck_core)
add_test(NAME nova_deck_moonlight_handoff_preflight_test COMMAND nova_deck_moonlight_handoff_preflight_test)

add_test(NAME nova_deck_moonlight_handoff_source_guard_test
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_moonlight_handoff_source_guard_test.py
)

add_test(NAME nova_deck_gamemode_capture_harness_test
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_gamemode_capture_test.py
)
Expand Down
113 changes: 104 additions & 9 deletions clients/deck/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ ApplicationWindow {
readonly property int sampleCardWidth: 392
readonly property int detailColumnWidth: 424
readonly property int hostCardHeight: 104
readonly property int detailPanelHeight: 184
readonly property int launchPreviewHeight: 258
readonly property int detailPanelHeight: 150
readonly property int launchPreviewHeight: 400
readonly property int hostTextWidth: hostColumnWidth - 40
readonly property int sampleTextWidth: sampleCardWidth - 48
readonly property int detailTextWidth: detailColumnWidth - 48
Expand All @@ -32,8 +32,13 @@ ApplicationWindow {
property string selectedLaunchPreviewText: novaSelectedLaunchPreviewText
property var launchPreviewCopyAction: novaLaunchPreviewCopyAction
property var launchIntentPreview: novaLaunchIntentPreview
property var moonlightHandoffPreflight: novaMoonlightHandoffPreflight
property string selectedLaunchPublicCopy: launchIntentPreview.publicCopy
property string selectedStreamLifecycleCopy: launchIntentPreview.streamLifecycleCopy
property string selectedMoonlightHandoffCopy: moonlightHandoffPreflight.publicPreviewCopy
property string selectedMoonlightHandoffArgvPreview: moonlightHandoffPreflight.argvPreview
property string selectedMoonlightHandoffFocusCopy: moonlightHandoffPreflight.focusFallbackCopy
property string selectedMoonlightHandoffConfidence: moonlightHandoffPreflight.focusConfidence

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

function moonlightHandoffRuntimeGatesClosed() {
return moonlightHandoffPreflight.safeToRender
&& !moonlightHandoffPreflight.executable
&& !moonlightHandoffPreflight.allowsNetwork
&& !moonlightHandoffPreflight.allowsProcessExecution
&& !moonlightHandoffPreflight.allowsMoonlight
&& !moonlightHandoffPreflight.allowsHostMutation
}

function refreshMoonlightHandoffPreflightBinding(hostName, gameTitle) {
moonlightHandoffPreflight = novaMoonlightHandoffPreflightBridge.resolve(
hostName,
gameTitle,
novaLibraryReadOnly,
novaLibraryGames.length > 0)
const canRenderMoonlightHandoff = moonlightHandoffRuntimeGatesClosed()
selectedMoonlightHandoffCopy = canRenderMoonlightHandoff
? moonlightHandoffPreflight.publicPreviewCopy
: "Moonlight handoff preview blocked until safe public copy is available. Nothing will launch yet."
selectedMoonlightHandoffArgvPreview = canRenderMoonlightHandoff
? moonlightHandoffPreflight.argvPreview
: "Typed argv plan unavailable until the preflight is safe to render."
selectedMoonlightHandoffFocusCopy = canRenderMoonlightHandoff
? moonlightHandoffPreflight.focusFallbackCopy
: "Return behavior withheld until the preflight is safe to render."
selectedMoonlightHandoffConfidence = canRenderMoonlightHandoff
? moonlightHandoffPreflight.focusConfidence
: "blocked_static"
}

function refreshLaunchPreviewBinding() {
const hostId = selectedHostForPreview && selectedHostForPreview.id
? selectedHostForPreview.id
Expand Down Expand Up @@ -72,6 +107,7 @@ ApplicationWindow {
+ "&state=noop-preview"
selectedLaunchPublicCopy = "Review " + gameTitle + " on " + hostName + " via " + steamCopy + ". Safe preview only; no game or stream starts."
selectedStreamLifecycleCopy = "Safe preview of " + gameTitle + " on " + hostName + "; stream remains not started."
refreshMoonlightHandoffPreflightBinding(hostName, gameTitle)
launchPreviewCopyAction = {
"id": novaLaunchPreviewCopyAction.id,
"label": novaLaunchPreviewCopyAction.label,
Expand Down Expand Up @@ -616,12 +652,63 @@ ApplicationWindow {
wrapMode: Text.WordWrap
}

Label {
Rectangle {
id: moonlightHandoffPanel
objectName: "moonlight-handoff-panel"
Layout.preferredWidth: detailTextWidth
text: "Exact preview details stay behind Copy preview details — copy locally to inspect the preview URI."
color: "#7C88B8"
font.pixelSize: 12
wrapMode: Text.WordWrap
Layout.preferredHeight: 118
radius: 14
color: "#101A30"
border.color: "#7C73FF"
border.width: 2

ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 3

Label {
text: "Moonlight handoff preview — Nothing will launch yet"
color: "#E9ECFF"
font.pixelSize: 14
font.bold: true
}

Label {
Layout.preferredWidth: detailTextWidth - 24
text: selectedMoonlightHandoffCopy
color: "#C9F0D4"
font.pixelSize: 11
wrapMode: Text.WordWrap
}

Label {
Layout.preferredWidth: detailTextWidth - 24
text: "Typed argv plan · redacted host selector · " + selectedMoonlightHandoffArgvPreview
color: "#B8C2F0"
font.pixelSize: 10
wrapMode: Text.WordWrap
}

Label {
Layout.preferredWidth: detailTextWidth - 24
text: moonlightHandoffRuntimeGatesClosed()
? "Runtime gates: network off · process off · Moonlight off · host mutation off"
: "Runtime gate failed — review blocked"
color: "#FFDDA8"
font.pixelSize: 10
font.bold: true
wrapMode: Text.WordWrap
}

Label {
Layout.preferredWidth: detailTextWidth - 24
text: "Focus return: unproven_static"
color: "#7C88B8"
font.pixelSize: 10
wrapMode: Text.WordWrap
}
}
}

Button {
Expand All @@ -642,12 +729,20 @@ ApplicationWindow {
onClicked: activateLaunchPreviewCopyFromController()
}

Label {
Layout.preferredWidth: detailTextWidth
text: "Exact preview details stay behind Copy preview details — copy locally to inspect the preview URI."
color: "#7C88B8"
font.pixelSize: 10
wrapMode: Text.WordWrap
}

Label {
id: copyStatusLabel
Layout.preferredWidth: detailTextWidth
text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify. A Copy preview saves this safe plan locally for inspection."
text: launchPreviewCopyAction.idleStatusLabel + " · A Copy preview saves this safe plan locally for inspection."
color: "#FFDDA8"
font.pixelSize: 14
font.pixelSize: 10
wrapMode: Text.WordWrap
}
}
Expand Down
119 changes: 119 additions & 0 deletions clients/deck/src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "deck_layout.h"
#include "deck_gamepad.h"
#include "polaris_game_fixture.h"
#include "stream/deck_moonlight_handoff_preflight.h"

#include <QClipboard>
#include <QSocketNotifier>
Expand Down Expand Up @@ -246,6 +247,115 @@ QVariantMap toPreviewCopyActionModel(const nova::deck::DeckLaunchPreviewCopyActi
model.insert("executable", copyAction.executable);
return model;
}

QString moonlightHandoffVerdictLabel(const nova::deck::stream::DeckMoonlightHandoffVerdict verdict) {
using nova::deck::stream::DeckMoonlightHandoffVerdict;
switch (verdict) {
case DeckMoonlightHandoffVerdict::ReadyForReview:
return QStringLiteral("ready_for_review");
case DeckMoonlightHandoffVerdict::BlockedStatic:
return QStringLiteral("blocked_static");
case DeckMoonlightHandoffVerdict::ForbiddenRuntimeBoundary:
return QStringLiteral("forbidden_runtime_boundary");
}
return QStringLiteral("unknown");
}

QString moonlightHandoffSurfaceLabel(const nova::deck::stream::DeckMoonlightHandoffSurface surface) {
using nova::deck::stream::DeckMoonlightHandoffSurface;
switch (surface) {
case DeckMoonlightHandoffSurface::MoonlightQtCli:
return QStringLiteral("moonlight_qt_cli");
case DeckMoonlightHandoffSurface::HostAppSnapshot:
return QStringLiteral("host_app_snapshot");
case DeckMoonlightHandoffSurface::DesktopEntry:
return QStringLiteral("desktop_entry");
case DeckMoonlightHandoffSurface::FlatpakIdentity:
return QStringLiteral("flatpak_identity");
case DeckMoonlightHandoffSurface::SteamShortcut:
return QStringLiteral("steam_shortcut");
case DeckMoonlightHandoffSurface::CustomUri:
return QStringLiteral("custom_uri");
case DeckMoonlightHandoffSurface::NovaOwnedCommonCFuture:
return QStringLiteral("nova_owned_common_c_future");
case DeckMoonlightHandoffSurface::Unsupported:
return QStringLiteral("unsupported");
}
return QStringLiteral("unknown");
}

QVariantList toStringListModel(const std::vector<std::string>& values) {
QVariantList model;
for (const auto& value : values) {
model.append(toQString(value));
}
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 + ")
+ toQString(tokens[3]);
}
return QStringLiteral("Typed argv plan unavailable until the preflight is ready for review.");
}

QVariantMap toMoonlightHandoffPreflightModel(
const nova::deck::stream::DeckMoonlightHandoffPreflightResult& result) {
QVariantMap model;
model.insert("verdict", moonlightHandoffVerdictLabel(result.verdict));
model.insert("candidateSurface", moonlightHandoffSurfaceLabel(result.candidatePlan.surface));
model.insert("publicPreviewCopy", toQString(result.publicPreviewCopy));
model.insert("publicSummary", toQString(result.candidatePlan.publicSummary));
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("sourceSurface", toQString(result.focusReturnPlan.sourceSurface));
model.insert("intendedReturnTarget", toQString(result.focusReturnPlan.intendedReturnTarget));
model.insert("focusFallbackCopy", toQString(result.focusReturnPlan.fallbackCopy));
model.insert("focusConfidence", toQString(result.focusReturnPlan.confidence));
model.insert("safeToRender", result.safeToRender);
model.insert("executable", result.executable);
model.insert("allowsNetwork", result.allowsNetwork);
model.insert("allowsProcessExecution", result.allowsProcessExecution);
model.insert("allowsMoonlight", result.allowsMoonlight);
model.insert("allowsHostMutation", result.allowsHostMutation);
return model;
}

nova::deck::stream::DeckMoonlightHandoffPreflightResult resolveMoonlightHandoffPreflightFor(
const QString& hostDisplayNamePublic,
const QString& gameTitlePublic,
const bool hasSafeSnapshot,
const bool appPresentInSnapshot) {
return nova::deck::stream::resolveDeckMoonlightHandoffPreflight(
nova::deck::stream::DeckMoonlightHandoffPreflightRequest{
.hostDisplayNamePublic = hostDisplayNamePublic.toStdString(),
.gameTitlePublic = gameTitlePublic.toStdString(),
.privateHostSelectorRedactedForDebug = "redacted-host-selector",
.requestedSurface = nova::deck::stream::DeckMoonlightHandoffSurface::MoonlightQtCli,
.hasSafeSnapshot = hasSafeSnapshot,
.appPresentInSnapshot = appPresentInSnapshot,
});
}

class QtMoonlightHandoffPreflightBridge final : public QObject {
Q_OBJECT
public:
using QObject::QObject;

Q_INVOKABLE QVariantMap resolve(
const QString& hostDisplayNamePublic,
const QString& gameTitlePublic,
const bool hasSafeSnapshot,
const bool appPresentInSnapshot) const {
return toMoonlightHandoffPreflightModel(resolveMoonlightHandoffPreflightFor(
hostDisplayNamePublic,
gameTitlePublic,
hasSafeSnapshot,
appPresentInSnapshot));
}
};
} // namespace

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

QtLocalClipboardBridge localClipboard;
QtMoonlightHandoffPreflightBridge moonlightHandoffBridge;
QtDeckGamepadBridge gamepadBridge;

const auto initialMoonlightHandoffPreflight = resolveMoonlightHandoffPreflightFor(
toQString(selectedBinding.selectedHostName),
toQString(selectedBinding.selectedGameTitle),
sampleLibrary.readOnly,
!sampleLibrary.games.empty());

QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("novaDeckShellName", toQString(profile.shellName));
engine.rootContext()->setContextProperty("novaDeckWidth", profile.width);
Expand All @@ -286,6 +403,8 @@ int main(int argc, char *argv[]) {
engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary));
engine.rootContext()->setContextProperty("novaLaunchIntentPreview", toLaunchIntentPreviewModel(launchIntent, streamIntent));
engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction));
engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflight", toMoonlightHandoffPreflightModel(initialMoonlightHandoffPreflight));
engine.rootContext()->setContextProperty("novaMoonlightHandoffPreflightBridge", &moonlightHandoffBridge);
engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard);
engine.rootContext()->setContextProperty("novaGamepad", &gamepadBridge);
engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(libraryHosts)));
Expand Down
Loading
Loading