修复:完善终端开机及串流交接并接入云管平台
这个提交包含在:
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)
|
||||
在新工单中引用
屏蔽一个用户