diff --git a/clients/deck/CMakeLists.txt b/clients/deck/CMakeLists.txt index 7de4a814..6ca702a9 100644 --- a/clients/deck/CMakeLists.txt +++ b/clients/deck/CMakeLists.txt @@ -57,6 +57,7 @@ target_compile_definitions(nova_deck_core include(CTest) if(BUILD_TESTING) + find_package(Python3 REQUIRED COMPONENTS Interpreter) add_executable(nova_deck_layout_test tests/deck_layout_test.cpp ) @@ -77,6 +78,10 @@ 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_test(NAME nova_deck_gamemode_capture_harness_test + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/deck_gamemode_capture_test.py + ) endif() option(NOVA_DECK_BUILD_QT_SHELL "Build the experimental Qt/QML Steam Deck shell" ON) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index a382e1ff..7145aec1 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -19,11 +19,13 @@ ApplicationWindow { readonly property int sampleCardWidth: 392 readonly property int detailColumnWidth: 424 readonly property int hostCardHeight: 104 - readonly property int detailPanelHeight: 196 - readonly property int launchPreviewHeight: 236 + readonly property int detailPanelHeight: 184 + readonly property int launchPreviewHeight: 258 readonly property int hostTextWidth: hostColumnWidth - 40 readonly property int sampleTextWidth: sampleCardWidth - 48 readonly property int detailTextWidth: detailColumnWidth - 48 + readonly property color focusRingColor: "#8AFFC1" + readonly property color focusGlowColor: "#243D57" property int previewCopyActivationCount: 0 property var selectedHostForPreview: novaSelectedHostDetail property var selectedGameForPreview: novaSelectedGameCard @@ -34,7 +36,7 @@ ApplicationWindow { property string selectedStreamLifecycleCopy: launchIntentPreview.streamLifecycleCopy function selectedHostSubtitle() { - return "Read-only host detail only — not discovered from the network." + return "Selected host only — not discovered from the network." } function previewComponent(value) { @@ -68,8 +70,8 @@ ApplicationWindow { + "&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." + 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." launchPreviewCopyAction = { "id": novaLaunchPreviewCopyAction.id, "label": novaLaunchPreviewCopyAction.label, @@ -167,6 +169,14 @@ ApplicationWindow { } } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.BlankCursor + z: 1000 + } + FocusScope { id: libraryFocusScope anchors.fill: parent @@ -193,7 +203,7 @@ ApplicationWindow { } Label { - text: "Controller-first Steam Deck shell scaffold" + text: "Your couch-ready Nova command center" color: "#A8B0D8" font.pixelSize: 24 } @@ -227,8 +237,8 @@ ApplicationWindow { Layout.preferredWidth: hostColumnWidth Layout.preferredHeight: visible ? 120 : 0 radius: 20 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#39466F" + color: activeFocus ? focusGlowColor : "#151D39" + border.color: activeFocus ? focusRingColor : "#39466F" border.width: activeFocus ? 5 : 2 focus: visible activeFocusOnTab: visible @@ -250,7 +260,7 @@ ApplicationWindow { Label { text: "Empty host state is focusable and deterministic." color: "#A8B0D8" - font.pixelSize: 12 + font.pixelSize: 14 } } } @@ -268,7 +278,7 @@ ApplicationWindow { Layout.preferredHeight: hostCardHeight radius: 20 color: selectedHostForPreview.id === modelData.id ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : selectedHostForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" + border.color: activeFocus ? focusRingColor : selectedHostForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" border.width: activeFocus ? 5 : selectedHostForPreview.id === modelData.id ? 4 : 3 focus: modelData.initialFocus activeFocusOnTab: true @@ -316,7 +326,7 @@ ApplicationWindow { visible: selectedHostForPreview.id === modelData.id text: "Selected host" color: "#8AFFC1" - font.pixelSize: 12 + font.pixelSize: 14 font.bold: true } } @@ -331,7 +341,7 @@ ApplicationWindow { spacing: deckPanelSpacing Label { - text: "Read-only Polaris library" + text: "Polaris library preview" color: "#E9ECFF" font.pixelSize: 24 font.bold: true @@ -339,7 +349,7 @@ ApplicationWindow { Label { Layout.preferredWidth: sampleTextWidth - text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only · Read-only snapshot loaded" : " · Snapshot unavailable in this preview shell — no backend request will be made") + text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only · Preview snapshot ready" : " · Snapshot unavailable in this preview shell — no backend request will be made") color: "#A8B0D8" font.pixelSize: 13 wrapMode: Text.WordWrap @@ -352,8 +362,8 @@ ApplicationWindow { Layout.preferredWidth: sampleCardWidth Layout.preferredHeight: visible ? 116 : 0 radius: 18 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#39466F" + color: activeFocus ? focusGlowColor : "#151D39" + border.color: activeFocus ? focusRingColor : "#39466F" border.width: activeFocus ? 5 : 2 focus: visible activeFocusOnTab: visible @@ -378,7 +388,7 @@ ApplicationWindow { Layout.preferredWidth: sampleTextWidth text: "Snapshot unavailable in this preview shell — no backend request will be made." color: "#A8B0D8" - font.pixelSize: 12 + font.pixelSize: 14 wrapMode: Text.WordWrap } } @@ -397,7 +407,7 @@ ApplicationWindow { Layout.preferredHeight: 88 radius: 18 color: selectedGameForPreview.id === modelData.id ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : selectedGameForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" + border.color: activeFocus ? focusRingColor : selectedGameForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" border.width: activeFocus ? 5 : selectedGameForPreview.id === modelData.id ? 4 : 2 focus: modelData.initialFocus activeFocusOnTab: true @@ -445,14 +455,14 @@ ApplicationWindow { Label { text: modelData.launchModeLabel color: "#A8B0D8" - font.pixelSize: 12 + font.pixelSize: 14 } Label { visible: selectedGameForPreview.id === modelData.id text: "Selected game" color: "#8AFFC1" - font.pixelSize: 11 + font.pixelSize: 13 font.bold: true } } @@ -470,8 +480,8 @@ ApplicationWindow { Layout.preferredWidth: detailColumnWidth Layout.preferredHeight: detailPanelHeight radius: 22 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#39466F" + color: activeFocus ? focusGlowColor : "#151D39" + border.color: activeFocus ? focusRingColor : "#39466F" border.width: activeFocus ? 5 : 2 focus: true activeFocusOnTab: true @@ -488,7 +498,7 @@ ApplicationWindow { spacing: 8 Label { - text: "Read-only host detail" + text: "Selected host" color: "#7C88B8" font.pixelSize: 16 } @@ -530,8 +540,8 @@ ApplicationWindow { Layout.preferredWidth: detailColumnWidth Layout.preferredHeight: launchPreviewHeight radius: 20 - color: activeFocus ? "#2A2948" : "#181D34" - border.color: activeFocus ? "#B8C2FF" : "#39466F" + color: activeFocus ? focusGlowColor : "#181D34" + border.color: activeFocus ? focusRingColor : "#39466F" border.width: activeFocus ? 5 : 2 opacity: novaHostLaunchCta.enabled ? 1.0 : 0.72 focus: false @@ -561,7 +571,7 @@ ApplicationWindow { Layout.preferredWidth: detailTextWidth text: novaHostLaunchCta.helpText color: "#B8C2F0" - font.pixelSize: 12 + font.pixelSize: 14 wrapMode: Text.WordWrap } @@ -569,16 +579,16 @@ ApplicationWindow { Layout.preferredWidth: detailTextWidth text: novaHostLaunchCta.previewStateLabel color: "#FFDDA8" - font.pixelSize: 12 + font.pixelSize: 14 font.bold: true wrapMode: Text.WordWrap } Label { Layout.preferredWidth: detailTextWidth - text: "Typed launch boundary: " + novaLaunchIntentBoundary.label + " · network/process/Moonlight blocked" + text: "Safe preview: no game, stream, or network launch starts from this screen." color: "#FFDDA8" - font.pixelSize: 11 + font.pixelSize: 13 wrapMode: Text.WordWrap } @@ -586,7 +596,7 @@ ApplicationWindow { Layout.preferredWidth: detailTextWidth text: novaLaunchIntentBoundary.reason color: "#A8B0D8" - font.pixelSize: 10 + font.pixelSize: 13 wrapMode: Text.WordWrap } @@ -594,7 +604,7 @@ ApplicationWindow { Layout.preferredWidth: detailTextWidth text: selectedLaunchPublicCopy color: "#C9F0D4" - font.pixelSize: 11 + font.pixelSize: 13 wrapMode: Text.WordWrap } @@ -602,23 +612,22 @@ ApplicationWindow { Layout.preferredWidth: detailTextWidth text: selectedStreamLifecycleCopy color: "#A8B0D8" - font.pixelSize: 10 + font.pixelSize: 13 wrapMode: Text.WordWrap } Label { Layout.preferredWidth: detailTextWidth - text: selectedLaunchPreviewText - color: "#A8B0D8" + text: "Exact preview details stay behind Copy preview details — copy locally to inspect the preview URI." + color: "#7C88B8" font.pixelSize: 12 - font.family: "monospace" - wrapMode: Text.WrapAnywhere + wrapMode: Text.WordWrap } Button { id: copyPreviewButton objectName: launchPreviewCopyAction.id - text: activeFocus ? "A · " + launchPreviewCopyAction.label : launchPreviewCopyAction.label + text: activeFocus ? "D-pad focus · A · " + launchPreviewCopyAction.label : launchPreviewCopyAction.label enabled: launchPreviewCopyAction.enabled focusPolicy: Qt.StrongFocus activeFocusOnTab: true @@ -636,9 +645,9 @@ ApplicationWindow { Label { id: copyStatusLabel Layout.preferredWidth: detailTextWidth - text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify. A copies the preview URI locally only." + text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify. A Copy preview saves this safe plan locally for inspection." color: "#FFDDA8" - font.pixelSize: 12 + font.pixelSize: 14 wrapMode: Text.WordWrap } } @@ -650,7 +659,7 @@ ApplicationWindow { Label { text: novaDeckFullscreenPreferred - ? "Deck default: 1280×800 · fullscreen-first · controller-first" + ? "D-pad Navigate · A Copy preview · 1280×800 Deck-first" : "Deck default: 1280×800 · windowed test mode" color: "#7C88B8" font.pixelSize: 18 diff --git a/clients/deck/scripts/deck_gamemode_capture.py b/clients/deck/scripts/deck_gamemode_capture.py new file mode 100644 index 00000000..80762e8f --- /dev/null +++ b/clients/deck/scripts/deck_gamemode_capture.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""Local-only Nova Deck Game Mode capture harness. + +This script is intentionally conservative: it enumerates Nova-ish windows, +selects only a 1280x800-ish Nova Deck product window, targets input at that +window id, records proof artifacts, and fails closed on helper/ambiguous +windows. It does not launch Moonlight, Sunshine, games, network discovery, +Polaris endpoints, or public publishing actions. +""" +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Iterable, Sequence + + +EXPECTED_WIDTH = 1280 +EXPECTED_HEIGHT = 800 +DEFAULT_TOLERANCE = 4 +DEFAULT_WINDOW_NAME = "Nova" +TARGET_TITLE_RE = re.compile(r"^Nova Deck$", re.IGNORECASE) +HELPER_RE = re.compile(r"helper|splash|popup|tooltip|steamwebhelper|gamescope|overlay", re.IGNORECASE) +SENSITIVE_ENV_KEY_RE = re.compile( + r"token|secret|password|passwd|api_key|private_key|access_key|credential|cookie|cert", + re.IGNORECASE, +) + + +class HarnessError(RuntimeError): + pass + + +class SelectionError(HarnessError): + pass + + +@dataclasses.dataclass(frozen=True) +class Geometry: + x: int + y: int + width: int + height: int + + def is_expected_deck_size(self, tolerance: int = DEFAULT_TOLERANCE) -> bool: + return abs(self.width - EXPECTED_WIDTH) <= tolerance and abs(self.height - EXPECTED_HEIGHT) <= tolerance + + def is_helper_sized(self) -> bool: + return self.width <= 32 or self.height <= 32 + + +@dataclasses.dataclass(frozen=True) +class WindowCandidate: + window_id: str + name: str = "" + window_class: str = "" + geometry: Geometry = Geometry(0, 0, 0, 0) + pid: int | None = None + mapped: bool | None = None + + def as_dict(self) -> dict: + return { + "window_id": self.window_id, + "name": self.name, + "window_class": self.window_class, + "geometry": dataclasses.asdict(self.geometry), + "pid": self.pid, + "mapped": self.mapped, + "plausible": is_plausible_nova_deck_window(self), + } + + +def run(argv: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(list(argv), check=check, text=text, capture_output=True) + + +def parse_xdotool_geometry(output: str) -> Geometry: + shell_values = dict(re.findall(r"^([A-Z]+)=(-?\d+)$", output, flags=re.MULTILINE)) + if "WIDTH" in shell_values and "HEIGHT" in shell_values: + return Geometry( + x=int(shell_values.get("X", 0)), + y=int(shell_values.get("Y", 0)), + width=int(shell_values["WIDTH"]), + height=int(shell_values["HEIGHT"]), + ) + position = re.search(r"Position:\s*(-?\d+),(-?\d+)", output) + geometry = re.search(r"Geometry:\s*(\d+)x(\d+)", output) + if not geometry: + raise HarnessError(f"could not parse xdotool geometry: {output!r}") + x = int(position.group(1)) if position else 0 + y = int(position.group(2)) if position else 0 + return Geometry(x=x, y=y, width=int(geometry.group(1)), height=int(geometry.group(2))) + + +def _optional_xdotool(args: Sequence[str]) -> str: + try: + return run(["xdotool", *args]).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "" + + +def enumerate_xdotool_candidates(name: str = DEFAULT_WINDOW_NAME) -> list[WindowCandidate]: + if shutil.which("xdotool") is None: + raise HarnessError("xdotool not found; cannot enumerate Deck windows") + search = run(["xdotool", "search", "--name", name], check=False) + window_ids = [line.strip() for line in search.stdout.splitlines() if line.strip()] + candidates: list[WindowCandidate] = [] + for window_id in window_ids: + raw_geometry = _optional_xdotool(["getwindowgeometry", "--shell", window_id]) + try: + geometry = parse_xdotool_geometry(raw_geometry) + except HarnessError: + geometry = Geometry(0, 0, 0, 0) + pid_raw = _optional_xdotool(["getwindowpid", window_id]) + mapped_raw = _optional_xdotool(["getwindowmapstate", window_id]) + candidates.append( + WindowCandidate( + window_id=window_id, + name=_optional_xdotool(["getwindowname", window_id]), + window_class=_optional_xdotool(["getwindowclassname", window_id]), + geometry=geometry, + pid=int(pid_raw) if pid_raw.isdigit() else None, + mapped=(mapped_raw.lower() == "ismapped") if mapped_raw else None, + ) + ) + return candidates + + +def is_plausible_nova_deck_window(candidate: WindowCandidate, *, tolerance: int = DEFAULT_TOLERANCE) -> bool: + if not candidate.geometry.is_expected_deck_size(tolerance): + return False + if candidate.geometry.is_helper_sized(): + return False + if candidate.mapped is False: + return False + if HELPER_RE.search(candidate.name) or HELPER_RE.search(candidate.window_class): + return False + title_ok = bool(TARGET_TITLE_RE.search(candidate.name)) or candidate.window_class.lower() in {"nova-deck", "nova"} + return title_ok + + +def select_nova_deck_window( + candidates: Sequence[WindowCandidate], + *, + expected_pid: int | None = None, + tolerance: int = DEFAULT_TOLERANCE, +) -> WindowCandidate: + plausible = [c for c in candidates if is_plausible_nova_deck_window(c, tolerance=tolerance)] + if expected_pid is not None: + pid_matches = [c for c in plausible if c.pid == expected_pid] + if len(pid_matches) == 1: + return pid_matches[0] + if len(pid_matches) > 1: + ids = ", ".join(c.window_id for c in pid_matches) + raise SelectionError(f"ambiguous Nova Deck windows for pid {expected_pid}: {ids}") + if not plausible: + details = json.dumps([c.as_dict() for c in candidates], indent=2) + raise SelectionError(f"no 1280x800 Nova Deck window found; candidates={details}") + if len(plausible) > 1: + ids = ", ".join(c.window_id for c in plausible) + raise SelectionError(f"ambiguous Nova Deck windows: {ids}") + return plausible[0] + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_json(path: Path, data: object) -> None: + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def redact_sensitive_output(output: str) -> str: + """Redact likely secret-bearing environment-style output before artifact writes.""" + redacted_lines = [] + for line in output.splitlines(): + key, separator, _value = line.partition("=") + if separator and SENSITIVE_ENV_KEY_RE.search(key): + redacted_lines.append(f"{key}=[REDACTED]") + else: + redacted_lines.append(line) + suffix = "\n" if output.endswith("\n") else "" + return "\n".join(redacted_lines) + suffix + + +def build_ffmpeg_capture_command(window_id: str, path: Path, *, display: str) -> list[str]: + return [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "x11grab", "-window_id", str(window_id), "-i", display, + "-frames:v", "1", "-update", "1", str(path), + ] + + +def capture_window(window_id: str, path: Path, *, display: str | None, timeout: int = 5) -> None: + if shutil.which("ffmpeg") is None: + raise HarnessError("ffmpeg not found; cannot capture selected window") + env = os.environ.copy() + if display: + env["DISPLAY"] = display + capture_display = display or env.get("DISPLAY", ":0") + cmd = build_ffmpeg_capture_command(window_id, path, display=capture_display) + subprocess.run(cmd, check=True, env=env, timeout=timeout) + if not path.exists() or path.stat().st_size == 0: + raise HarnessError(f"capture failed or empty: {path}") + + +def send_window_key(window_id: str, key: str) -> None: + if shutil.which("xdotool") is None: + raise HarnessError("xdotool not found; cannot send window-targeted input") + run(["xdotool", "key", "--window", str(window_id), key]) + + +def make_image_diff(before: Path, after: Path, diff: Path) -> dict: + before_hash = sha256_file(before) + after_hash = sha256_file(after) + changed = before_hash != after_hash + compare = shutil.which("magick") or shutil.which("compare") + compare_exit = None + if compare: + if Path(compare).name == "magick": + cmd = [compare, "compare", "-metric", "AE", str(before), str(after), str(diff)] + else: + cmd = [compare, "-metric", "AE", str(before), str(after), str(diff)] + proc = subprocess.run(cmd, text=True, capture_output=True) + compare_exit = proc.returncode + if not diff.exists() and changed: + diff.write_text(f"ImageMagick compare did not create a diff; stderr={proc.stderr}\n") + else: + diff.write_text("ImageMagick compare unavailable; SHA-256 changed=%s\nbefore=%s\nafter=%s\n" % (changed, before_hash, after_hash)) + return {"changed": changed, "before_sha256": before_hash, "after_sha256": after_hash, "compare_exit": compare_exit} + + +def make_contact_sheet(images: Sequence[Path], output: Path) -> None: + magick = shutil.which("magick") + montage = shutil.which("montage") + if magick: + subprocess.run([magick, "montage", *map(str, images), "-tile", f"{len(images)}x1", "-geometry", "+8+8", str(output)], check=True) + elif montage: + subprocess.run([montage, *map(str, images), "-tile", f"{len(images)}x1", "-geometry", "+8+8", str(output)], check=True) + else: + output.write_text("Contact sheet unavailable; install ImageMagick. Images:\n" + "\n".join(str(p) for p in images) + "\n") + + +def record_session_proof(root: Path, *, binary: Path | None = None) -> dict: + proof: dict[str, object] = {} + commands = { + "loginctl_sessions": ["loginctl", "list-sessions", "--no-legend"], + "user_environment": ["systemctl", "--user", "show-environment"], + "gamescope_processes": ["pgrep", "-a", "gamescope"], + } + for key, cmd in commands.items(): + try: + proc = subprocess.run(cmd, text=True, capture_output=True, timeout=5) + proof[key] = { + "exit": proc.returncode, + "stdout": redact_sensitive_output(proc.stdout[-4000:]), + "stderr": redact_sensitive_output(proc.stderr[-2000:]), + } + except Exception as exc: + proof[key] = {"error": str(exc)} + if binary: + proof["binary"] = str(binary) + if binary.exists(): + proof["binary_sha256"] = sha256_file(binary) + try: + proc = subprocess.run(["ldd", str(binary)], text=True, capture_output=True, timeout=10) + proof["ldd"] = { + "exit": proc.returncode, + "stdout": redact_sensitive_output(proc.stdout[-8000:]), + "stderr": redact_sensitive_output(proc.stderr[-4000:]), + } + except Exception as exc: + proof["ldd"] = {"error": str(exc)} + try: + proc = subprocess.run([str(binary), "--smoke-exit"], text=True, capture_output=True, timeout=15) + proof["smoke_exit"] = { + "exit": proc.returncode, + "stdout": redact_sensitive_output(proc.stdout[-4000:]), + "stderr": redact_sensitive_output(proc.stderr[-4000:]), + } + except Exception as exc: + proof["smoke_exit"] = {"error": str(exc)} + write_json(root / "session_proof.json", proof) + return proof + + +def cleanup_recorded_pid(pid_file: Path | None, root: Path) -> dict: + result = {"pid_file": str(pid_file) if pid_file else None, "attempted": False, "remaining": None} + if not pid_file or not pid_file.exists(): + write_json(root / "cleanup.json", result) + return result + pid_text = pid_file.read_text().strip() + if not pid_text.isdigit(): + raise HarnessError(f"pid file does not contain a numeric pid: {pid_file}") + pid = int(pid_text) + result.update({"attempted": True, "pid": pid}) + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + time.sleep(1.0) + try: + os.kill(pid, 0) + result["remaining"] = True + except ProcessLookupError: + result["remaining"] = False + write_json(root / "cleanup.json", result) + if result["remaining"]: + raise HarnessError(f"staged process still remains after cleanup: {pid}") + return result + + +def run_live(args: argparse.Namespace) -> int: + root = Path(args.artifact_root).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + candidates = enumerate_xdotool_candidates(args.window_name) + write_json(root / "window_candidates.json", [c.as_dict() for c in candidates]) + selected = select_nova_deck_window(candidates, expected_pid=args.expected_pid, tolerance=args.tolerance) + write_json(root / "selected_window.json", selected.as_dict()) + if args.dry_run: + print(f"selected {selected.window_id} {selected.geometry.width}x{selected.geometry.height}") + return 0 + before = root / "before.png" + after_tab = root / "after_tab.png" + after_down = root / "after_down.png" + diff = root / "focus_diff.png" + contact = root / "focus_contact.png" + capture_window(selected.window_id, before, display=args.display) + send_window_key(selected.window_id, "Tab") + time.sleep(args.settle_seconds) + capture_window(selected.window_id, after_tab, display=args.display) + send_window_key(selected.window_id, "Down") + time.sleep(args.settle_seconds) + capture_window(selected.window_id, after_down, display=args.display) + diff_result = make_image_diff(before, after_tab, diff) + make_contact_sheet([before, after_tab, after_down], contact) + record_session_proof(root, binary=Path(args.binary).expanduser().resolve() if args.binary else None) + cleanup = cleanup_recorded_pid(Path(args.pid_file).expanduser() if args.pid_file else None, root) + summary = { + "artifact_root": str(root), + "selected_window": selected.as_dict(), + "captures": [str(before), str(after_tab), str(after_down)], + "focus_diff": str(diff), + "contact_sheet": str(contact), + "diff_result": diff_result, + "cleanup": cleanup, + "safety": "local-only UI capture; no Moonlight/Sunshine/game/backend/discovery/HostStore/publication action", + } + write_json(root / "harness_summary.json", summary) + if not diff_result["changed"]: + raise HarnessError("window-targeted input produced identical before/after screenshots") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +def run_self_test() -> int: + candidates = [ + WindowCandidate("0x02", "Nova Deck", geometry=Geometry(0, 0, 1280, 800), pid=4242), + WindowCandidate("0x01", "Nova Deck helper", geometry=Geometry(0, 0, 1, 1), pid=4100), + ] + selected = select_nova_deck_window(candidates, expected_pid=4242) + assert selected.window_id == "0x02" + capture_cmd = build_ffmpeg_capture_command("0x02", Path("shot.png"), display=":1") + assert capture_cmd[capture_cmd.index("-f") + 1] == "x11grab" + assert capture_cmd[capture_cmd.index("-i") + 1] == ":1" + assert capture_cmd.index("x11grab") < capture_cmd.index("-i") < capture_cmd.index("-update") + try: + select_nova_deck_window([ + WindowCandidate("0x02", "Nova Deck", geometry=Geometry(0, 0, 1280, 800), pid=1), + WindowCandidate("0x03", "Nova Deck", geometry=Geometry(0, 0, 1280, 800), pid=2), + ]) + except SelectionError as exc: + assert "ambiguous" in str(exc) + else: + raise AssertionError("ambiguous valid windows did not fail closed") + print("self-test PASS: rejects helpers, selects 1280x800 target, fails closed on ambiguity") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Nova Deck Game Mode capture harness") + parser.add_argument("--artifact-root", default="nova-deck-gamemode-capture-artifacts") + parser.add_argument("--window-name", default=DEFAULT_WINDOW_NAME) + parser.add_argument("--expected-pid", type=int) + parser.add_argument("--pid-file") + parser.add_argument("--binary") + parser.add_argument("--display", default=os.environ.get("DISPLAY")) + parser.add_argument("--tolerance", type=int, default=DEFAULT_TOLERANCE) + parser.add_argument("--settle-seconds", type=float, default=0.35) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--self-test", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.self_test: + return run_self_test() + return run_live(args) + except HarnessError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index cbad4ca4..9bbea8d6 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -10,9 +10,9 @@ namespace { constexpr std::string_view kPreviewStateLabel = "Preview only — not executable"; constexpr std::string_view kPreviewBoundaryId = "deck-launch-preview-only"; -constexpr std::string_view kPreviewBoundaryLabel = "Preview-only typed intent boundary"; -constexpr std::string_view kPreviewBoundaryReason = "Deck shell may build copyable preview text, but launch execution is blocked."; -constexpr std::string_view kCopyIdleStatusLabel = "A copies the preview URI locally only — no launch, stream, backend, or Moonlight."; +constexpr std::string_view kPreviewBoundaryLabel = "Safe preview: no game, stream, or network launch starts from this screen."; +constexpr std::string_view kPreviewBoundaryReason = "Nova Deck shows a copyable preview plan only; games, streams, and network launches stay off."; +constexpr std::string_view kCopyIdleStatusLabel = "A Copy preview saves this safe plan locally for inspection. No game, stream, or network launch starts."; constexpr std::string_view kCopySuccessToast = "Preview text copied for inspection only — still not executable."; constexpr std::string_view kCopyInertToast = "No preview text to copy — preview-only action stayed inert."; @@ -473,7 +473,7 @@ DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const Polaris .localPrivateArtRedacted = true, }, .safety = DeckPreviewSafetyBooleans{}, - .publicPreviewCopy = "Preview " + gameTitle + " on " + hostName + " via " + launchModeCopyFor(mode) + "; no launch will run.", + .publicPreviewCopy = "Review " + gameTitle + " on " + hostName + " via " + launchModeCopyFor(mode) + ". Safe preview only; no game or stream starts.", .inertPreviewUri = uri, }; } @@ -490,7 +490,7 @@ DeckStreamIntent resolveStreamIntent(const DeckLaunchIntent& intent) { .recovery = DeckStreamRecovery::UserReviewRequired, .privacy = intent.privacy, .safety = intent.safety, - .publicCopy = "Preview stream for " + intent.gameTitle + " on " + intent.targetHostName + " remains noop_preview/not_started.", + .publicCopy = "Safe preview of " + intent.gameTitle + " on " + intent.targetHostName + "; stream remains not started.", }; } @@ -554,7 +554,7 @@ DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& const bool hasPreviewText = !preview.text.empty(); return DeckLaunchPreviewCopyAction{ .id = "host-detail-copy-preview", - .label = "Copy preview URI (no launch)", + .label = "Copy preview details", .previewText = preview.text, .idleStatusLabel = hasPreviewText ? std::string(kCopyIdleStatusLabel) : std::string(kCopyInertToast), .successToast = std::string(kCopySuccessToast), @@ -607,8 +607,8 @@ DeckLaunchCta inertLaunchCtaFor(const DeckHostDetail& detail, const PolarisGameF (void)preview; return DeckLaunchCta{ .id = "host-detail-launch-cta", - .label = "Launch preview only", - .helpText = "Display-only preview — not wired to launch, Moonlight, or a network backend.", + .label = "Safe launch preview", + .helpText = "Safe preview: no game, stream, or network launch starts from this screen.", .previewStateLabel = preview.stateLabel, .previewText = preview.text, .enabled = false, diff --git a/clients/deck/tests/deck_gamemode_capture_test.py b/clients/deck/tests/deck_gamemode_capture_test.py new file mode 100644 index 00000000..365c640d --- /dev/null +++ b/clients/deck/tests/deck_gamemode_capture_test.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +import pathlib +import sys +import tempfile +import unittest +from unittest import mock +sys.dont_write_bytecode = True + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import deck_gamemode_capture as harness + + +class DeckGameModeCaptureHarnessTest(unittest.TestCase): + def test_rejects_helpers_and_selects_1280x800_window(self): + candidates = [ + harness.WindowCandidate(window_id="0x01", name="Nova Deck helper", geometry=harness.Geometry(0, 0, 1, 1), pid=4100), + harness.WindowCandidate(window_id="0x02", name="Nova Deck", geometry=harness.Geometry(0, 0, 1280, 800), pid=4242), + ] + selected = harness.select_nova_deck_window(candidates, expected_pid=4242) + self.assertEqual(selected.window_id, "0x02") + + def test_fails_closed_when_two_plausible_windows_remain(self): + candidates = [ + harness.WindowCandidate(window_id="0x02", name="Nova Deck", geometry=harness.Geometry(0, 0, 1280, 800), pid=4242), + harness.WindowCandidate(window_id="0x03", name="Nova Deck", geometry=harness.Geometry(0, 0, 1280, 800), pid=4243), + ] + with self.assertRaisesRegex(harness.SelectionError, "ambiguous"): + harness.select_nova_deck_window(candidates) + + def test_old_tail_selector_fixture_would_choose_wrong_window(self): + candidates = [ + harness.WindowCandidate(window_id="0x02", name="Nova Deck", geometry=harness.Geometry(0, 0, 1280, 800), pid=4242), + harness.WindowCandidate(window_id="0x01", name="Nova Deck helper", geometry=harness.Geometry(0, 0, 1, 1), pid=4100), + ] + self.assertEqual(candidates[-1].geometry.width, 1) + self.assertEqual(harness.select_nova_deck_window(candidates, expected_pid=4242).window_id, "0x02") + + def test_parses_xdotool_shell_geometry(self): + geometry = harness.parse_xdotool_geometry("WINDOW=123\nX=12\nY=34\nWIDTH=1280\nHEIGHT=800\nSCREEN=0\n") + self.assertEqual(geometry, harness.Geometry(12, 34, 1280, 800)) + + def test_capture_uses_x11grab_as_input_before_png_output(self): + captured = {} + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp) / "shot.png" + + def fake_run(cmd, **kwargs): + captured["cmd"] = list(cmd) + out.write_bytes(b"png") + return harness.subprocess.CompletedProcess(cmd, 0) + + with ( + mock.patch.object(harness.shutil, "which", return_value="/usr/bin/ffmpeg"), + mock.patch.object(harness.subprocess, "run", side_effect=fake_run), + ): + harness.capture_window("0x02", out, display=":1") + + cmd = captured["cmd"] + self.assertEqual(cmd[cmd.index("-f") + 1], "x11grab") + self.assertIn("-i", cmd) + self.assertEqual(cmd[cmd.index("-i") + 1], ":1") + self.assertIn("-update", cmd) + self.assertLess(cmd.index("x11grab"), cmd.index("-i")) + self.assertLess(cmd.index("-i"), cmd.index("-update")) + self.assertEqual(cmd[-1], str(out)) + + def test_redacts_sensitive_session_environment_output(self): + raw = "DISPLAY=:1\nAPI_TOKEN=supersecretvalue\nHERMES_PASSWORD=badsecret\nNORMAL=value\n" + redacted = harness.redact_sensitive_output(raw) + self.assertIn("DISPLAY=:1", redacted) + self.assertIn("NORMAL=value", redacted) + self.assertIn("API_TOKEN=[REDACTED]", redacted) + self.assertIn("HERMES_PASSWORD=[REDACTED]", redacted) + self.assertNotIn("supersecretvalue", redacted) + self.assertNotIn("badsecret", redacted) + + +if __name__ == "__main__": + unittest.main() diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index a2c37b5a..86d861c7 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -67,6 +67,7 @@ int main() { assert(mainQml.find("anchors.margins: 56") == std::string::npos); assert(mainQml.find("Layout.preferredWidth: 480") == std::string::npos); assert(mainQml.find("Layout.preferredWidth: 410") == std::string::npos); + assert(mainQml.find("Controller-first Steam Deck shell scaffold") == std::string::npos); assert(mainQml.find("property int previewCopyActivationCount: 0") != std::string::npos); assert(mainQml.find("previewCopyActivationCount += 1") != std::string::npos); assert(mainQml.find("A pressed #") != std::string::npos); @@ -75,10 +76,10 @@ int main() { assert(mainQml.find("novaLibraryGames") != std::string::npos); assert(mainQml.find("novaLibraryHosts") != std::string::npos); assert(mainQml.find("libraryGameRepeater") != std::string::npos); - assert(mainQml.find("Read-only Polaris library") != std::string::npos); + assert(mainQml.find("Polaris library preview") != std::string::npos); assert(mainQml.find("novaLaunchIntentBoundary") != std::string::npos); - assert(mainQml.find("Typed launch boundary") != std::string::npos); - assert(mainQml.find("network/process/Moonlight blocked") != std::string::npos); + assert(mainQml.find("Safe preview: no game, stream, or network launch starts from this screen.") != std::string::npos); + assert(mainQml.find("D-pad Navigate") != std::string::npos); assert(mainQml.find("selectedHostForPreview") != std::string::npos); assert(mainQml.find("selectedGameForPreview") != std::string::npos); assert(mainQml.find("refreshLaunchPreviewBinding") != std::string::npos); @@ -86,13 +87,20 @@ int main() { assert(mainQml.find("Selected host") != std::string::npos); assert(mainQml.find("Selected game") != std::string::npos); assert(mainQml.find("No games in read-only snapshot") != std::string::npos); - assert(mainQml.find("Read-only snapshot loaded") != std::string::npos); + assert(mainQml.find("Preview snapshot ready") != 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("A Copy preview saves this safe plan locally for inspection") != 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(mainQml.find("readonly property color focusRingColor") != std::string::npos); + assert(mainQml.find("readonly property color focusGlowColor") != std::string::npos); + assert(mainQml.find("cursorShape: Qt.BlankCursor") != std::string::npos); + assert(mainQml.find("D-pad focus") != std::string::npos); + assert(mainQml.find("Exact preview details stay behind Copy preview details") != std::string::npos); + assert(mainQml.find("text: selectedLaunchPreviewText") == std::string::npos); + assert(mainQml.find("font.family: monospace") == std::string::npos); assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, @@ -173,8 +181,8 @@ int main() { const auto launchCta = nova::deck::inertLaunchCtaFor(detail); assert(launchCta.id == std::string_view("host-detail-launch-cta")); - assert(launchCta.label == std::string_view("Launch preview only")); - assert(launchCta.helpText == std::string_view("Display-only preview — not wired to launch, Moonlight, or a network backend.")); + assert(launchCta.label == std::string_view("Safe launch preview")); + assert(launchCta.helpText == std::string_view("Safe preview: no game, stream, or network launch starts from this screen.")); 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&mode=steam-direct&stream=headless&state=noop-preview")); @@ -189,13 +197,13 @@ int main() { assert(launchIntent.steamLaunchMode == "direct"); assert(launchIntent.boundary.kind == nova::deck::DeckLaunchIntentBoundaryKind::PreviewOnly); assert(launchIntent.boundary.id == "deck-launch-preview-only"); - assert(launchIntent.boundary.label == "Preview-only typed intent boundary"); + assert(launchIntent.boundary.label == "Safe preview: no game, stream, or network launch starts from this screen."); assert(launchIntent.boundary.previewOnly); assert(!launchIntent.boundary.allowsNetwork); assert(!launchIntent.boundary.allowsProcessExecution); assert(!launchIntent.boundary.allowsMoonlight); assert(!launchIntent.boundary.allowsHostMutation); - assert(launchIntent.boundary.reason == "Deck shell may build copyable preview text, but launch execution is blocked."); + assert(launchIntent.boundary.reason == "Nova Deck shows a copyable preview plan only; games, streams, and network launches stay off."); assert(!launchIntent.executable); assert(launchIntent.safetyLabel == "Preview only — not executable"); assert(launchIntent.host.addressClass == nova::deck::DeckHostAddressClass::DemoOnly); @@ -209,7 +217,7 @@ int main() { 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.publicPreviewCopy == "Review Portal 2 on Gaming PC via Steam direct. Safe preview only; no game or stream starts."); 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)); @@ -220,7 +228,7 @@ int main() { 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.publicCopy == "Safe preview of Portal 2 on Gaming PC; stream remains not started."); assert(!streamIntent.safety.allowsNetwork); assert(!streamIntent.safety.allowsProcessExecution); assert(!streamIntent.safety.allowsMoonlight); @@ -272,7 +280,7 @@ int main() { 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"); + assert(commandPreview.boundaryLabel == "Safe preview: no game, stream, or network launch starts from this screen."); assert(commandPreview.copyOnly); assert(!commandPreview.executable); assert(!commandPreview.networkAllowed); @@ -290,9 +298,9 @@ int main() { const auto copyAction = nova::deck::copyLaunchPreviewActionFor(commandPreview); assert(copyAction.id == std::string_view("host-detail-copy-preview")); - assert(copyAction.label == std::string_view("Copy preview URI (no launch)")); + assert(copyAction.label == std::string_view("Copy preview details")); assert(copyAction.previewText == commandPreview.text); - assert(copyAction.idleStatusLabel == "A copies the preview URI locally only — no launch, stream, backend, or Moonlight."); + assert(copyAction.idleStatusLabel == "A Copy preview saves this safe plan locally for inspection. No game, stream, or network launch starts."); assert(copyAction.successToast == "Preview text copied for inspection only — still not executable."); assert(copyAction.inertToast == "No preview text to copy — preview-only action stayed inert."); assert(copyAction.copyOnly); @@ -357,7 +365,7 @@ int main() { assert(detailFocus[0].id == std::string_view("host-detail-panel")); assert(detailFocus[1].id == std::string_view("host-detail-launch-cta")); assert(detailFocus[2].id == std::string_view("host-detail-copy-preview")); - assert(detailFocus[2].label == std::string_view("Copy preview URI (no launch)")); + assert(detailFocus[2].label == std::string_view("Copy preview details")); assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-panel", nova::deck::DeckFocusDirection::Down) == std::string_view("host-detail-launch-cta")); assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-launch-cta", nova::deck::DeckFocusDirection::Down)