功能:接入Linux原生光标并完善设备接入工具
This commit is contained in:
1 parent
a14f028402
commit
a47968496e
92 files changed
+3090
-22
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user