功能:接入Linux原生光标并完善设备接入工具

This commit is contained in:
songyiming committed 2026-11-05 19:18:54 +08:00
1 parent a14f028402
commit a47968496e
92 files changed
+3090 -22

No files matched your search

+7 -3
View File
@@ -28,16 +28,20 @@ PATCH_DIR="${VDI_MOONLIGHT_PATCH_DIR:-$(cd "$(dirname "$0")/../terminal/moonligh
apply_vdi_patch() {
local source="$1" patch="$2"
test -r "$patch" || { echo "Missing VDI patch: $patch" >&2; exit 1; }
if git -C "$source" apply --reverse --check "$patch" 2>/dev/null; then
# A Windows checkout can give patch files CRLF. Normalize patch transport
# only; source drift still has to pass the ordinary exact-context check.
if sed 's/\r$//' "$patch" | git -C "$source" apply --reverse --check - 2>/dev/null; then
return
fi
git -C "$source" apply --check "$patch"
git -C "$source" apply "$patch"
sed 's/\r$//' "$patch" | git -C "$source" apply --check -
sed 's/\r$//' "$patch" | git -C "$source" apply -
}
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0001-vdi-input-and-latest-frame.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight/moonlight-common-c/moonlight-common-c" \
"$PATCH_DIR/0002-vdi-absolute-click-barrier.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0003-vdi-input-priority.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0004-vdi-power-key-policy.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0005-vdi-native-linux-cursor.patch"
# Moonlight 6.2.0 uses std::optional in drm.h but relies on a transitive
# include from Qt 6. Our Qt 5 build needs the standard header explicitly.
+6 -4
View File
@@ -86,16 +86,18 @@ if line not in text.splitlines():p.write_text(text.rstrip()+'\\n'+line+'\\n')
p.chmod(0o600)
'''
auth_line = (f'from="{args.pve}",restrict,port-forwarding,permitopen="127.0.0.1:40001",'
f'permitopen="127.0.0.1:40002",'
f'command="/bin/false" {pubkey}')
box_run(args.box, f"python3 - {shlex.quote(backup)} {shlex.quote(auth_line)}", auth_script)
stage = box_run(args.box, "mktemp -d /tmp/vdi-usb-deploy.XXXXXX").strip()
service_text = (repo / "terminal/usb-monitor/etc/vdi-usb-monitor.service").read_text()
if args.any_storage:
service_exec = ("ExecStart=/usr/local/libexec/vdi-usb-monitor serve "
"--any-storage --listen 127.0.0.1:40001")
"--any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002")
else:
service_exec = (f"ExecStart=/usr/local/libexec/vdi-usb-monitor serve --vendor {args.vendor} "
f"--product {args.product} --serial {args.serial} --listen 127.0.0.1:40001")
f"--product {args.product} --serial {args.serial} --listen 127.0.0.1:40001 "
"--status 127.0.0.1:40002")
service_text = re.sub(r"^ExecStart=.*$", service_exec, service_text, flags=re.MULTILINE)
service_fd, service_path = tempfile.mkstemp(prefix="vdi-usb-monitor-", suffix=".service")
os.close(service_fd)
@@ -149,7 +151,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 {args.box}
ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 -L 127.0.0.1:40002:127.0.0.1:40002 {args.box}
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
@@ -165,7 +167,7 @@ After=network-online.target vdi-usb-tunnel-{args.vm}.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/libexec/vdi-usb-device-qmp.py watch --vm {args.vm} --port 40001 --bus ehci-2.0 {qmp_args}
ExecStart=/usr/local/libexec/vdi-usb-device-qmp.py watch --vm {args.vm} --port 40001 --status-port 40002 --interval 1 --remove-after 3 --bus ehci-2.0 {qmp_args}
Restart=always
RestartSec=3
NoNewPrivileges=yes
+2
View File
@@ -23,6 +23,8 @@ scp -q "$SRC/tests/test_display_guard.py" "$BOX":~/vdi-build/repo/kiosk-shell/te
scp -q "$SRC/scripts/vdi-display-guard" "$BOX":~/vdi-build/repo/kiosk-shell/scripts/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$SRC/scripts/vdi-kiosk-start" "$BOX":~/vdi-terminal/bin/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight-rkmpp" "$BOX":~/vdi-terminal/bin/
scp -q "$REPO/terminal/native-cursor/client.py" "$BOX":~/vdi-terminal/bin/vdi-native-cursor
ssh "$BOX" 'sed -i "s/\r$//" ~/vdi-terminal/bin/vdi-native-cursor'
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-terminal/bin/vdi-moonlight-rkmpp; chmod +x ~/vdi-terminal/bin/vdi-moonlight-rkmpp'
scp -q "$SRC/etc/70-vdi-rockchip-video.rules" "$BOX":/tmp/70-vdi-rockchip-video.rules
ssh "$BOX" 'sed -i "s/\r$//" /tmp/70-vdi-rockchip-video.rules; sudo -n install -m 0644 /tmp/70-vdi-rockchip-video.rules /etc/udev/rules.d/70-vdi-rockchip-video.rules && sudo -n udevadm control --reload-rules && for device in /dev/mpp_service /dev/rga /dev/dma_heap/*; do test ! -e "$device" || { sudo -n chgrp video "$device" && sudo -n chmod 0660 "$device"; } || exit 1; done'
+63
View File
@@ -0,0 +1,63 @@
"""Bounds/replay checks for untrusted cursor metadata before SDL allocation."""
import base64
import importlib.util
import hashlib
from pathlib import Path
import unittest
import tempfile
spec = importlib.util.spec_from_file_location(
'native_cursor_client', Path(__file__).resolve().parents[2] /
'terminal/native-cursor/client.py')
client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(client)
class CursorProtocolTests(unittest.TestCase):
def state(self):
return dict(type='cursor', version=1, lease='c'*32, seq=2, ack=0, w=2, h=2,
hx=1, hy=1, x=100, y=100, sw=2560, sh=1440,
rgba=base64.b64encode(bytes([255]*16)).decode())
def test_valid_and_hidden(self):
for pixels in (bytes([255]*16), bytes(16)):
s = self.state()
s['rgba'] = base64.b64encode(pixels).decode()
packet = client.validate(s, 'c'*32, [2560, 1440], 1)
self.assertEqual(len(packet), 88)
self.assertEqual(packet[64:80], pixels)
def test_bad_metadata(self):
for key, value in [('version', 2), ('version', True), ('ack', -1),
('ack', 2**63), ('lease', 'd'*32), ('type', 'ready'),
('seq', 1), ('seq', 2**63), ('w', 0), ('w', 129),
('h', -1), ('hx', 2), ('hy', -1), ('x', -1),
('y', 1440), ('sw', 1920), ('w', True),
('seq', 2.0), ('rgba', '***'), ('rgba', 'AAAA')]:
with self.subTest(key=key, value=value):
state = self.state()
state[key] = value
with self.assertRaises(ValueError):
client.validate(state, 'c'*32, [2560, 1440], 1)
def test_missing_field(self):
state = self.state()
del state['h']
with self.assertRaises(ValueError):
client.validate(state, 'c'*32, [2560, 1440], 1)
def test_old_client_rejected_before_lease(self):
with tempfile.TemporaryDirectory() as directory:
binary = Path(directory) / 'client'
binary.write_bytes(b'validated client')
expected = hashlib.sha256(binary.read_bytes()).hexdigest()
client.verify_client(binary, expected)
binary.write_bytes(b'old client without cursor receiver')
with self.assertRaises(ValueError):
client.verify_client(binary, expected)
with self.assertRaises(ValueError):
client.verify_client(binary, None)
if __name__ == '__main__':
unittest.main()
+20
View File
@@ -1,5 +1,7 @@
import importlib.util
from pathlib import Path
import socket
import threading
import unittest
spec = importlib.util.spec_from_file_location("usb_qmp", Path(__file__).parents[1] / "usb-device-qmp.py")
@@ -51,6 +53,24 @@ class UsbQmpTests(unittest.TestCase):
usb.attach(q, 100, 40001, "ehci-2.0", "0951", "1666")
self.assertEqual(q.calls[-1], ("chardev-remove", {"id": "vdi_usb_box_100"}))
def test_status_probe_distinguishes_ready_from_empty(self):
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen()
port = listener.getsockname()[1]
def serve():
conn, _ = listener.accept()
conn.sendall(b"ready\n")
conn.close()
listener.close()
thread = threading.Thread(target=serve)
thread.start()
self.assertTrue(usb.probe_status(port, timeout=1))
thread.join(timeout=1)
self.assertFalse(usb.probe_status(port, timeout=0.05))
if __name__ == "__main__":
unittest.main()
+44 -4
View File
@@ -116,13 +116,41 @@ def ensure(qmp, vm, port, bus, vendor=None, product=None):
attach(qmp, vm, port, bus, vendor, product)
def watch(vm, port, bus, vendor=None, product=None, interval=3):
"""Keep the runtime endpoint attached across VM restarts."""
def probe_status(port, host="127.0.0.1", timeout=0.7):
"""Return whether the box has an exportable storage device ready."""
try:
with socket.create_connection((host, port), timeout=timeout) as stream:
stream.settimeout(timeout)
return stream.recv(32).strip() == b"ready"
except OSError:
return False
def watch(vm, port, bus, vendor=None, product=None, interval=3,
status_port=None, remove_after=3):
"""Track the box device and keep the guest endpoint in sync.
With status_port, the QEMU endpoint is present only while the box reports
an exportable physical storage device. A short missing-device debounce
prevents a transient udev/export restart from causing a guest hot-unplug.
Without status_port this retains the legacy always-present behavior.
"""
missing = 0
while True:
try:
ready = True if status_port is None else probe_status(status_port)
qmp = Qmp(vm)
try:
ensure(qmp, vm, port, bus, vendor, product)
channel, device = ids(vm)
present = device in peripherals(qmp)
if ready:
missing = 0
if not present:
attach(qmp, vm, port, bus, vendor, product)
else:
missing += 1
if present and missing >= remove_after:
detach(qmp, vm)
finally:
qmp.close()
except (OSError, RuntimeError) as exc:
@@ -140,11 +168,21 @@ def main():
parser.add_argument("--product")
parser.add_argument("--any-storage", action="store_true",
help="Allow any pure USB Mass Storage device")
parser.add_argument("--status-port", type=int,
help="loopback port forwarded to the box readiness endpoint")
parser.add_argument("--interval", type=float, default=3,
help="watch polling interval in seconds")
parser.add_argument("--remove-after", type=int, default=3,
help="consecutive empty probes before guest removal")
parser.add_argument("--guest-ejected", action="store_true",
help="Confirm the guest has safely ejected the USB device before detach")
args = parser.parse_args()
if args.vm < 100 or not 1 <= args.port <= 65535:
parser.error("invalid VM ID or local tunnel port")
if args.status_port is not None and not 1 <= args.status_port <= 65535:
parser.error("invalid status port")
if args.interval <= 0 or args.remove_after < 1:
parser.error("interval must be positive and remove-after must be at least one")
if args.action in ("attach", "watch"):
exact = all(re.fullmatch(r"[0-9a-fA-F]{4}", x or "") for x in [args.vendor, args.product])
if args.any_storage and (args.vendor or args.product):
@@ -156,7 +194,9 @@ def main():
if args.action == "watch":
watch(args.vm, args.port, args.bus,
None if args.any_storage else args.vendor.lower(),
None if args.any_storage else args.product.lower())
None if args.any_storage else args.product.lower(),
interval=args.interval, status_port=args.status_port,
remove_after=args.remove_after)
return
if args.action == "detach" and not args.guest_ejected:
parser.error("safely eject in the guest first, then pass --guest-ejected")