修复:完善终端开机及串流交接并接入云管平台

这个提交包含在:
songyiming 于 2026-11-09 17:54:18 +08:00 提交
1 父节点 8276da0d45
当前提交 18a9ce995d
25 个文件已更改
+922 -48

没有与搜索条件相匹配的文件

@@ -12,6 +12,7 @@ private slots:
void requiresBothReadyMarkers();
void acceptsSeparateHardwareTestDecoder();
void rejectsSecondStart();
void reportsKioskLaunchFailure();
void buildsConsistentStreamArguments();
void selectsProfileWithoutLosingStats();
void rejectsInvalidDecoderOverride();
@@ -107,6 +108,41 @@ void MoonlightSessionTest::rejectsSecondStart() {
qputenv("PATH", oldPath);
}
void MoonlightSessionTest::reportsKioskLaunchFailure() {
QTemporaryDir tempDir;
QVERIFY(tempDir.isValid());
const QString executable = tempDir.filePath(QStringLiteral("failed-client"));
QFile script(executable);
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
script.write("#!/bin/sh\n"
"test \"$VDI_KIOSK_STREAM\" = 1 || exit 2\n"
"exit 1\n");
script.close();
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
QFileDevice::ExeOwner));
MoonlightSession session;
MoonlightSession::Options options;
options.program = executable;
session.setOptions(options);
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("127.0.0.1");
ticket.app = QStringLiteral("MissingApplication");
int failed = 0;
int finished = 0;
QString message;
QVERIFY(session.start(ticket, [&](const MoonlightSession::Event& event) {
if (event.type == MoonlightSession::EventType::kFailed) {
++failed;
message = event.message;
} else if (event.type == MoonlightSession::EventType::kFinished) {
++finished;
}
}));
QTRY_COMPARE_WITH_TIMEOUT(failed, 1, 2000);
QCOMPARE(finished, 0);
QVERIFY(message.contains(QStringLiteral("code=1")));
}
void MoonlightSessionTest::buildsConsistentStreamArguments() {
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("192.168.1.70");
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Opt-in live X11 entry regression check. Follow with verify_return_surface.
DISPLAY=:0 python3 verify_entry_surface.py 0
Starts a real stream from card 0/1/2 and uses Ctrl+Alt+Shift+Q to return.
Requires verify_return_surface.py in the same directory.
"""
import ctypes as C
import json
import subprocess
import sys
import time
from verify_return_surface import x, display, root, attrs, find, child_at, Attributes
class ClassHint(C.Structure):
_fields_ = [("name", C.c_void_p), ("class_name", C.c_void_p)]
x.XQueryTree.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong),
C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)),
C.POINTER(C.c_uint)]
x.XGetClassHint.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(ClassHint)]
x.XFree.argtypes = [C.c_void_p]
# A client may destroy a window between QueryTree and GetWindowAttributes.
# Ignore that expected BadWindow race; all other errors fail the check.
errors = []
class XError(C.Structure):
_fields_ = [("type", C.c_int), ("display", C.c_void_p),
("resource", C.c_ulong), ("serial", C.c_ulong),
("error_code", C.c_ubyte), ("request_code", C.c_ubyte),
("minor_code", C.c_ubyte)]
@C.CFUNCTYPE(C.c_int, C.c_void_p, C.POINTER(XError))
def on_error(_, error):
if error.contents.error_code != 3: # BadWindow
errors.append(error.contents.error_code)
return 0
x.XSetErrorHandler.argtypes = [C.c_void_p]
x.XSetErrorHandler(on_error)
def windows():
returned_root, parent, count = C.c_ulong(), C.c_ulong(), C.c_uint()
children = C.POINTER(C.c_ulong)()
x.XQueryTree(display, root, C.byref(returned_root), C.byref(parent),
C.byref(children), C.byref(count))
ids = [children[i] for i in range(count.value)]
if children:
x.XFree(children)
result = {}
for window in ids:
a = Attributes()
if not x.XGetWindowAttributes(display, window, C.byref(a)) or a.map_state != 2:
continue
hint = ClassHint()
cls = ""
if x.XGetClassHint(display, window, C.byref(hint)):
if hint.class_name:
cls = C.string_at(hint.class_name).decode("utf-8", "replace")
x.XFree(hint.class_name)
if hint.name:
x.XFree(hint.name)
result[window] = (a, cls)
return result
def main():
index = int(sys.argv[1])
if index not in (0, 1, 2):
raise ValueError("Desktop index must be 0/1/2")
geometry = attrs(root)
width, height = geometry.width, geometry.height
kiosk = find("--onlyvisible", "--name", "^VDI 云终端$")[0]
if find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight"):
raise RuntimeError("Start this check from the idle desktop list")
subprocess.run(["xdotool", "mousemove", str(width - 155), str(209 + 110 * index),
"click", "1"], check=True)
started = time.monotonic()
samples, violations, states, first_geometry = 0, [], [], {}
foreground_at = None
points = [(20, 10), (width // 2, height // 2), (width - 20, height - 10)]
ready = False
while time.monotonic() - started < 35:
elapsed = time.monotonic() - started
mapped = windows()
samples += 1
tops = [child_at(px, py) for px, py in points]
classes = {w: cls for w, (_, cls) in mapped.items()}
for window, (a, cls) in mapped.items():
if "moonlight" in cls.lower():
geometry = [a.x, a.y, a.width, a.height]
first_geometry.setdefault(window, {"t": round(elapsed, 4),
"class": cls, "geometry": geometry})
if cls != "com.moonlight_stream.Moonlight" or geometry != [0, 0, width, height]:
violations.append({"t": elapsed, "window": window,
"class": cls, "geometry": geometry})
if kiosk not in mapped:
violations.append({"t": elapsed, "reason": "kiosk backing window unmapped"})
if not states or states[-1]["windows"] != tops:
states.append({"t": round(elapsed, 4), "windows": tops, "classes": classes})
if all(classes.get(w) == "com.moonlight_stream.Moonlight" for w in tops):
if foreground_at is None:
foreground_at = elapsed
if elapsed - foreground_at >= 2:
ready = True
break
else:
foreground_at = None
time.sleep(0.004)
with open("/tmp/entry-live-%s.xwd" % index, "wb") as screenshot:
subprocess.run(["xwd", "-root", "-silent"], stdout=screenshot, check=True)
subprocess.run(["xdotool", "key", "--clearmodifiers", "ctrl+alt+shift+q"], check=True)
time.sleep(1)
returned = all(child_at(px, py) == kiosk for px, py in points)
result = {"desktop_index": index, "entry_samples": samples,
"stream_foreground": ready, "returned_to_kiosk": returned,
"first_mapped_moonlight_geometry": first_geometry,
"stack_changes": states, "violations": violations,
"x11_errors": errors, "ok": ready and returned and not violations and not errors}
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result["ok"] else 1
try:
sys.exit(main())
finally:
x.XCloseDisplay(display)
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Opt-in live X11 check: connect a desktop card and exit with the real hotkey.
Run on an idle terminal, e.g. DISPLAY=:0 python3 verify_return_surface.py 0.
Indices 0/1/2 select Windows/Ubuntu/Kylin in the current three-card layout.
This starts and disconnects a stream, but does not shut down the guest.
"""
import ctypes as C
import json
import subprocess
import sys
import time
class Attributes(C.Structure):
_fields_ = [("x", C.c_int), ("y", C.c_int), ("width", C.c_int),
("height", C.c_int), ("border_width", C.c_int),
("depth", C.c_int), ("visual", C.c_void_p),
("root", C.c_ulong), ("window_class", C.c_int),
("bit_gravity", C.c_int), ("win_gravity", C.c_int),
("backing_store", C.c_int), ("backing_planes", C.c_ulong),
("backing_pixel", C.c_ulong), ("save_under", C.c_int),
("colormap", C.c_ulong), ("map_installed", C.c_int),
("map_state", C.c_int), ("all_event_masks", C.c_long),
("your_event_mask", C.c_long), ("do_not_propagate_mask", C.c_long),
("override_redirect", C.c_int), ("screen", C.c_void_p)]
x = C.CDLL("libX11.so.6")
x.XOpenDisplay.argtypes = [C.c_char_p]
x.XOpenDisplay.restype = C.c_void_p
x.XDefaultRootWindow.argtypes = [C.c_void_p]
x.XDefaultRootWindow.restype = C.c_ulong
x.XGetWindowAttributes.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(Attributes)]
x.XTranslateCoordinates.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong,
C.c_int, C.c_int, C.POINTER(C.c_int),
C.POINTER(C.c_int), C.POINTER(C.c_ulong)]
x.XCloseDisplay.argtypes = [C.c_void_p]
display = x.XOpenDisplay(None)
if not display:
raise SystemExit("Cannot open X11 display")
root = x.XDefaultRootWindow(display)
def attrs(window):
value = Attributes()
if not x.XGetWindowAttributes(display, window, C.byref(value)):
raise RuntimeError("X11 window no longer exists")
return value
def find(*args):
p = subprocess.run(["xdotool", "search", *args], capture_output=True, text=True)
return [int(w) for w in p.stdout.split()] if p.returncode == 0 else []
def child_at(px, py):
dx, dy, child = C.c_int(), C.c_int(), C.c_ulong()
x.XTranslateCoordinates(display, root, root, px, py,
C.byref(dx), C.byref(dy), C.byref(child))
return child.value
def main():
index = int(sys.argv[1])
if index not in (0, 1, 2):
raise ValueError("Desktop index must be 0, 1, or 2")
kiosk = find("--onlyvisible", "--name", "^VDI 云终端$")
if len(kiosk) != 1:
raise RuntimeError("Expected one mapped kiosk window")
kiosk = kiosk[0]
geometry = attrs(root)
width, height = geometry.width, geometry.height
# Current card layout: margins 80/50, 96px cards, 14px spacing.
subprocess.run(["xdotool", "mousemove", str(width - 155), str(209 + index * 110),
"click", "1"], check=True)
deadline = time.monotonic() + 35
stream = None
while time.monotonic() < deadline:
for window in find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight"):
a = attrs(window)
if a.width >= width and a.height >= height:
stream = window
break
if stream and child_at(width // 2, height // 2) == stream:
break
time.sleep(0.1)
else:
raise RuntimeError("Stream did not become the full-screen foreground window")
# Let the existing ready transition/overlay settle, then inspect the
# backing window while the native client still owns focus and the hotkey.
time.sleep(2)
# Moonlight replaces its Qt startup window with a separate SDL stream
# window. Resolve both only after startup instead of treating that normal
# replacement as a foreign desktop window.
stream_windows = find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight")
stream = child_at(width // 2, height // 2)
if stream not in stream_windows:
raise RuntimeError("Moonlight is not foreground after startup settled")
with open("/tmp/return-live-%s.xwd" % index, "wb") as screenshot:
subprocess.run(["xwd", "-root", "-silent"], stdout=screenshot, check=True)
samples, violations, stacks = 0, [], []
started = time.monotonic()
hotkey = None
points = [(20, 10), (width // 2, height // 2),
(width - 20, height - 10), (width - 20, 10)]
while time.monotonic() - started < 3:
elapsed = time.monotonic() - started
if hotkey is None and elapsed >= 0.3:
hotkey = subprocess.Popen(["xdotool", "key", "--clearmodifiers",
"ctrl+alt+shift+q"])
a = attrs(kiosk)
tops = [child_at(px, py) for px, py in points]
samples += 1
if a.map_state != 2 or (a.x, a.y, a.width, a.height) != (0, 0, width, height):
violations.append({"t": elapsed, "reason": "kiosk not mapped full-screen"})
if any(w not in [kiosk, *stream_windows] for w in tops):
violations.append({"t": elapsed, "reason": "foreign/root window exposed",
"top_windows": tops})
if not stacks or stacks[-1]["windows"] != tops:
stacks.append({"t": round(elapsed, 4), "windows": tops})
time.sleep(0.004)
hotkey.wait(timeout=3)
returned = all(child_at(px, py) == kiosk for px, py in points)
result = {"desktop_index": index, "samples": samples,
"kiosk": kiosk, "stream": stream, "stream_windows": stream_windows,
"stack_changes": stacks,
"returned_to_kiosk": returned, "violations": violations,
"ok": returned and not violations}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["ok"] else 1
if __name__ == "__main__":
try:
sys.exit(main())
finally:
x.XCloseDisplay(display)