From 0bdd644d420737a7aea506e252fc5e12db34c599 Mon Sep 17 00:00:00 2001 From: junaga Date: Tue, 28 Jul 2026 20:08:19 +0200 Subject: [PATCH] Add safe Windows/Linux pointer lab Co-authored-by: Codex --- CMakeLists.txt | 15 + README.md | 2 + lab/README.md | 114 ++++++ lab/analyze-capture-timing.py | 95 +++++ lab/analyze-physical-linux.py | 192 +++++++++ lab/bluetooth-radio.sh | 209 ++++++++++ lab/capture-physical-linux.sh | 111 +++++ lab/compare-captures.py | 101 +++++ lab/guest/guest-hyprctl.sh | 14 + lab/guest/linux-meta-data.yaml | 2 + lab/guest/linux-user-data.yaml.in | 100 +++++ lab/guest/windows/Autounattend.xml | 179 ++++++++ lab/guest/windows/BluetoothScan.cs | 82 ++++ lab/guest/windows/Console.ps1 | 8 + lab/guest/windows/Open-Bluetooth.ps1 | 2 + lab/guest/windows/Repair-PointerLab.ps1 | 34 ++ lab/guest/windows/Run-Capture.ps1 | 34 ++ lab/guest/windows/Setup-PointerLab.ps1 | 93 +++++ lab/host/bluetooth-usb.xml.in | 7 + lab/pointer-lab.sh | 136 +++++++ lab/preflight.py | 210 ++++++++++ lab/replay-hid.py | 220 ++++++++++ lab/run-digital-test.sh | 139 +++++++ lab/traces/full-scenarios.csv | 32 ++ lab/unsafe/full-xhci-controller.sh | 161 ++++++++ lab/unsafe/grub-iommu.cfg | 2 + lab/unsafe/windows-pcie-pci-bridge.xml | 4 + lab/unsafe/xhci-hostdev.xml | 6 + lab/usb-gadgets.sh | 194 +++++++++ lab/vm-lab.sh | 520 ++++++++++++++++++++++++ lab/windows-capture-to-common.py | 169 ++++++++ lab/windows-login.sh | 115 ++++++ lab/windows-remote.py | 147 +++++++ src/plugin.cpp | 102 ++++- tools/windows-capture.cpp | 279 +++++++++++++ 35 files changed, 3827 insertions(+), 3 deletions(-) create mode 100644 lab/README.md create mode 100755 lab/analyze-capture-timing.py create mode 100755 lab/analyze-physical-linux.py create mode 100755 lab/bluetooth-radio.sh create mode 100755 lab/capture-physical-linux.sh create mode 100755 lab/compare-captures.py create mode 100755 lab/guest/guest-hyprctl.sh create mode 100644 lab/guest/linux-meta-data.yaml create mode 100644 lab/guest/linux-user-data.yaml.in create mode 100644 lab/guest/windows/Autounattend.xml create mode 100644 lab/guest/windows/BluetoothScan.cs create mode 100644 lab/guest/windows/Console.ps1 create mode 100644 lab/guest/windows/Open-Bluetooth.ps1 create mode 100644 lab/guest/windows/Repair-PointerLab.ps1 create mode 100644 lab/guest/windows/Run-Capture.ps1 create mode 100644 lab/guest/windows/Setup-PointerLab.ps1 create mode 100644 lab/host/bluetooth-usb.xml.in create mode 100755 lab/pointer-lab.sh create mode 100755 lab/preflight.py create mode 100755 lab/replay-hid.py create mode 100755 lab/run-digital-test.sh create mode 100644 lab/traces/full-scenarios.csv create mode 100755 lab/unsafe/full-xhci-controller.sh create mode 100644 lab/unsafe/grub-iommu.cfg create mode 100644 lab/unsafe/windows-pcie-pci-bridge.xml create mode 100644 lab/unsafe/xhci-hostdev.xml create mode 100755 lab/usb-gadgets.sh create mode 100755 lab/vm-lab.sh create mode 100755 lab/windows-capture-to-common.py create mode 100755 lab/windows-login.sh create mode 100755 lab/windows-remote.py create mode 100644 tools/windows-capture.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5443004..e940818 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,21 @@ if(WINDOWS_POINTER_BUILD_TOOLS) windows_pointer_target_defaults(windows-pointer-replay) target_link_libraries(windows-pointer-replay PRIVATE windows_pointer::engine) + if(WIN32) + add_executable(windows-pointer-windows-capture tools/windows-capture.cpp) + windows_pointer_target_defaults(windows-pointer-windows-capture) + target_link_libraries(windows-pointer-windows-capture PRIVATE user32 wtsapi32) + if(MINGW) + target_link_options( + windows-pointer-windows-capture + PRIVATE + -static + -static-libgcc + -static-libstdc++ + ) + endif() + endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_executable(windows-pointer-uinput tools/uinput-mouse.cpp) windows_pointer_target_defaults(windows-pointer-uinput) diff --git a/README.md b/README.md index 58d1f24..6efd4c8 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ boundaries. adapter contract. - [Testing](docs/testing.md) documents conformance, regression, fuzz, replay, integration, and performance checks. +- [Windows/Linux VM lab](lab/README.md) runs the same synthetic HID reports + through both operating systems without taking over host USB devices. - [Contributing](CONTRIBUTING.md) explains how to change the engine or add an adapter without turning either into folklore. - [Changelog](CHANGELOG.md) records released behavior. diff --git a/lab/README.md b/lab/README.md new file mode 100644 index 0000000..0eeffef --- /dev/null +++ b/lab/README.md @@ -0,0 +1,114 @@ +# Windows/Linux pointer lab + +The safe default is a fully automated, simultaneous Windows/Linux test. It +does not detach the host keyboard, mouse, webcam, Bluetooth radio, or USB +controller. + +## everyday commands + +```sh +# Build, start both VMs, run the exact comparison, and clean up test devices. +lab/pointer-lab.sh test + +# Use a particular input trace. +lab/pointer-lab.sh test lab/traces/full-scenarios.csv + +# Manage or inspect the friendly VMs. +lab/pointer-lab.sh start +lab/pointer-lab.sh status +lab/pointer-lab.sh screenshot windows +lab/pointer-lab.sh screenshot linux +lab/pointer-lab.sh stop +``` + +`test` is unattended. It builds current code, boots and repairs the existing +guests, synchronizes the Linux plugin and Windows collector, and creates two +kernel-backed synthetic USB HID mice. The same report is written to both +devices behind a per-report barrier. The test then: + +1. captures Windows Raw Input and desktop pointer events; +2. captures Linux/Hyprland raw and transformed reports; +3. requires every raw and accelerated delta to match exactly; +4. reports guest timing and simultaneous host-write skew; +5. detaches and removes both synthetic devices, including after failures. + +Timestamped artifacts are written below `build/lab/dual-vm-*`. + +## friendly guests + +- `wplab-linux` is Debian 14 with an auto-started Hyprland session, a flat + libinput profile, SSH, and the current plugin. +- `wplab-windows` is a disposable Windows 11 VM with persistent auto-login, + default Windows pointer settings (10/20 with EPP enabled), WinRM on the + libvirt-private network, and a visible Pointer Lab console for Raw Input. +- Both desktops use VNC bound to localhost. Scripts discover the assigned VNC + port dynamically; no `5900`/`5901` ordering is assumed. +- Windows binaries are synchronized over the private VM network instead of + being typed into a console or copied in tiny WinRM chunks. + +The visible Windows console is deliberate. Windows does not deliver the same +desktop pointer stream to hidden service or scheduler sessions. The host opens +a fresh interactive PowerShell through the VM's virtual keyboard before each +capture; no physical keyboard is involved. + +## initial provisioning + +Check the Debian host: + +```sh +python3 lab/preflight.py +``` + +The dependencies are ordinary Debian packages except for the Windows ISO +itself. The preflight checks KVM, libvirt/QEMU, configfs HID support, MinGW, +WinRM support, VNC automation, capture tools, RAM, and disk space. + +Provision the Linux guest from the verified Debian cloud image: + +```sh +lab/vm-lab.sh provision-linux +lab/vm-lab.sh wait-linux +``` + +Provision the Windows guest from a genuine Windows 11 ISO: + +```sh +lab/vm-lab.sh provision-windows /absolute/path/to/windows-11.iso +lab/vm-lab.sh wait-windows +``` + +After provisioning, use only the everyday `pointer-lab.sh` commands. + +## Bluetooth hardware tests + +Real Bluetooth is optional and intentionally separate from pointer-algorithm +conformance. + +```sh +sudo lab/bluetooth-radio.sh status +sudo lab/bluetooth-radio.sh attach-windows +sudo lab/bluetooth-radio.sh attach-linux +sudo lab/bluetooth-radio.sh detach +``` + +This path passes through **one dedicated USB Bluetooth adapter**, not a PCI +USB controller. It refuses any adapter carrying a connected host device. On +the current host, the built-in AX210 is marked `HOST-IN-USE` because it carries +the Pebble, so an additional USB Bluetooth dongle is required. + +A dedicated dongle preserves the host keyboard, mouse, webcam, and built-in +Bluetooth. It covers the guest Bluetooth/HID stack and real radio transport. +It does not claim to test the guest against the motherboard's physical xHCI +driver; that distinction is not relevant to pointer acceleration and is not +worth taking over every host USB device. + +Physical sensor movement cannot be automated without a mechanical actuator. +When nobody is present, run only the synthetic conformance test. + +## unsafe research-only path + +`lab/unsafe/full-xhci-controller.sh` is retained only to reproduce low-level +controller experiments. It refuses attachment unless an explicit expert +override is provided because it disconnects every device on the motherboard +controller. It is never called by preflight, `start`, `test`, `status`, or +`stop`. diff --git a/lab/analyze-capture-timing.py b/lab/analyze-capture-timing.py new file mode 100755 index 0000000..ce7fb94 --- /dev/null +++ b/lab/analyze-capture-timing.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import pathlib +import statistics + + +def read_column(path: pathlib.Path, column: str) -> list[int]: + with path.open(newline="", encoding="utf-8") as stream: + rows = ( + line + for line in stream + if line.strip() and not line.startswith("#") + ) + reader = csv.DictReader(rows) + if reader.fieldnames is None or column not in reader.fieldnames: + raise ValueError(f"{path}: missing {column}") + return [int(row[column]) for row in reader] + + +def intervals(values: list[int]) -> list[int]: + return [right - left for left, right in zip(values, values[1:])] + + +def percentile(values: list[int], percent: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + position = (len(ordered) - 1) * percent + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction + + +def describe(name: str, values: list[int], reference: list[int]) -> None: + sample_intervals = intervals(values) + reference_intervals = intervals(reference) + if len(sample_intervals) != len(reference_intervals): + print( + f"{name}: unavailable ({len(values)} samples; " + f"reference has {len(reference)})" + ) + return + errors = [ + sample - expected + for sample, expected in zip( + sample_intervals, reference_intervals, strict=True + ) + ] + absolute_errors = [abs(error) for error in errors] + print( + f"{name}: intervals={len(sample_intervals)}, " + f"median={statistics.median(sample_intervals) / 1e6:.3f} ms, " + f"p95_abs_error={percentile(absolute_errors, 0.95) / 1e6:.3f} ms, " + f"max_abs_error={max(absolute_errors, default=0) / 1e6:.3f} ms" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("host", type=pathlib.Path) + parser.add_argument("windows", type=pathlib.Path) + parser.add_argument("linux", type=pathlib.Path) + arguments = parser.parse_args() + + scheduled = read_column(arguments.host, "scheduled_ns") + windows_host = read_column(arguments.host, "device_0_start_ns") + linux_host = read_column(arguments.host, "device_1_start_ns") + windows_guest = read_column(arguments.windows, "time_ns") + linux_guest = read_column(arguments.linux, "time_ns") + + describe("host→Windows gadget writes", windows_host, scheduled) + describe("host→Linux gadget writes", linux_host, scheduled) + describe("Windows guest receipt", windows_guest, scheduled) + describe("Linux guest receipt", linux_guest, scheduled) + + write_skew = [ + abs(windows - linux) + for windows, linux in zip(windows_host, linux_host, strict=True) + ] + print( + "simultaneous host-write skew: " + f"median={statistics.median(write_skew) / 1e6:.3f} ms, " + f"p95={percentile(write_skew, 0.95) / 1e6:.3f} ms, " + f"max={max(write_skew, default=0) / 1e6:.3f} ms" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab/analyze-physical-linux.py b/lab/analyze-physical-linux.py new file mode 100755 index 0000000..5835289 --- /dev/null +++ b/lab/analyze-physical-linux.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Correlate a physical Linux mouse capture across Bluetooth and input layers.""" + +from __future__ import annotations + +import argparse +import csv +import re +import statistics +from pathlib import Path + + +def percentile(values: list[float], percentage: float) -> float: + ordered = sorted(values) + if not ordered: + return float("nan") + rank = (len(ordered) - 1) * percentage / 100 + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + fraction = rank - lower + return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction + + +def parse_plugin(path: Path) -> tuple[list[float], list[tuple[int, int]]]: + timestamps: list[float] = [] + samples: list[tuple[int, int]] = [] + with path.open(newline="") as stream: + for row in csv.DictReader(stream): + timestamps.append(int(row["time_ns"]) / 1_000_000_000) + samples.append((int(row["raw_x"]), int(row["raw_y"]))) + return timestamps, samples + + +def parse_evtest(path: Path) -> tuple[list[float], list[tuple[int, int]]]: + timestamps: list[float] = [] + samples: list[tuple[int, int]] = [] + dx = dy = 0 + moved = False + timestamp = 0.0 + relative = re.compile( + r"Event: time ([0-9.]+), type \d+ \(EV_REL\), " + r"code \d+ \((REL_X|REL_Y)\), value (-?\d+)" + ) + sync = re.compile(r"Event: time ([0-9.]+), .* SYN_REPORT") + for line in path.read_text(errors="replace").splitlines(): + match = relative.search(line) + if match: + timestamp = float(match.group(1)) + code, value = match.group(2), int(match.group(3)) + if code == "REL_X": + dx += value + else: + dy += value + moved = True + elif sync.search(line) and moved: + timestamps.append(timestamp) + samples.append((dx, dy)) + dx = dy = 0 + moved = False + return timestamps, samples + + +def parse_libinput(path: Path) -> tuple[list[float], list[tuple[int, int]]]: + timestamps: list[float] = [] + samples: list[tuple[int, int]] = [] + number = r"[+-]?[0-9.]+" + motion = re.compile( + rf"\+\s*([0-9.]+)s\s+({number})/\s*({number})\s+" + rf"\(\s*({number})/\s*({number})\)" + ) + for line in path.read_text(errors="replace").splitlines(): + match = motion.search(line) + if not match: + continue + timestamps.append(float(match.group(1))) + samples.append( + (round(float(match.group(4))), round(float(match.group(5)))) + ) + return timestamps, samples + + +def parse_bluetooth(path: Path) -> list[float]: + timestamps: list[float] = [] + timestamp: float | None = None + acl = re.compile(r"^\s*> ACL Data.* ([0-9.]+)$") + for line in path.read_text(errors="replace").splitlines(): + match = acl.search(line) + if match: + timestamp = float(match.group(1)) + elif "Handle: 0x0028" in line and timestamp is not None: + timestamps.append(timestamp) + return timestamps + + +def intervals_ms(timestamps: list[float]) -> list[float]: + return [ + (current - previous) * 1000 + for previous, current in zip(timestamps, timestamps[1:]) + if current >= previous + ] + + +def describe(name: str, timestamps: list[float]) -> str: + intervals = intervals_ms(timestamps) + if not intervals: + return f"{name}: {len(timestamps)} samples (no intervals)" + active = [value for value in intervals if value <= 20] + burst = [value for value in active if value <= 0.2] + on_cadence = [value for value in active if 6 <= value <= 10] + late = [value for value in active if 10 < value <= 20] + burst_after_late = sum( + current <= 0.2 and previous > 10 + for previous, current in zip(intervals, intervals[1:]) + ) + return ( + f"{name}: {len(timestamps)} samples\n" + f" all gaps: median={statistics.median(intervals):.3f} ms, " + f"p95={percentile(intervals, 95):.3f} ms, max={max(intervals):.3f} ms\n" + f" active gaps (<=20 ms): {len(active)}, " + f"median={statistics.median(active):.3f} ms, " + f"p95={percentile(active, 95):.3f} ms\n" + f" burst <=0.2 ms: {len(burst)} ({100 * len(burst) / len(active):.1f}%), " + f"6-10 ms: {len(on_cadence)}, 10-20 ms: {len(late)}, " + f"late-then-burst pairs: {burst_after_late}" + ) + + +def align( + reference_name: str, + reference: list[tuple[int, int]], + candidate_name: str, + candidate: list[tuple[int, int]], +) -> str: + if not reference or not candidate: + return ( + f"{reference_name} vs {candidate_name}: cannot align " + f"({len(reference)}/{len(candidate)} samples)" + ) + best: tuple[tuple[int, int, int], int, int, int] | None = None + for reference_offset in range(min(32, len(reference))): + for candidate_offset in range(min(32, len(candidate))): + count = min( + len(reference) - reference_offset, + len(candidate) - candidate_offset, + ) + mismatches = sum( + reference[reference_offset + index] + != candidate[candidate_offset + index] + for index in range(count) + ) + score = (mismatches, -(count), reference_offset + candidate_offset) + if best is None or score < best[0]: + best = ( + score, + count, + reference_offset, + candidate_offset, + ) + assert best is not None + score, count, reference_offset, candidate_offset = best + mismatches = score[0] + return ( + f"{reference_name}[{reference_offset}:] vs " + f"{candidate_name}[{candidate_offset}:]: " + f"{count} compared, {mismatches} mismatches, " + f"tails={len(reference) - reference_offset - count}/" + f"{len(candidate) - candidate_offset - count}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("capture", type=Path) + args = parser.parse_args() + capture = args.capture + + plugin_times, plugin = parse_plugin(capture / "plugin.csv") + evtest_times, evtest = parse_evtest(capture / "evtest.txt") + libinput_times, libinput = parse_libinput(capture / "libinput.txt") + bluetooth_times = parse_bluetooth(capture / "bluetooth.txt") + + print(describe("Bluetooth ATT notifications", bluetooth_times)) + print(describe("evdev motion frames", evtest_times)) + print(describe("libinput unaccelerated motion", libinput_times)) + print(describe("Hyprland plugin reports", plugin_times)) + print() + print(align("plugin", plugin, "evdev", evtest)) + print(align("evdev", evtest, "libinput", libinput)) + + +if __name__ == "__main__": + main() diff --git a/lab/bluetooth-radio.sh b/lab/bluetooth-radio.sh new file mode 100755 index 0000000..241052d --- /dev/null +++ b/lab/bluetooth-radio.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +TEMPLATE="${SCRIPT_DIR}/host/bluetooth-usb.xml.in" +RUNTIME_DIR=/run/windows-pointer-lab +WINDOWS_NAME=wplab-windows +LINUX_NAME=wplab-linux + +usage() { + echo "usage: sudo $0 [USB_PATH]" + echo + echo "USB_PATH is a stable sysfs name such as 1-12." + echo "Without USB_PATH, attach chooses the only idle Bluetooth USB adapter." + echo "An adapter carrying a connected host device is never selected." +} + +require_root() { + if [[ ${EUID} -ne 0 ]]; then + echo "run this command with sudo" >&2 + exit 1 + fi +} + +hci_usb_path() { + local hci=$1 current + current=$(readlink -f "/sys/class/bluetooth/${hci}/device") + while [[ ${current} != / ]]; do + if [[ -f "${current}/idVendor" && -f "${current}/idProduct" && + -f "${current}/busnum" && -f "${current}/devnum" ]]; then + basename "${current}" + return + fi + current=$(dirname "${current}") + done + return 1 +} + +hci_for_usb() { + local requested hci mapped + requested=$(basename "$1") + for hci in /sys/class/bluetooth/hci*; do + [[ -e "${hci}" ]] || continue + [[ $(basename "${hci}") =~ ^hci[0-9]+$ ]] || continue + mapped=$(hci_usb_path "$(basename "${hci}")") || continue + if [[ ${mapped} == "${requested}" ]]; then + basename "${hci}" + return + fi + done + return 1 +} + +hci_has_connection() { + local hci=$1 object value + while IFS= read -r object; do + [[ ${object} == "/org/bluez/${hci}/dev_"* ]] || continue + value=$( + busctl get-property \ + org.bluez "${object}" org.bluez.Device1 Connected \ + 2>/dev/null || true + ) + [[ ${value} == "b true" ]] && return 0 + done < <(busctl tree --list org.bluez 2>/dev/null || true) + return 1 +} + +bluetooth_usb_paths() { + local hci + for hci in /sys/class/bluetooth/hci*; do + [[ -e "${hci}" ]] || continue + [[ $(basename "${hci}") =~ ^hci[0-9]+$ ]] || continue + hci_usb_path "$(basename "${hci}")" + done | sort -u +} + +describe_path() { + local name=$1 path hci vendor product bus device safety description + path="/sys/bus/usb/devices/${name}" + [[ -d "${path}" ]] || return 1 + hci=$(hci_for_usb "${path}") || return 1 + vendor=$(<"${path}/idVendor") + product=$(<"${path}/idProduct") + bus=$(<"${path}/busnum") + device=$(<"${path}/devnum") + safety=idle + hci_has_connection "${hci}" && safety=HOST-IN-USE + description=$(lsusb -s "${bus}:${device}" | sed -E 's/^Bus [^ ]+ Device [^ ]+: ID [^ ]+ //') + printf '%-8s %s:%s %-5s %-11s %s\n' \ + "${name}" "${vendor}" "${product}" "${hci}" "${safety}" "${description}" +} + +select_idle_path() { + local requested=${1:-} name hci + if [[ -n "${requested}" ]]; then + [[ -d "/sys/bus/usb/devices/${requested}" ]] || { + echo "Bluetooth USB path does not exist: ${requested}" >&2 + exit 1 + } + hci=$(hci_for_usb "/sys/bus/usb/devices/${requested}") || { + echo "${requested} is not a Bluetooth USB adapter" >&2 + exit 1 + } + if hci_has_connection "${hci}"; then + echo "refusing ${requested}: ${hci} carries a connected host device" >&2 + echo "attach a dedicated USB Bluetooth adapter instead" >&2 + exit 1 + fi + echo "${requested}" + return + fi + + local -a idle=() + while IFS= read -r name; do + hci=$(hci_for_usb "/sys/bus/usb/devices/${name}") + hci_has_connection "${hci}" || idle+=("${name}") + done < <(bluetooth_usb_paths) + if [[ ${#idle[@]} -ne 1 ]]; then + echo "expected exactly one idle Bluetooth USB adapter; found ${#idle[@]}" >&2 + echo "connect a dedicated adapter or pass its USB_PATH explicitly" >&2 + exit 1 + fi + echo "${idle[0]}" +} + +domain_running() { + [[ $(virsh --connect qemu:///system domstate "$1" 2>/dev/null) == running ]] +} + +detach_all() { + local state domain + shopt -s nullglob + for state in "${RUNTIME_DIR}"/bluetooth-radio-*.xml; do + domain=${state##*/bluetooth-radio-} + domain=${domain%.xml} + if domain_running "${domain}"; then + virsh --connect qemu:///system detach-device \ + "${domain}" "${state}" --live || true + fi + rm -f -- "${state}" + done + shopt -u nullglob + udevadm settle + echo "dedicated Bluetooth USB passthrough detached" +} + +attach_radio() { + local domain=$1 requested=${2:-} name path hci vendor product bus device state + domain_running "${domain}" || { + echo "${domain} must be running" >&2 + exit 1 + } + detach_all >/dev/null + name=$(select_idle_path "${requested}") + path="/sys/bus/usb/devices/${name}" + hci=$(hci_for_usb "${path}") + vendor=$(<"${path}/idVendor") + product=$(<"${path}/idProduct") + bus=$(<"${path}/busnum") + device=$(<"${path}/devnum") + install -d -m 0755 "${RUNTIME_DIR}" + state="${RUNTIME_DIR}/bluetooth-radio-${domain}.xml" + sed \ + -e "s/@VENDOR@/${vendor}/g" \ + -e "s/@PRODUCT@/${product}/g" \ + -e "s/@BUS@/${bus}/g" \ + -e "s/@DEVICE@/${device}/g" \ + "${TEMPLATE}" >"${state}" + virsh --connect qemu:///system attach-device "${domain}" "${state}" --live + echo "attached only Bluetooth USB ${name} (${vendor}:${product}, ${hci}) to ${domain}" + echo "host keyboard, non-Bluetooth mice, webcam, and other USB devices remain attached" +} + +status() { + local found=false name + printf '%-8s %-9s %-5s %-11s %s\n' \ + PATH USB_ID HCI SAFETY DESCRIPTION + while IFS= read -r name; do + found=true + describe_path "${name}" + done < <(bluetooth_usb_paths) + ${found} || echo "no host Bluetooth USB adapters found" + shopt -s nullglob + local state + for state in "${RUNTIME_DIR}"/bluetooth-radio-*.xml; do + echo "active_passthrough=${state##*/bluetooth-radio-}" + done + shopt -u nullglob +} + +require_root +case ${1:-} in + status) + status + ;; + attach-windows) + attach_radio "${WINDOWS_NAME}" "${2:-}" + ;; + attach-linux) + attach_radio "${LINUX_NAME}" "${2:-}" + ;; + detach) + detach_all + ;; + *) + usage + exit 2 + ;; +esac diff --git a/lab/capture-physical-linux.sh b/lab/capture-physical-linux.sh new file mode 100755 index 0000000..4c06d8c --- /dev/null +++ b/lab/capture-physical-linux.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) +DURATION=${1:-12} +RUN_ID=$(date -u +%Y%m%dT%H%M%SZ) +OUTPUT=${2:-"${REPO_ROOT}/build/lab/physical-linux-${RUN_ID}"} +PLUGIN="${REPO_ROOT}/build/dev/windows-pointer-linux.so" + +if ! [[ "${DURATION}" =~ ^[0-9]+$ ]] || ((DURATION < 1)); then + echo "duration must be a positive integer" >&2 + exit 2 +fi +if [[ ! -f "${PLUGIN}" ]]; then + echo "missing plugin build: ${PLUGIN}" >&2 + exit 1 +fi + +mouse_event="" +for candidate in /dev/input/event*; do + properties=$(udevadm info -q property "${candidate}" 2>/dev/null || true) + if rg -q '^ID_BUS=bluetooth$' <<<"${properties}" && + rg -q '^ID_INPUT_MOUSE=1$' <<<"${properties}"; then + mouse_event=${candidate} + break + fi +done +if [[ -z "${mouse_event}" ]]; then + echo "no Bluetooth mouse input device found" >&2 + exit 1 +fi +bluetooth_address=$( + bluetoothctl devices Connected | + awk '/Pebble M350s/ {print $2; exit}' +) + +install -d "${OUTPUT}" +plugin_was_loaded=false +plugin_loaded_here=false +capture_pids=() + +if hyprctl plugin list | rg -q 'windows-pointer-linux'; then + plugin_was_loaded=true +else + hyprctl plugin load "${PLUGIN}" + plugin_loaded_here=true +fi + +cleanup() { + for process in "${capture_pids[@]}"; do + kill -INT "${process}" >/dev/null 2>&1 || true + done + hyprctl windows-pointer-linux trace stop >/dev/null 2>&1 || true + hyprctl windows-pointer-linux trace dump >"${OUTPUT}/plugin.csv" 2>/dev/null || + true + if ${plugin_loaded_here}; then + hyprctl plugin unload "${PLUGIN}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +{ + echo "duration_seconds=${DURATION}" + echo "input_device=${mouse_event}" + echo "plugin_previously_loaded=${plugin_was_loaded}" + echo "kernel=$(uname -r)" + if [[ -n "${bluetooth_address}" ]]; then + bluetoothctl info "${bluetooth_address}" + fi + sudo libinput list-devices | + sed -n '/Device:.*Pebble M350s/,/^$/p' +} >"${OUTPUT}/metadata.txt" + +hyprctl windows-pointer-linux reset +hyprctl windows-pointer-linux trace start + +sudo timeout --signal=INT --kill-after=2 "${DURATION}" \ + btmon -w "${OUTPUT}/bluetooth.btsnoop" \ + >"${OUTPUT}/btmon.stdout" 2>"${OUTPUT}/btmon.stderr" & +capture_pids+=("$!") + +sudo timeout --signal=INT --kill-after=2 "${DURATION}" \ + evtest "${mouse_event}" \ + >"${OUTPUT}/evtest.txt" 2>&1 & +capture_pids+=("$!") + +sudo timeout --signal=INT --kill-after=2 "${DURATION}" \ + stdbuf -oL libinput debug-events \ + --device "${mouse_event}" \ + --verbose \ + >"${OUTPUT}/libinput.txt" 2>&1 & +capture_pids+=("$!") + +echo "physical Linux capture armed for ${DURATION} seconds" +for process in "${capture_pids[@]}"; do + wait "${process}" || true +done +capture_pids=() + +hyprctl windows-pointer-linux trace stop +hyprctl windows-pointer-linux trace dump >"${OUTPUT}/plugin.csv" +btmon -r "${OUTPUT}/bluetooth.btsnoop" >"${OUTPUT}/bluetooth.txt" + +if ${plugin_loaded_here}; then + hyprctl plugin unload "${PLUGIN}" + plugin_loaded_here=false +fi +trap - EXIT + +echo "physical Linux capture: ${OUTPUT}" diff --git a/lab/compare-captures.py b/lab/compare-captures.py new file mode 100755 index 0000000..4c6b415 --- /dev/null +++ b/lab/compare-captures.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import pathlib +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Sample: + report: int + raw_x: int + raw_y: int + output_x: int + output_y: int + + +def read_capture(path: pathlib.Path) -> list[Sample]: + with path.open(newline="", encoding="utf-8") as stream: + rows = ( + line + for line in stream + if line.strip() and not line.startswith("#") + ) + reader = csv.DictReader(rows) + required = {"report", "raw_x", "raw_y", "output_x", "output_y"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise ValueError( + f"{path}: expected columns {','.join(sorted(required))}" + ) + samples = [ + Sample( + report=int(row["report"]), + raw_x=int(row["raw_x"]), + raw_y=int(row["raw_y"]), + output_x=int(row["output_x"]), + output_y=int(row["output_y"]), + ) + for row in reader + ] + for expected, sample in enumerate(samples): + if sample.report != expected: + raise ValueError( + f"{path}: expected report {expected}, got {sample.report}" + ) + return samples + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("left", type=pathlib.Path) + parser.add_argument("right", type=pathlib.Path) + arguments = parser.parse_args() + + left = read_capture(arguments.left) + right = read_capture(arguments.right) + if len(left) != len(right): + print( + f"report-count mismatch: {arguments.left} has {len(left)}, " + f"{arguments.right} has {len(right)}" + ) + return 1 + + left_position = [0, 0] + right_position = [0, 0] + mismatches = 0 + for left_sample, right_sample in zip(left, right, strict=True): + left_position[0] += left_sample.output_x + left_position[1] += left_sample.output_y + right_position[0] += right_sample.output_x + right_position[1] += right_sample.output_y + if left_sample != right_sample: + if mismatches < 10: + print( + f"report {left_sample.report}: " + f"left raw=({left_sample.raw_x},{left_sample.raw_y}) " + f"out=({left_sample.output_x},{left_sample.output_y}); " + f"right raw=({right_sample.raw_x},{right_sample.raw_y}) " + f"out=({right_sample.output_x},{right_sample.output_y})" + ) + mismatches += 1 + + if mismatches: + print( + f"{mismatches} mismatches; final positions " + f"left=({left_position[0]},{left_position[1]}) " + f"right=({right_position[0]},{right_position[1]})" + ) + return 1 + + print( + f"exact match: {len(left)} reports, final position " + f"({left_position[0]},{left_position[1]})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab/guest/guest-hyprctl.sh b/lab/guest/guest-hyprctl.sh new file mode 100755 index 0000000..fc6c5ba --- /dev/null +++ b/lab/guest/guest-hyprctl.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} + +if [ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then + HYPRLAND_INSTANCE_SIGNATURE=$( + hyprctl instances -j | + jq -er 'max_by(.time) | .instance' + ) + export HYPRLAND_INSTANCE_SIGNATURE +fi + +exec hyprctl "$@" diff --git a/lab/guest/linux-meta-data.yaml b/lab/guest/linux-meta-data.yaml new file mode 100644 index 0000000..5c7612e --- /dev/null +++ b/lab/guest/linux-meta-data.yaml @@ -0,0 +1,2 @@ +instance-id: windows-pointer-lab-linux-01 +local-hostname: wplab-linux diff --git a/lab/guest/linux-user-data.yaml.in b/lab/guest/linux-user-data.yaml.in new file mode 100644 index 0000000..15e83d7 --- /dev/null +++ b/lab/guest/linux-user-data.yaml.in @@ -0,0 +1,100 @@ +#cloud-config +hostname: wplab-linux +manage_etc_hosts: true +ssh_pwauth: false +disable_root: true + +users: + - name: lab + gecos: Pointer Lab + groups: + - adm + - audio + - input + - render + - seat + - sudo + - video + lock_passwd: true + shell: /bin/bash + ssh_authorized_keys: + - @SSH_PUBLIC_KEY@ + +package_update: true +package_upgrade: false +packages: + - bluez + - cmake + - dbus-user-session + - g++ + - git + - greetd + - hyprland + - hyprland-dev + - jq + - libinput-tools + - mesa-utils + - ninja-build + - openssh-server + - pkg-config + - qemu-guest-agent + - seatd + - sudo + - tshark + - usbutils + - xwayland + +write_files: + - path: /usr/sbin/policy-rc.d + permissions: "0755" + content: | + #!/bin/sh + exit 101 + + - path: /etc/greetd/config.toml + permissions: "0644" + content: | + [terminal] + vt = 7 + + [default_session] + command = "Hyprland --config /home/lab/.config/hypr/hyprland.conf" + user = "lab" + + - path: /etc/sudoers.d/pointer-lab + permissions: "0440" + content: | + lab ALL=(ALL) NOPASSWD: ALL + + - path: /home/lab/.config/hypr/hyprland.conf + permissions: "0644" + content: | + monitor = ,preferred,auto,1 + + input { + kb_layout = us + accel_profile = flat + sensitivity = 0 + } + + cursor { + no_hardware_cursors = true + } + + misc { + disable_hyprland_logo = true + disable_splash_rendering = true + } + + bind = SUPER, Q, exec, foot + +runcmd: + - [rm, -f, /usr/sbin/policy-rc.d] + - [systemctl, enable, --now, qemu-guest-agent.service] + - [systemctl, enable, ssh.service] + - [systemctl, enable, greetd.service] + - [systemctl, set-default, graphical.target] + - [chown, -R, "lab:lab", /home/lab] + - [systemctl, start, greetd.service] + +final_message: "windows-pointer-lab Linux guest ready" diff --git a/lab/guest/windows/Autounattend.xml b/lab/guest/windows/Autounattend.xml new file mode 100644 index 0000000..d47f992 --- /dev/null +++ b/lab/guest/windows/Autounattend.xml @@ -0,0 +1,179 @@ + + + + + + en-US + + en-US + en-US + en-US + en-US + + + + + 0 + true + + + 1 + EFI + 260 + + + 2 + MSR + 16 + + + 3 + Primary + true + + + + + 1 + 1 + FAT32 + + + + 2 + 3 + NTFS + + C + + + + OnError + + + false + OnError + + + + + + /IMAGE/INDEX + 1 + + + + 0 + 3 + + OnError + + + + true + Pointer Lab + windows-pointer-linux + + + + + + + true + + + WPLAB-WIN + W. Europe Standard Time + + + + + + en-US + en-US + en-US + en-US + + + + true + 1 + lab + + PointerLab1! + true</PlainText> + </Password> + </AutoLogon> + <OOBE> + <HideEULAPage>true</HideEULAPage> + <HideLocalAccountScreen>true</HideLocalAccountScreen> + <HideOnlineAccountScreens>true</HideOnlineAccountScreens> + <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE> + <ProtectYourPC>3</ProtectYourPC> + </OOBE> + <UserAccounts> + <LocalAccounts> + <LocalAccount wcm:action="add"> + <Description>Disposable pointer laboratory account</Description> + <DisplayName>Pointer Lab</DisplayName> + <Group>Administrators</Group> + <Name>lab</Name> + <Password> + <Value>PointerLab1!</Value> + <PlainText>true</PlainText> + </Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <Description>Configure pointer laboratory</Description> + <CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$v = Get-Volume -FileSystemLabel 'WPLAB_TOOLS'; &amp; ($v.DriveLetter + ':\Setup-PointerLab.ps1')"</CommandLine> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> +</unattend> diff --git a/lab/guest/windows/BluetoothScan.cs b/lab/guest/windows/BluetoothScan.cs new file mode 100644 index 0000000..a3e3a06 --- /dev/null +++ b/lab/guest/windows/BluetoothScan.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Windows.Devices.Bluetooth.Advertisement; + +internal sealed class SeenDevice +{ + internal ulong Address; + internal short Rssi; + internal string Name = ""; + internal int Reports; + internal string Manufacturers = ""; + internal string Services = ""; +} + +internal static class BluetoothScan +{ + private static readonly object Sync = new object(); + private static readonly Dictionary<ulong, SeenDevice> Devices = + new Dictionary<ulong, SeenDevice>(); + + private static void Received( + BluetoothLEAdvertisementWatcher sender, + BluetoothLEAdvertisementReceivedEventArgs args) + { + lock (Sync) + { + SeenDevice device; + if (!Devices.TryGetValue(args.BluetoothAddress, out device)) + { + device = new SeenDevice { Address = args.BluetoothAddress }; + Devices.Add(args.BluetoothAddress, device); + } + + device.Rssi = args.RawSignalStrengthInDBm; + device.Reports++; + if (!String.IsNullOrEmpty(args.Advertisement.LocalName)) + device.Name = args.Advertisement.LocalName; + device.Manufacturers = String.Join( + ",", + args.Advertisement.ManufacturerData + .Select(value => value.CompanyId.ToString("X4"))); + device.Services = String.Join( + ",", + args.Advertisement.ServiceUuids.Select(value => value.ToString())); + } + } + + public static int Main(string[] arguments) + { + int seconds = 12; + if (arguments.Length > 0 && !Int32.TryParse(arguments[0], out seconds)) + return 2; + + var watcher = new BluetoothLEAdvertisementWatcher(); + watcher.ScanningMode = BluetoothLEScanningMode.Active; + watcher.Received += Received; + watcher.Start(); + Thread.Sleep(seconds * 1000); + watcher.Stop(); + Thread.Sleep(500); + watcher.Received -= Received; + + lock (Sync) + { + foreach (SeenDevice device in Devices.Values + .OrderByDescending(value => value.Rssi)) + { + Console.WriteLine( + "{0:X12}\t{1}\t{2}\t{3}\t{4}\t{5}", + device.Address, + device.Rssi, + device.Reports, + device.Name, + device.Manufacturers, + device.Services); + } + } + return 0; + } +} diff --git a/lab/guest/windows/Console.ps1 b/lab/guest/windows/Console.ps1 new file mode 100644 index 0000000..e13e237 --- /dev/null +++ b/lab/guest/windows/Console.ps1 @@ -0,0 +1,8 @@ +$Host.UI.RawUI.WindowTitle = "Pointer Lab - ready for automated capture" +Set-Location C:\pointer-lab + +Write-Host "" +Write-Host "Pointer Lab is ready." -ForegroundColor Cyan +Write-Host "The Debian host controls automated captures; this window must remain open." +Write-Host "Closing it is harmless: it reopens automatically at the next sign-in." +Write-Host "" diff --git a/lab/guest/windows/Open-Bluetooth.ps1 b/lab/guest/windows/Open-Bluetooth.ps1 new file mode 100644 index 0000000..551ff1e --- /dev/null +++ b/lab/guest/windows/Open-Bluetooth.ps1 @@ -0,0 +1,2 @@ +Start-Service -Name bthserv -ErrorAction SilentlyContinue +Start-Process 'ms-settings:bluetooth' diff --git a/lab/guest/windows/Repair-PointerLab.ps1 b/lab/guest/windows/Repair-PointerLab.ps1 new file mode 100644 index 0000000..3ae7a1e --- /dev/null +++ b/lab/guest/windows/Repair-PointerLab.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = "Stop" +$destination = "C:\pointer-lab" + +& icacls.exe $destination /grant "lab:(OI)(CI)F" /T | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "icacls exited with code $LASTEXITCODE" +} + +$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" +Set-ItemProperty $winlogon -Name AutoAdminLogon -Value "1" +Set-ItemProperty $winlogon -Name ForceAutoLogon -Value "1" +Set-ItemProperty $winlogon -Name DefaultUserName -Value "lab" +Set-ItemProperty $winlogon -Name DefaultPassword -Value "PointerLab1!" +Remove-ItemProperty $winlogon -Name AutoLogonCount -ErrorAction SilentlyContinue + +$startup = [Environment]::GetFolderPath("Startup") +$shell = New-Object -ComObject WScript.Shell +$shortcut = $shell.CreateShortcut("${startup}\Pointer Lab Console.lnk") +$shortcut.TargetPath = "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" +$shortcut.Arguments = "-NoExit -ExecutionPolicy Bypass -File C:\pointer-lab\Console.ps1" +$shortcut.WorkingDirectory = $destination +$shortcut.Save() +New-Item ` + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` + -Force | Out-Null +Set-ItemProperty ` + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` + -Name PointerLabConsole ` + -Value 'powershell.exe -NoExit -ExecutionPolicy Bypass -File C:\pointer-lab\Console.ps1' + +Enable-PSRemoting -SkipNetworkProfileCheck -Force +Set-Service WinRM -StartupType Automatic +Start-Service WinRM +Set-Content "${destination}\repair-complete.txt" "ready" diff --git a/lab/guest/windows/Run-Capture.ps1 b/lab/guest/windows/Run-Capture.ps1 new file mode 100644 index 0000000..eaef77b --- /dev/null +++ b/lab/guest/windows/Run-Capture.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = "Stop" + +$root = "C:\pointer-lab" +$capture = "${root}\capture" +$launcherLog = "${root}\launcher.log" +$duration = 30 +Set-Content $launcherLog "capture launcher started $(Get-Date -Format o)" + +try { + if (Test-Path "${root}\duration.txt") { + $requested = [int](Get-Content "${root}\duration.txt" -TotalCount 1) + if ($requested -ge 1 -and $requested -le 3600) { + $duration = $requested + } + } + + Remove-Item "${root}\ready.txt" -Force -ErrorAction SilentlyContinue + Remove-Item "${root}\complete.txt" -Force -ErrorAction SilentlyContinue + Remove-Item $capture -Recurse -Force -ErrorAction SilentlyContinue + + & "${root}\windows-pointer-windows-capture.exe" ` + --output $capture ` + --ready-file "${root}\ready.txt" ` + --seconds $duration + + if ($LASTEXITCODE -ne 0) { + throw "capture exited with code $LASTEXITCODE" + } + Set-Content "${root}\complete.txt" "complete" + Add-Content $launcherLog "capture completed $(Get-Date -Format o)" +} catch { + Add-Content $launcherLog ($_ | Format-List * -Force | Out-String) + throw +} diff --git a/lab/guest/windows/Setup-PointerLab.ps1 b/lab/guest/windows/Setup-PointerLab.ps1 new file mode 100644 index 0000000..e9d5a9b --- /dev/null +++ b/lab/guest/windows/Setup-PointerLab.ps1 @@ -0,0 +1,93 @@ +$ErrorActionPreference = "Stop" + +$toolsVolume = Get-Volume -FileSystemLabel "WPLAB_TOOLS" +$source = "$($toolsVolume.DriveLetter):\" +$destination = "C:\pointer-lab" +New-Item -ItemType Directory -Force -Path $destination | Out-Null +Copy-Item "${source}windows-pointer-windows-capture.exe" $destination -Force +Copy-Item "${source}Run-Capture.ps1" $destination -Force +Copy-Item "${source}Console.ps1" $destination -Force +Copy-Item "${source}Repair-PointerLab.ps1" $destination -Force +& icacls.exe $destination /grant "lab:(OI)(CI)F" /T | Out-Null + +Set-ItemProperty "HKCU:\Control Panel\Mouse" -Name MouseSensitivity -Value "10" +Set-ItemProperty "HKCU:\Control Panel\Mouse" -Name MouseSpeed -Value "1" +Set-ItemProperty "HKCU:\Control Panel\Mouse" -Name MouseThreshold1 -Value "6" +Set-ItemProperty "HKCU:\Control Panel\Mouse" -Name MouseThreshold2 -Value "10" + +Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class PointerLabSystemParameters { + [DllImport("user32.dll", EntryPoint = "SystemParametersInfoW", SetLastError = true)] + public static extern bool SetMouse( + uint action, uint parameter, int[] values, uint flags); + [DllImport("user32.dll", EntryPoint = "SystemParametersInfoW", SetLastError = true)] + public static extern bool SetSpeed( + uint action, uint parameter, IntPtr value, uint flags); +} +"@ +$mouse = [int[]](6, 10, 1) +if (-not [PointerLabSystemParameters]::SetMouse(0x0004, 0, $mouse, 3)) { + throw "SPI_SETMOUSE failed" +} +if (-not [PointerLabSystemParameters]::SetSpeed(0x0071, 0, [IntPtr]10, 3)) { + throw "SPI_SETMOUSESPEED failed" +} + +powercfg.exe /change monitor-timeout-ac 0 +powercfg.exe /change standby-timeout-ac 0 + +New-Item ` + "HKLM:\SYSTEM\CurrentControlSet\Control\BitLocker" ` + -Force | Out-Null +Set-ItemProperty ` + "HKLM:\SYSTEM\CurrentControlSet\Control\BitLocker" ` + -Name PreventDeviceEncryption ` + -Type DWord ` + -Value 1 + +$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" +Set-ItemProperty $winlogon -Name AutoAdminLogon -Value "1" +Set-ItemProperty $winlogon -Name ForceAutoLogon -Value "1" +Set-ItemProperty $winlogon -Name DefaultUserName -Value "lab" +Set-ItemProperty $winlogon -Name DefaultPassword -Value "PointerLab1!" +Remove-ItemProperty $winlogon -Name AutoLogonCount -ErrorAction SilentlyContinue + +$startup = [Environment]::GetFolderPath("Startup") +$shell = New-Object -ComObject WScript.Shell +$shortcut = $shell.CreateShortcut("${startup}\Pointer Lab Console.lnk") +$shortcut.TargetPath = "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" +$shortcut.Arguments = "-NoExit -ExecutionPolicy Bypass -File C:\pointer-lab\Console.ps1" +$shortcut.WorkingDirectory = $destination +$shortcut.Save() +New-Item ` + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` + -Force | Out-Null +Set-ItemProperty ` + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` + -Name PointerLabConsole ` + -Value 'powershell.exe -NoExit -ExecutionPolicy Bypass -File C:\pointer-lab\Console.ps1' + +@" +Pointer Lab + +This disposable VM is controlled by the Debian host. +The visible Pointer Lab PowerShell window receives automated capture commands. +No host USB controller is required for deterministic tests. +"@ | Set-Content "${destination}\README.txt" + +Enable-PSRemoting -SkipNetworkProfileCheck -Force +Set-Service WinRM -StartupType Automatic +Remove-NetFirewallRule ` + -DisplayName "Pointer Lab WinRM" ` + -ErrorAction SilentlyContinue +New-NetFirewallRule ` + -DisplayName "Pointer Lab WinRM" ` + -Direction Inbound ` + -Action Allow ` + -Protocol TCP ` + -LocalPort 5985 ` + -Profile Any | Out-Null + +Set-Content "${destination}\setup-complete.txt" "ready" diff --git a/lab/host/bluetooth-usb.xml.in b/lab/host/bluetooth-usb.xml.in new file mode 100644 index 0000000..7eb5d34 --- /dev/null +++ b/lab/host/bluetooth-usb.xml.in @@ -0,0 +1,7 @@ +<hostdev mode='subsystem' type='usb' managed='yes'> + <source> + <vendor id='0x@VENDOR@'/> + <product id='0x@PRODUCT@'/> + <address bus='@BUS@' device='@DEVICE@'/> + </source> +</hostdev> diff --git a/lab/pointer-lab.sh b/lab/pointer-lab.sh new file mode 100755 index 0000000..15eecbe --- /dev/null +++ b/lab/pointer-lab.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) +LAB_ROOT=${WINDOWS_POINTER_LAB_ROOT:-/usr/local/var/lib/windows-pointer-lab} +WINDOWS_NAME=wplab-windows +LINUX_NAME=wplab-linux + +usage() { + echo "usage: $0 <start|test|status|stop|screenshot> [arguments]" + echo + echo " start start and prepare both friendly VMs" + echo " test [TRACE] run the fully automated dual-VM test" + echo " status show VM, synthetic HID, and Bluetooth safety state" + echo " stop clean up synthetic devices and stop both VMs" + echo " screenshot VM [FILE] capture windows or linux without opening a viewer" +} + +guest_ip() { + virsh --connect qemu:///system domifaddr "$1" --source lease 2>/dev/null | + awk '/ipv4/ {sub("/.*", "", $4); print $4; exit}' +} + +wait_for_windows_network() { + local address + for _ in $(seq 1 90); do + address=$(guest_ip "${WINDOWS_NAME}") + if [[ -n "${address}" ]] && + nc -z -w 1 "${address}" 5985 >/dev/null 2>&1; then + return + fi + sleep 1 + done + return 1 +} + +prepare_windows() { + local address + if ! wait_for_windows_network; then + "${SCRIPT_DIR}/windows-login.sh" + fi + "${SCRIPT_DIR}/vm-lab.sh" wait-windows + address=$("${SCRIPT_DIR}/vm-lab.sh" windows-ip) + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${address}" put \ + "${SCRIPT_DIR}/guest/windows/Console.ps1" \ + 'C:\pointer-lab\Console.ps1' + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${address}" put \ + "${SCRIPT_DIR}/guest/windows/Repair-PointerLab.ps1" \ + 'C:\pointer-lab\Repair-PointerLab.ps1' + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${address}" run \ + "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\pointer-lab\Repair-PointerLab.ps1" +} + +start_lab() { + python3 "${SCRIPT_DIR}/preflight.py" + "${SCRIPT_DIR}/vm-lab.sh" start + "${SCRIPT_DIR}/vm-lab.sh" wait-linux + prepare_windows + "${SCRIPT_DIR}/vm-lab.sh" sync-linux + "${SCRIPT_DIR}/vm-lab.sh" sync-windows + echo + echo "Pointer Lab ready:" + echo " Linux: $(guest_ip "${LINUX_NAME}")" + echo " Windows: $(guest_ip "${WINDOWS_NAME}")" + echo " Safe automated test: ${SCRIPT_DIR}/pointer-lab.sh test" +} + +run_test() { + local trace=${1:-} + cmake --build "${REPO_ROOT}/build/dev" + cmake --build "${REPO_ROOT}/build/windows-capture" + start_lab + if [[ -n "${trace}" ]]; then + "${SCRIPT_DIR}/run-digital-test.sh" "${trace}" + else + "${SCRIPT_DIR}/run-digital-test.sh" + fi +} + +show_status() { + python3 "${SCRIPT_DIR}/preflight.py" || true + echo + "${SCRIPT_DIR}/vm-lab.sh" status + echo + sudo "${SCRIPT_DIR}/bluetooth-radio.sh" status + echo + if [[ -L /sys/bus/pci/devices/0000:00:14.0/driver ]]; then + echo "host_usb_controller_driver=$(basename "$(readlink -f /sys/bus/pci/devices/0000:00:14.0/driver)")" + fi +} + +stop_lab() { + sudo "${SCRIPT_DIR}/bluetooth-radio.sh" detach || true + "${SCRIPT_DIR}/vm-lab.sh" detach-digital || true + "${SCRIPT_DIR}/vm-lab.sh" stop +} + +take_screenshot() { + local requested=${1:-} domain output endpoint + case ${requested} in + windows) domain=${WINDOWS_NAME} ;; + linux) domain=${LINUX_NAME} ;; + *) + echo "screenshot VM must be windows or linux" >&2 + exit 2 + ;; + esac + output=${2:-"${REPO_ROOT}/build/lab/${requested}-screen.png"} + install -d "$(dirname "${output}")" + endpoint=$("${SCRIPT_DIR}/vm-lab.sh" vnc "${domain}") + vncdo -t 10 -s "${endpoint}" capture "${output}" + echo "${output}" +} + +case ${1:-} in + start) + start_lab + ;; + test) + run_test "${2:-}" + ;; + status) + show_status + ;; + stop) + stop_lab + ;; + screenshot) + take_screenshot "${2:-}" "${3:-}" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/lab/preflight.py b/lab/preflight.py new file mode 100755 index 0000000..8b798b6 --- /dev/null +++ b/lab/preflight.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import platform +import shutil +import subprocess +from typing import Any + + +REQUIRED_COMMANDS = ( + "cloud-localds", + "qemu-system-x86_64", + "qemu-img", + "virsh", + "virt-install", + "swtpm", + "tshark", + "vncdo", + "wimlib-imagex", + "x86_64-w64-mingw32-g++", + "xorriso", +) + +LAB_ROOT = pathlib.Path( + os.environ.get( + "WINDOWS_POINTER_LAB_ROOT", + "/usr/local/var/lib/windows-pointer-lab", + ) +) +PROVISIONED_DISKS = ( + LAB_ROOT / "vms/wplab-linux.qcow2", + LAB_ROOT / "vms/wplab-windows.qcow2", +) + + +def read_text(path: str) -> str: + try: + return pathlib.Path(path).read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return "" + + +def command_output(*command: str) -> str: + try: + return subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ).stdout.strip() + except OSError: + return "" + + +def cpu_flags() -> set[str]: + for line in read_text("/proc/cpuinfo").splitlines(): + if line.startswith("flags"): + _, _, values = line.partition(":") + return set(values.split()) + return set() + + +def module_enabled(name: str) -> bool: + config = read_text(f"/boot/config-{platform.release()}") + return f"CONFIG_{name}=m" in config or f"CONFIG_{name}=y" in config + + +def disk_free(path: str) -> int: + try: + return shutil.disk_usage(path).free + except OSError: + return 0 + + +def memory_available() -> int: + for line in read_text("/proc/meminfo").splitlines(): + if line.startswith("MemAvailable:"): + return int(line.split()[1]) * 1024 + return 0 + + +def memory_total() -> int: + for line in read_text("/proc/meminfo").splitlines(): + if line.startswith("MemTotal:"): + return int(line.split()[1]) * 1024 + return 0 + + +def collect() -> dict[str, Any]: + flags = cpu_flags() + commands = {name: shutil.which(name) for name in REQUIRED_COMMANDS} + udcs = sorted(pathlib.Path("/sys/class/udc").glob("*")) + bluetooth_adapters = sorted( + path + for path in pathlib.Path("/sys/class/bluetooth").glob("hci*") + if path.name.removeprefix("hci").isdigit() + ) + usb = command_output("lsusb") + guests_provisioned = all(path.exists() for path in PROVISIONED_DISKS) + required_free_disk = (1 if guests_provisioned else 45) * 1024**3 + + checks = { + "vmx": "vmx" in flags, + "kvm_device": pathlib.Path("/dev/kvm").exists(), + "kvm_access": os.access("/dev/kvm", os.R_OK | os.W_OK), + "two_dummy_udcs": ( + len(udcs) >= 2 or module_enabled("USB_DUMMY_HCD") + ), + "gadget_modules": all( + module_enabled(name) + for name in ("USB_DUMMY_HCD", "USB_LIBCOMPOSITE", "USB_F_HID") + ), + "commands": all(commands.values()), + "winrm_module": bool( + command_output( + "python3", + "-c", + "import winrm; print('ok')", + ) + ), + "memory": memory_total() >= 15 * 1024**3, + "disk": disk_free("/usr/local") >= required_free_disk, + } + + return { + "checks": checks, + "ready": { + "digital_vm": all( + checks[name] + for name in ( + "vmx", + "kvm_device", + "kvm_access", + "two_dummy_udcs", + "gadget_modules", + "commands", + "winrm_module", + "memory", + "disk", + ) + ), + "dedicated_bluetooth_usb": len(bluetooth_adapters) >= 2, + }, + "host": { + "kernel": platform.release(), + "memory_total_bytes": memory_total(), + "memory_available_bytes": memory_available(), + "usr_local_free_bytes": disk_free("/usr/local"), + "required_free_disk_bytes": required_free_disk, + "guests_provisioned": guests_provisioned, + "dummy_udcs": [path.name for path in udcs], + "bluetooth_adapters": [path.name for path in bluetooth_adapters], + "commands": commands, + "usb": usb.splitlines(), + }, + } + + +def human(result: dict[str, Any]) -> None: + labels = { + "vmx": "Intel VMX exposed", + "kvm_device": "/dev/kvm exists", + "kvm_access": "/dev/kvm is accessible", + "two_dummy_udcs": "two dummy USB controllers can be created", + "gadget_modules": "USB gadget kernel support exists", + "commands": "host commands are installed", + "winrm_module": "Python WinRM support is installed", + "memory": "at least 15 GiB physical memory is installed", + "disk": ( + "at least 1 GiB is free for the provisioned lab" + if result["host"]["guests_provisioned"] + else "at least 45 GiB is free to provision the lab" + ), + } + for name, passed in result["checks"].items(): + print(f"{'ok' if passed else 'BLOCKED':7} {labels[name]}") + print() + print( + "digital VM test:", + "ready" if result["ready"]["digital_vm"] else "blocked", + ) + print( + "dedicated Bluetooth hardware test:", + ( + "ready" + if result["ready"]["dedicated_bluetooth_usb"] + else "needs a second USB Bluetooth adapter" + ), + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--json", action="store_true") + arguments = parser.parse_args() + result = collect() + if arguments.json: + print(json.dumps(result, indent=2)) + else: + human(result) + return 0 if result["ready"]["digital_vm"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab/replay-hid.py b/lab/replay-hid.py new file mode 100755 index 0000000..aa77a85 --- /dev/null +++ b/lab/replay-hid.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import os +import pathlib +import struct +import threading +import time +from dataclasses import dataclass +from typing import TextIO + + +@dataclass(frozen=True) +class Report: + x: int + y: int + buttons: int = 0 + + def encode(self) -> bytes: + return struct.pack("<Bhh", self.buttons, self.x, self.y) + + +def parse_number(value: str, name: str, minimum: int, maximum: int) -> int: + try: + number = int(value, 10) + except ValueError as error: + raise ValueError(f"{name} must be an integer") from error + if not minimum <= number <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return number + + +def read_trace(stream: TextIO) -> list[Report]: + reports: list[Report] = [] + for line_number, raw_line in enumerate(stream, 1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + fields = [field.strip() for field in line.split(",")] + if len(fields) not in (2, 3): + raise ValueError( + f"line {line_number}: expected x,y or x,y,buttons" + ) + reports.append( + Report( + parse_number(fields[0], "x", -32768, 32767), + parse_number(fields[1], "y", -32768, 32767), + parse_number(fields[2], "buttons", 0, 255) + if len(fields) == 3 + else 0, + ) + ) + if not reports: + raise ValueError("the trace contains no reports") + return reports + + +def devices_from_state(path: pathlib.Path) -> list[pathlib.Path]: + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition("=") + if separator: + values[key] = value + devices = [ + pathlib.Path(values[name]) + for name in ("WINDOWS_HIDG", "LINUX_HIDG") + if name in values + ] + if len(devices) != 2: + raise ValueError(f"{path} does not name both HID gadget endpoints") + return devices + + +class Fanout: + def __init__(self, devices: list[pathlib.Path]) -> None: + self._paths = devices + self._files = [ + os.open(path, os.O_WRONLY | os.O_CLOEXEC) for path in devices + ] + self._start = threading.Barrier(len(devices) + 1) + self._finish = threading.Barrier(len(devices) + 1) + self._report = b"" + self._stop = False + self._times = [(0, 0) for _ in devices] + self._errors: list[BaseException] = [] + self._threads = [ + threading.Thread( + target=self._worker, + args=(index,), + name=f"hid-writer-{index}", + daemon=True, + ) + for index in range(len(devices)) + ] + for thread in self._threads: + thread.start() + + def close(self) -> None: + self._stop = True + try: + self._start.wait(timeout=2) + except threading.BrokenBarrierError: + pass + for thread in self._threads: + thread.join(timeout=2) + for descriptor in self._files: + os.close(descriptor) + + def write(self, report: Report) -> list[tuple[int, int]]: + if self._errors: + raise RuntimeError("HID writer failed") from self._errors[0] + self._report = report.encode() + try: + self._start.wait() + self._finish.wait() + except threading.BrokenBarrierError as error: + if self._errors: + raise RuntimeError("HID writer failed") from self._errors[0] + raise RuntimeError("HID writer synchronization failed") from error + if self._errors: + raise RuntimeError("HID writer failed") from self._errors[0] + return list(self._times) + + def _worker(self, index: int) -> None: + try: + while True: + self._start.wait() + if self._stop: + return + started = time.monotonic_ns() + written = os.write(self._files[index], self._report) + ended = time.monotonic_ns() + if written != len(self._report): + raise OSError( + f"short HID write to {self._paths[index]}: {written}" + ) + self._times[index] = (started, ended) + self._finish.wait() + except BaseException as error: + self._errors.append(error) + self._start.abort() + self._finish.abort() + + +def wait_until(deadline_ns: int) -> None: + while True: + remaining = deadline_ns - time.monotonic_ns() + if remaining <= 0: + return + if remaining > 200_000: + time.sleep((remaining - 100_000) / 1_000_000_000) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("trace", type=pathlib.Path) + parser.add_argument("--device", action="append", type=pathlib.Path, default=[]) + parser.add_argument("--from-state", type=pathlib.Path) + parser.add_argument("--interval-ms", type=float, default=20.0) + parser.add_argument("--output", type=pathlib.Path, required=True) + arguments = parser.parse_args() + + devices = list(arguments.device) + if arguments.from_state: + devices.extend(devices_from_state(arguments.from_state)) + if len(devices) < 1: + parser.error("provide --device or --from-state") + if arguments.interval_ms <= 0: + parser.error("--interval-ms must be positive") + + with arguments.trace.open(encoding="utf-8") as stream: + reports = read_trace(stream) + + arguments.output.parent.mkdir(parents=True, exist_ok=True) + interval_ns = round(arguments.interval_ms * 1_000_000) + columns = [ + "report", + "scheduled_ns", + "raw_x", + "raw_y", + "buttons", + ] + for index in range(len(devices)): + columns.extend((f"device_{index}_start_ns", f"device_{index}_end_ns")) + + fanout = Fanout(devices) + try: + origin = time.monotonic_ns() + 100_000_000 + with arguments.output.open("w", newline="", encoding="utf-8") as output: + writer = csv.writer(output) + writer.writerow(columns) + for index, report in enumerate(reports): + scheduled = origin + index * interval_ns + wait_until(scheduled) + times = fanout.write(report) + row: list[int] = [ + index, + scheduled, + report.x, + report.y, + report.buttons, + ] + for started, ended in times: + row.extend((started, ended)) + writer.writerow(row) + finally: + fanout.close() + + print( + f"replayed {len(reports)} reports to {len(devices)} devices; " + f"host timing: {arguments.output}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab/run-digital-test.sh b/lab/run-digital-test.sh new file mode 100755 index 0000000..3ae1854 --- /dev/null +++ b/lab/run-digital-test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=${WINDOWS_POINTER_LAB_ROOT:-/usr/local/var/lib/windows-pointer-lab} +SSH_KEY="${LAB_ROOT}/state/ssh/id_ed25519" +LINUX_NAME=wplab-linux +WINDOWS_NAME=wplab-windows +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) +TRACE=${1:-"${REPO_ROOT}/tests/traces/discord-call.csv"} +INTERVAL_MS=${INTERVAL_MS:-20} +RUN_ID=$(date -u +%Y%m%dT%H%M%SZ) +OUTPUT="${REPO_ROOT}/build/lab/dual-vm-${RUN_ID}" +report_count=$( + awk 'NF && $0 !~ /^#/ {count++} END {print count+0}' "${TRACE}" +) +capture_seconds=$( + awk -v reports="${report_count}" -v interval="${INTERVAL_MS}" \ + 'BEGIN {print int((reports * interval + 999) / 1000) + 3}' +) + +guest_ip() { + virsh --connect qemu:///system domifaddr "$1" --source lease | + awk '/ipv4/ {sub("/.*", "", $4); print $4; exit}' +} + +ssh_linux() { + local address=$1 + shift + ssh -i "${SSH_KEY}" \ + -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "lab@${address}" "$@" +} + +windows_run() { + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${windows_address}" run "$1" +} + +windows_wait_file() { + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${windows_address}" \ + wait-file "$1" --timeout 120 +} + +linux_address=$(guest_ip "${LINUX_NAME}") +windows_address=$(guest_ip "${WINDOWS_NAME}") +if [[ -z "${linux_address}" || -z "${windows_address}" ]]; then + echo "both guests must have DHCP addresses" >&2 + exit 1 +fi + +install -d "${OUTPUT}/windows" +windows_vnc=$("${SCRIPT_DIR}/vm-lab.sh" vnc "${WINDOWS_NAME}") + +linux_hyprctl="~/pointer-lab/guest-hyprctl.sh" +plugin="~/pointer-lab/windows-pointer-linux.so" +linux_plugin_loaded=false +digital_attached=false + +cleanup() { + if ${linux_plugin_loaded}; then + ssh_linux "${linux_address}" \ + "${linux_hyprctl} windows-pointer-linux trace stop >/dev/null 2>&1 || true; + ${linux_hyprctl} plugin unload ${plugin} >/dev/null 2>&1 || true" \ + || true + fi + if ${digital_attached}; then + "${SCRIPT_DIR}/vm-lab.sh" detach-digital >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +"${SCRIPT_DIR}/vm-lab.sh" attach-digital +digital_attached=true + +ssh_linux "${linux_address}" \ + "${linux_hyprctl} plugin load ${plugin}; + ${linux_hyprctl} windows-pointer-linux trace start" +linux_plugin_loaded=true + +windows_run \ + "Remove-Item 'C:\\pointer-lab\\capture' -Recurse -Force -ErrorAction SilentlyContinue; + Remove-Item 'C:\\pointer-lab\\ready.txt' -Force -ErrorAction SilentlyContinue" +virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTMETA KEY_R +sleep 1 +vncdo -t 10 --delay=1 -s "${windows_vnc}" \ + type powershell \ + key enter \ + pause 4 \ + type "cd /pointer-lab" \ + key enter \ + pause 1 \ + type "./windows-pointer-windows-capture.exe --output capture --ready-file ready.txt --seconds ${capture_seconds}" \ + key enter +windows_wait_file "C:\pointer-lab\ready.txt" + +sudo python3 "${SCRIPT_DIR}/replay-hid.py" \ + --from-state /run/windows-pointer-lab/gadgets.env \ + --interval-ms "${INTERVAL_MS}" \ + --output "${OUTPUT}/host-replay.csv" \ + "${TRACE}" + +ssh_linux "${linux_address}" \ + "${linux_hyprctl} windows-pointer-linux trace stop" +ssh_linux "${linux_address}" \ + "${linux_hyprctl} windows-pointer-linux trace dump" \ + >"${OUTPUT}/linux.csv" + +windows_wait_file "C:\pointer-lab\capture\metadata.txt" +install -d "${OUTPUT}/windows/capture" +for file in metadata.txt raw.csv pointer.csv; do + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${windows_address}" get \ + "C:\\pointer-lab\\capture\\${file}" \ + "${OUTPUT}/windows/capture/${file}" +done + +python3 "${SCRIPT_DIR}/windows-capture-to-common.py" \ + "${OUTPUT}/windows/capture" "${OUTPUT}/windows.csv" +set +e +python3 "${SCRIPT_DIR}/compare-captures.py" \ + "${OUTPUT}/windows.csv" "${OUTPUT}/linux.csv" +comparison_status=$? +set -e +python3 "${SCRIPT_DIR}/analyze-capture-timing.py" \ + "${OUTPUT}/host-replay.csv" \ + "${OUTPUT}/windows.csv" \ + "${OUTPUT}/linux.csv" + +ssh_linux "${linux_address}" \ + "${linux_hyprctl} plugin unload ${plugin}" +linux_plugin_loaded=false +"${SCRIPT_DIR}/vm-lab.sh" detach-digital +digital_attached=false +trap - EXIT + +echo "dual-VM capture: ${OUTPUT}" +exit "${comparison_status}" diff --git a/lab/traces/full-scenarios.csv b/lab/traces/full-scenarios.csv new file mode 100644 index 0000000..e091b36 --- /dev/null +++ b/lab/traces/full-scenarios.csv @@ -0,0 +1,32 @@ +# Discord target acquisition, drag-and-drop, and game-scale reversals in one +# state-continuous Windows/Linux session. +18,6 +9,3 +4,1 +2,0 +1,0 +0,1 +-1,0 +2,-1 +1,0 +2,1 +3,1 +6,2 +12,4 +18,6 +24,8 +20,7 +10,3 +5,2 +2,1 +1,0 +22,-4 +35,12 +-28,18 +45,-20 +-42,-8 +8,30 +-15,-36 +51,5 +-3,2 +30,-24 diff --git a/lab/unsafe/full-xhci-controller.sh b/lab/unsafe/full-xhci-controller.sh new file mode 100755 index 0000000..fdf87ac --- /dev/null +++ b/lab/unsafe/full-xhci-controller.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +LAB_DIR=$(cd -- "${SCRIPT_DIR}/.." && pwd) +XHCI_XML="${SCRIPT_DIR}/xhci-hostdev.xml" +PCI_ADDRESS=0000:00:14.0 +RECOVERY_UNIT=windows-pointer-lab-controller-recovery +RECOVERY_MINUTES=20 +DOMAINS=(wplab-windows wplab-linux) + +usage() { + echo "usage: sudo $0 <attach-windows|attach-linux|restore-host|status>" + echo + echo "UNSAFE: this takes every USB device on the host xHCI controller." + echo "Use lab/bluetooth-radio.sh with a dedicated USB adapter instead." +} + +require_root() { + if [[ ${EUID} -ne 0 ]]; then + echo "run this command with sudo" >&2 + exit 1 + fi +} + +domain_running() { + [[ $(virsh --connect qemu:///system domstate "$1" 2>/dev/null) == running ]] +} + +controller_in_domain() { + local domain=$1 + virsh --connect qemu:///system dumpxml "${domain}" 2>/dev/null | + xmllint --xpath \ + "boolean(/domain/devices/hostdev[@type='pci']/source/address[@domain='0x0000' and @bus='0x00' and @slot='0x14' and @function='0x0'])" \ + - 2>/dev/null | + rg -q true +} + +arm_recovery() { + systemctl stop "${RECOVERY_UNIT}.timer" >/dev/null 2>&1 || true + systemctl reset-failed "${RECOVERY_UNIT}.service" \ + "${RECOVERY_UNIT}.timer" >/dev/null 2>&1 || true + systemd-run \ + --quiet \ + --unit="${RECOVERY_UNIT}" \ + --on-active="${RECOVERY_MINUTES}m" \ + --timer-property=AccuracySec=1s \ + --property=Type=oneshot \ + "$(realpath "$0")" restore-host --from-timer +} + +cancel_recovery() { + systemctl stop "${RECOVERY_UNIT}.timer" >/dev/null 2>&1 || true +} + +verify_isolation() { + local group_path device_count + group_path=$(readlink -f "/sys/bus/pci/devices/${PCI_ADDRESS}/iommu_group") + device_count=$(find "${group_path}/devices" -mindepth 1 -maxdepth 1 | wc -l) + if [[ ${device_count} -ne 1 || + ! -e "${group_path}/devices/${PCI_ADDRESS}" ]]; then + echo "${PCI_ADDRESS} is not isolated in its IOMMU group" >&2 + find "${group_path}/devices" -mindepth 1 -maxdepth 1 -printf '%f\n' >&2 + exit 1 + fi +} + +restore_host() { + local from_timer=${1:-} + for domain in "${DOMAINS[@]}"; do + if domain_running "${domain}" && controller_in_domain "${domain}"; then + virsh --connect qemu:///system detach-device \ + "${domain}" "${XHCI_XML}" --live || true + fi + done + modprobe xhci_pci + modprobe btusb + systemctl start bluetooth.service + if [[ ${from_timer} != --from-timer ]]; then + cancel_recovery + fi + udevadm settle + echo "physical xHCI controller restored to the host" +} + +attach_controller() { + local domain=$1 + verify_isolation + if ! domain_running "${domain}"; then + echo "${domain} is not running" >&2 + exit 1 + fi + for candidate in "${DOMAINS[@]}"; do + if domain_running "${candidate}" && controller_in_domain "${candidate}"; then + if [[ ${candidate} == "${domain}" ]]; then + echo "${PCI_ADDRESS} is already attached to ${domain}" + arm_recovery + return + fi + echo "${PCI_ADDRESS} is already attached to ${candidate}" >&2 + exit 1 + fi + done + + arm_recovery + systemctl stop bluetooth.service + if ! virsh --connect qemu:///system attach-device \ + "${domain}" "${XHCI_XML}" --live; then + restore_host + exit 1 + fi + echo "physical xHCI controller attached to ${domain}" + echo "automatic host restoration armed for ${RECOVERY_MINUTES} minutes" +} + +status() { + local owner=host + for domain in "${DOMAINS[@]}"; do + if domain_running "${domain}" && controller_in_domain "${domain}"; then + owner=${domain} + fi + done + echo "controller_owner=${owner}" + echo "host_driver=$(basename "$(readlink -f "/sys/bus/pci/devices/${PCI_ADDRESS}/driver" 2>/dev/null || echo none)")" + if systemctl is-active --quiet "${RECOVERY_UNIT}.timer"; then + systemctl show "${RECOVERY_UNIT}.timer" \ + --property=ActiveState \ + --property=NextElapseUSecRealtime + else + echo "recovery_timer=inactive" + fi +} + +require_root +if [[ ${1:-} == attach-windows || ${1:-} == attach-linux ]]; then + if [[ ${WINDOWS_POINTER_ALLOW_FULL_XHCI_TAKEOVER:-} != yes ]]; then + echo "refusing full host USB-controller takeover" >&2 + echo "this disconnects the host keyboard, mouse, webcam, and Bluetooth" >&2 + echo "use ${LAB_DIR}/bluetooth-radio.sh with a dedicated adapter" >&2 + echo "expert override: WINDOWS_POINTER_ALLOW_FULL_XHCI_TAKEOVER=yes" >&2 + exit 1 + fi +fi +case ${1:-} in + attach-windows) + attach_controller wplab-windows + ;; + attach-linux) + attach_controller wplab-linux + ;; + restore-host) + restore_host "${2:-}" + ;; + status) + status + ;; + *) + usage + exit 2 + ;; +esac diff --git a/lab/unsafe/grub-iommu.cfg b/lab/unsafe/grub-iommu.cfg new file mode 100644 index 0000000..b010e79 --- /dev/null +++ b/lab/unsafe/grub-iommu.cfg @@ -0,0 +1,2 @@ +# Required only for the unsafe full-controller research path. +GRUB_CMDLINE_LINUX_DEFAULT="$GRUB_CMDLINE_LINUX_DEFAULT intel_iommu=on iommu=pt" diff --git a/lab/unsafe/windows-pcie-pci-bridge.xml b/lab/unsafe/windows-pcie-pci-bridge.xml new file mode 100644 index 0000000..bc5f9d6 --- /dev/null +++ b/lab/unsafe/windows-pcie-pci-bridge.xml @@ -0,0 +1,4 @@ +<controller type='pci' index='15' model='pcie-to-pci-bridge'> + <model name='pcie-pci-bridge'/> + <address type='pci' domain='0x0000' bus='0x04' slot='0x00' function='0x0'/> +</controller> diff --git a/lab/unsafe/xhci-hostdev.xml b/lab/unsafe/xhci-hostdev.xml new file mode 100644 index 0000000..09118c9 --- /dev/null +++ b/lab/unsafe/xhci-hostdev.xml @@ -0,0 +1,6 @@ +<hostdev mode='subsystem' type='pci' managed='yes'> + <source> + <address domain='0x0000' bus='0x00' slot='0x14' function='0x0'/> + </source> + <address type='pci' domain='0x0000' bus='0x0f' slot='0x01' function='0x0'/> +</hostdev> diff --git a/lab/usb-gadgets.sh b/lab/usb-gadgets.sh new file mode 100755 index 0000000..4819003 --- /dev/null +++ b/lab/usb-gadgets.sh @@ -0,0 +1,194 @@ +#!/bin/sh +set -eu + +gadget_root=/sys/kernel/config/usb_gadget +windows_name=windows-pointer-lab-windows +linux_name=windows-pointer-lab-linux +state_dir=/run/windows-pointer-lab +state_file=$state_dir/gadgets.env + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + printf 'run this command as root\n' >&2 + exit 1 + fi +} + +write_report_descriptor() { + # Eight buttons followed by signed 16-bit relative X and Y. + printf '\005\001\011\002\241\001\011\001\241\000\005\011\031\001\051\010\025\000\045\001\225\010\165\001\201\002\005\001\011\060\011\061\026\000\200\046\377\177\165\020\225\002\201\006\300\300' +} + +new_hidg() { + before=$1 + attempts=0 + while [ "$attempts" -lt 40 ]; do + for node in /dev/hidg*; do + [ -e "$node" ] || continue + case " +$before +" in + *" +$node +"*) ;; + *) printf '%s\n' "$node"; return 0 ;; + esac + done + attempts=$((attempts + 1)) + sleep 0.05 + done + return 1 +} + +wait_for_usb_driver() { + product=$1 + attempts=0 + while [ "$attempts" -lt 80 ]; do + for device in /sys/bus/usb/devices/*; do + [ -f "$device/idVendor" ] || continue + [ -f "$device/idProduct" ] || continue + [ "$(cat "$device/idVendor")" = "1d6b" ] || continue + [ "$(cat "$device/idProduct")" = "$product" ] || continue + for interface in "$device":*; do + [ -L "$interface/driver" ] || continue + [ "$(basename "$(readlink -f "$interface/driver")")" = "usbhid" ] || continue + return 0 + done + done + attempts=$((attempts + 1)) + sleep 0.05 + done + return 1 +} + +create_gadget() { + name=$1 + product=$2 + serial=$3 + udc=$4 + gadget=$gadget_root/$name + + [ ! -e "$gadget" ] || { + printf 'gadget already exists: %s\n' "$gadget" >&2 + return 1 + } + + before=$(find /dev -maxdepth 1 -name 'hidg*' -print 2>/dev/null || true) + mkdir "$gadget" + printf '0x1d6b' > "$gadget/idVendor" + printf '%s' "$product" > "$gadget/idProduct" + printf '0x0200' > "$gadget/bcdUSB" + printf '0x0100' > "$gadget/bcdDevice" + + mkdir "$gadget/strings/0x409" + printf '%s' "$serial" > "$gadget/strings/0x409/serialnumber" + printf 'windows-pointer-linux' > "$gadget/strings/0x409/manufacturer" + printf 'pointer lab relative mouse' > "$gadget/strings/0x409/product" + + mkdir "$gadget/configs/c.1" + mkdir "$gadget/configs/c.1/strings/0x409" + printf 'deterministic HID replay' > "$gadget/configs/c.1/strings/0x409/configuration" + printf '120' > "$gadget/configs/c.1/MaxPower" + + mkdir "$gadget/functions/hid.usb0" + printf '2' > "$gadget/functions/hid.usb0/protocol" + printf '1' > "$gadget/functions/hid.usb0/subclass" + printf '5' > "$gadget/functions/hid.usb0/report_length" + write_report_descriptor > "$gadget/functions/hid.usb0/report_desc" + ln -s "$gadget/functions/hid.usb0" "$gadget/configs/c.1/hid.usb0" + printf '%s' "$udc" > "$gadget/UDC" + + node=$(new_hidg "$before") || { + printf 'no /dev/hidg endpoint appeared for %s\n' "$name" >&2 + return 1 + } + product_number=$(printf '%s' "$product" | sed 's/^0x//') + wait_for_usb_driver "$product_number" || { + printf 'USB HID driver did not bind to %s\n' "$name" >&2 + return 1 + } + chmod 0660 "$node" + if [ -n "${SUDO_GID:-}" ]; then + chown "root:$SUDO_GID" "$node" + fi + printf '%s\n' "$node" +} + +remove_gadget() { + name=$1 + gadget=$gadget_root/$name + [ -d "$gadget" ] || return 0 + + printf '' > "$gadget/UDC" + rm -f "$gadget/configs/c.1/hid.usb0" + rmdir "$gadget/functions/hid.usb0" + rmdir "$gadget/configs/c.1/strings/0x409" + rmdir "$gadget/configs/c.1" + rmdir "$gadget/strings/0x409" + rmdir "$gadget" +} + +setup() { + require_root + modprobe dummy_hcd num=2 + modprobe libcomposite + modprobe usb_f_hid + + udcs=$(find /sys/class/udc -maxdepth 1 -mindepth 1 -printf '%f\n' | sort) + windows_udc=$(printf '%s\n' "$udcs" | sed -n '1p') + linux_udc=$(printf '%s\n' "$udcs" | sed -n '2p') + [ -n "$windows_udc" ] && [ -n "$linux_udc" ] || { + printf 'two USB device controllers are required\n' >&2 + exit 1 + } + + windows_hidg=$(create_gadget "$windows_name" 0x1050 WPLAB-WINDOWS "$windows_udc") + linux_hidg=$(create_gadget "$linux_name" 0x1051 WPLAB-LINUX "$linux_udc") + + mkdir -p "$state_dir" + { + printf 'WINDOWS_HIDG=%s\n' "$windows_hidg" + printf 'WINDOWS_USB_ID=1d6b:1050\n' + printf 'WINDOWS_SERIAL=WPLAB-WINDOWS\n' + printf 'LINUX_HIDG=%s\n' "$linux_hidg" + printf 'LINUX_USB_ID=1d6b:1051\n' + printf 'LINUX_SERIAL=WPLAB-LINUX\n' + } > "$state_file" + chmod 0644 "$state_file" + status +} + +teardown() { + require_root + remove_gadget "$windows_name" + remove_gadget "$linux_name" + rm -f "$state_file" + rmdir "$state_dir" 2>/dev/null || true +} + +status() { + for name in "$windows_name" "$linux_name"; do + gadget=$gadget_root/$name + if [ -d "$gadget" ]; then + serial=$(cat "$gadget/strings/0x409/serialnumber") + product=$(cat "$gadget/idProduct") + udc=$(cat "$gadget/UDC") + printf '%s: serial=%s product=%s udc=%s\n' "$name" "$serial" "$product" "$udc" + else + printf '%s: absent\n' "$name" + fi + done + [ ! -f "$state_file" ] || cat "$state_file" + lsusb -d 1d6b:1050 2>/dev/null || true + lsusb -d 1d6b:1051 2>/dev/null || true +} + +case "${1:-}" in + setup) setup ;; + teardown) teardown ;; + status) status ;; + *) + printf 'usage: %s setup|status|teardown\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/lab/vm-lab.sh b/lab/vm-lab.sh new file mode 100755 index 0000000..82399dd --- /dev/null +++ b/lab/vm-lab.sh @@ -0,0 +1,520 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=${WINDOWS_POINTER_LAB_ROOT:-/usr/local/var/lib/windows-pointer-lab} +IMAGE_DIR="${LAB_ROOT}/images" +STATE_DIR="${LAB_ROOT}/state" +VM_DIR="${LAB_ROOT}/vms" +SSH_DIR="${STATE_DIR}/ssh" +LINUX_NAME=wplab-linux +WINDOWS_NAME=wplab-windows +LINUX_BASE="${IMAGE_DIR}/debian-14-generic-amd64-20260724.qcow2" +LINUX_DISK="${VM_DIR}/${LINUX_NAME}.qcow2" +LINUX_SEED="${VM_DIR}/${LINUX_NAME}-seed.iso" +SSH_KEY="${SSH_DIR}/id_ed25519" +WINDOWS_TOOLS_ISO="${VM_DIR}/windows-pointer-tools.iso" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) + +usage() { + echo "usage: $0 <command> [arguments]" + echo + echo "commands:" + echo " provision-linux create and start the Debian/Hyprland guest" + echo " wait-linux wait for cloud-init and SSH" + echo " sync-linux copy the plugin and trace tools into Linux" + echo " provision-windows ISO create and start a Windows 11 guest" + echo " start start both existing guests" + echo " stop gracefully stop both guests" + echo " wait-windows wait for Windows setup and WinRM" + echo " sync-windows copy current capture tools into Windows" + echo " attach-digital attach one synthetic HID mouse to each guest" + echo " detach-digital detach the synthetic mice" + echo " linux-ip print the Linux guest IPv4 address" + echo " windows-ip print the Windows guest IPv4 address" + echo " vnc DOMAIN print a vncdo-compatible endpoint" + echo " status show domains, addresses, and USB gadgets" +} + +ensure_state_dirs() { + local lab_user lab_group + lab_user=$(id -un) + lab_group=$(id -gn) + sudo install -d -m 0755 -o "${lab_user}" -g "${lab_group}" \ + "${STATE_DIR}" "${VM_DIR}" "${SSH_DIR}" +} + +domain_exists() { + virsh --connect qemu:///system dominfo "$1" >/dev/null 2>&1 +} + +domain_running() { + [[ $(virsh --connect qemu:///system domstate "$1" 2>/dev/null) == running ]] +} + +start_all() { + local domain + for domain in "${LINUX_NAME}" "${WINDOWS_NAME}"; do + if ! domain_exists "${domain}"; then + echo "missing VM: ${domain}" >&2 + exit 1 + fi + if domain_running "${domain}"; then + echo "${domain} already running" + else + virsh --connect qemu:///system start "${domain}" + fi + done +} + +stop_all() { + local domain deadline all_stopped + for domain in "${WINDOWS_NAME}" "${LINUX_NAME}"; do + if domain_running "${domain}"; then + virsh --connect qemu:///system shutdown "${domain}" + fi + done + deadline=$((SECONDS + 90)) + while ((SECONDS < deadline)); do + all_stopped=true + for domain in "${WINDOWS_NAME}" "${LINUX_NAME}"; do + domain_running "${domain}" && all_stopped=false + done + ${all_stopped} && { + echo "both pointer-lab VMs are stopped" + return + } + sleep 2 + done + echo "VM shutdown is still in progress; no forced power-off was used" >&2 + return 1 +} + +vnc_endpoint() { + local domain=$1 display host number + domain_running "${domain}" || { + echo "${domain} is not running" >&2 + exit 1 + } + display=$(virsh --connect qemu:///system vncdisplay "${domain}") + host=${display%:*} + number=${display##*:} + [[ ${number} =~ ^[0-9]+$ ]] || { + echo "unexpected VNC display: ${display}" >&2 + exit 1 + } + echo "${host}::$((5900 + number))" +} + +ensure_ssh_key() { + if [[ ! -f "${SSH_KEY}" ]]; then + ssh-keygen -q -t ed25519 -N "" -f "${SSH_KEY}" + fi +} + +linux_ip() { + local address + address=$( + virsh --connect qemu:///system domifaddr "${LINUX_NAME}" \ + --source lease 2>/dev/null | + awk '/ipv4/ {sub("/.*", "", $4); print $4; exit}' + ) + if [[ -z "${address}" ]]; then + return 1 + fi + echo "${address}" +} + +windows_ip() { + local address + address=$( + virsh --connect qemu:///system domifaddr "${WINDOWS_NAME}" \ + --source lease 2>/dev/null | + awk '/ipv4/ {sub("/.*", "", $4); print $4; exit}' + ) + if [[ -z "${address}" ]]; then + return 1 + fi + echo "${address}" +} + +render_linux_user_data() { + local public_key + public_key=$(<"${SSH_KEY}.pub") + sed "s|@SSH_PUBLIC_KEY@|${public_key}|" \ + "${SCRIPT_DIR}/guest/linux-user-data.yaml.in" \ + >"${STATE_DIR}/linux-user-data.yaml" +} + +provision_linux() { + ensure_state_dirs + ensure_ssh_key + if [[ ! -f "${LINUX_BASE}" ]]; then + echo "missing verified Debian image: ${LINUX_BASE}" >&2 + exit 1 + fi + if domain_exists "${LINUX_NAME}"; then + echo "${LINUX_NAME} already exists" + if ! domain_running "${LINUX_NAME}"; then + virsh --connect qemu:///system start "${LINUX_NAME}" + fi + return + fi + + render_linux_user_data + + qemu-img create -q -f qcow2 -F qcow2 -b "${LINUX_BASE}" \ + "${LINUX_DISK}" 24G + cloud-localds "${LINUX_SEED}" \ + "${STATE_DIR}/linux-user-data.yaml" \ + "${SCRIPT_DIR}/guest/linux-meta-data.yaml" + chmod 0644 "${LINUX_BASE}" + chmod 0660 "${LINUX_DISK}" "${LINUX_SEED}" + + virt-install --connect qemu:///system \ + --name "${LINUX_NAME}" \ + --memory 2560 \ + --vcpus 2,cpuset=3-4 \ + --cpu host-passthrough \ + --machine q35 \ + --import \ + --disk "path=${LINUX_DISK},format=qcow2,bus=virtio" \ + --disk "path=${LINUX_SEED},device=cdrom,readonly=on" \ + --network network=default,model=virtio \ + --controller usb,model=qemu-xhci \ + --channel unix,target.type=virtio,target.name=org.qemu.guest_agent.0 \ + --graphics vnc,listen=127.0.0.1 \ + --video virtio \ + --rng /dev/urandom \ + --boot uefi \ + --osinfo detect=on,require=off \ + --noautoconsole +} + +wait_linux() { + local address="" + ensure_state_dirs + render_linux_user_data + for _ in $(seq 1 120); do + address=$(linux_ip || true) + if [[ -n "${address}" ]] && + ssh -i "${SSH_KEY}" \ + -o BatchMode=yes \ + -o ConnectTimeout=3 \ + -o StrictHostKeyChecking=accept-new \ + "lab@${address}" \ + "test -f /var/lib/cloud/instance/boot-finished" \ + >/dev/null 2>&1; then + scp -q -i "${SSH_KEY}" \ + -o StrictHostKeyChecking=accept-new \ + "${STATE_DIR}/linux-user-data.yaml" \ + "lab@${address}:/tmp/pointer-lab-user-data.yaml" + ssh -i "${SSH_KEY}" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=accept-new \ + "lab@${address}" \ + "sudo cp /tmp/pointer-lab-user-data.yaml /var/lib/cloud/instance/cloud-config.txt && + sudo cloud-init single --name write-files --frequency always && + sudo chown -R lab:lab /home/lab && + sudo rm -f /usr/sbin/policy-rc.d && + sudo systemctl reset-failed greetd.service && + sudo systemctl enable --now qemu-guest-agent.service ssh.service greetd.service" + echo "${LINUX_NAME} is ready at ${address}" + return + fi + sleep 5 + done + echo "timed out waiting for ${LINUX_NAME}" >&2 + exit 1 +} + +sync_linux() { + local address plugin + address=$(linux_ip) + plugin="${REPO_ROOT}/build/dev/windows-pointer-linux.so" + if [[ ! -f "${plugin}" ]]; then + echo "missing development plugin build: ${plugin}" >&2 + exit 1 + fi + ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=accept-new \ + "lab@${address}" \ + "sudo chown -R lab:lab /home/lab && install -d ~/pointer-lab" + scp -i "${SSH_KEY}" -o StrictHostKeyChecking=accept-new \ + "${plugin}" \ + "${REPO_ROOT}/lab/compare-captures.py" \ + "${REPO_ROOT}/lab/guest/guest-hyprctl.sh" \ + "${REPO_ROOT}/tests/traces/discord-call.csv" \ + "lab@${address}:pointer-lab/" + echo "Linux tools copied to lab@${address}:pointer-lab/" +} + +sync_windows() { + local address gateway server_pid transfer_status=0 + local collector="${REPO_ROOT}/build/windows-capture/windows-pointer-windows-capture.exe" + ensure_state_dirs + address=$(windows_ip) + gateway=$( + virsh --connect qemu:///system net-dumpxml default | + sed -nE "s/.*<ip address='([^']+)'.*/\\1/p" | + head -1 + ) + [[ -f "${collector}" ]] || { + echo "missing Windows collector build: ${collector}" >&2 + exit 1 + } + [[ -n "${gateway}" ]] || { + echo "could not resolve the libvirt-private gateway" >&2 + exit 1 + } + + python3 -m http.server 18080 \ + --bind "${gateway}" \ + --directory "${REPO_ROOT}" \ + >"${STATE_DIR}/windows-sync-http.log" 2>&1 & + server_pid=$! + sleep 1 + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${address}" run \ + "\$source = 'http://${gateway}:18080/build/windows-capture/windows-pointer-windows-capture.exe'; + \$temporary = 'C:\\pointer-lab\\windows-pointer-windows-capture.exe.new'; + Invoke-WebRequest -UseBasicParsing -Uri \$source -OutFile \$temporary; + Move-Item \$temporary 'C:\\pointer-lab\\windows-pointer-windows-capture.exe' -Force" || + transfer_status=$? + kill "${server_pid}" >/dev/null 2>&1 || true + wait "${server_pid}" >/dev/null 2>&1 || true + ((transfer_status == 0)) || return "${transfer_status}" + + for file in Console.ps1 Repair-PointerLab.ps1 Run-Capture.ps1; do + python3 "${SCRIPT_DIR}/windows-remote.py" --host "${address}" put \ + "${SCRIPT_DIR}/guest/windows/${file}" \ + "C:\\pointer-lab\\${file}" + done + echo "Windows capture tools copied to ${address}:C:\\pointer-lab" +} + +make_windows_tools_iso() { + local collector="${REPO_ROOT}/build/windows-capture/windows-pointer-windows-capture.exe" + local staging="${STATE_DIR}/windows-tools" + if [[ ! -f "${collector}" ]]; then + echo "missing Windows collector build: ${collector}" >&2 + exit 1 + fi + install -d -m 0755 "${staging}" + install -m 0644 \ + "${SCRIPT_DIR}/guest/windows/Autounattend.xml" \ + "${staging}/Autounattend.xml" + install -m 0644 \ + "${SCRIPT_DIR}/guest/windows/Setup-PointerLab.ps1" \ + "${staging}/Setup-PointerLab.ps1" + install -m 0644 \ + "${SCRIPT_DIR}/guest/windows/Run-Capture.ps1" \ + "${staging}/Run-Capture.ps1" + install -m 0644 \ + "${SCRIPT_DIR}/guest/windows/Console.ps1" \ + "${staging}/Console.ps1" + install -m 0644 \ + "${SCRIPT_DIR}/guest/windows/Repair-PointerLab.ps1" \ + "${staging}/Repair-PointerLab.ps1" + install -m 0644 \ + "${collector}" \ + "${staging}/windows-pointer-windows-capture.exe" + xorriso -as mkisofs -quiet -J -R -V WPLAB_TOOLS \ + -o "${WINDOWS_TOOLS_ISO}" "${staging}" + chmod 0644 "${WINDOWS_TOOLS_ISO}" +} + +provision_windows() { + local iso=${1:-} + ensure_state_dirs + ensure_ssh_key + if [[ -z "${iso}" || ! -f "${iso}" ]]; then + echo "provision-windows requires a readable Windows 11 ISO path" >&2 + exit 1 + fi + if domain_exists "${WINDOWS_NAME}"; then + echo "${WINDOWS_NAME} already exists" + if ! domain_running "${WINDOWS_NAME}"; then + virsh --connect qemu:///system start "${WINDOWS_NAME}" + fi + return + fi + + local windows_disk="${VM_DIR}/${WINDOWS_NAME}.qcow2" + make_windows_tools_iso + qemu-img create -q -f qcow2 "${windows_disk}" 64G + chmod 0644 "${iso}" + chmod 0660 "${windows_disk}" + + virt-install --connect qemu:///system \ + --name "${WINDOWS_NAME}" \ + --memory 4096 \ + --vcpus 2,cpuset=1-2 \ + --cpu host-passthrough \ + --machine q35 \ + --disk "path=${windows_disk},format=qcow2,bus=sata" \ + --disk "path=${iso},device=cdrom,readonly=on" \ + --disk "path=${WINDOWS_TOOLS_ISO},device=cdrom,readonly=on" \ + --network network=default,model=e1000e \ + --controller usb,model=qemu-xhci \ + --graphics vnc,listen=127.0.0.1 \ + --video qxl \ + --tpm backend.type=emulator,backend.version=2.0,model=tpm-crb \ + --boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=yes,firmware.feature1.name=enrolled-keys,firmware.feature1.enabled=yes \ + --osinfo detect=on,require=off \ + --noautoconsole +} + +wait_windows() { + local address="" + for _ in $(seq 1 240); do + address=$(windows_ip || true) + if [[ -n "${address}" ]] && + python3 "${SCRIPT_DIR}/windows-remote.py" \ + --host "${address}" ping >/dev/null 2>&1; then + echo "${WINDOWS_NAME} is ready at ${address}" + return + fi + sleep 5 + done + echo "timed out waiting for ${WINDOWS_NAME}" >&2 + exit 1 +} + +usb_xml() { + local product=$1 + local path=$2 + sed "s/@PRODUCT@/${product}/" >"${path}" <<'EOF' +<hostdev mode='subsystem' type='usb' managed='yes'> + <source startupPolicy='optional'> + <vendor id='0x1d6b'/> + <product id='0x@PRODUCT@'/> + </source> +</hostdev> +EOF +} + +attach_one() { + local domain=$1 product=$2 xml=$3 + if ! domain_exists "${domain}"; then + echo "skipping absent domain ${domain}" + return + fi + if ! domain_running "${domain}"; then + echo "${domain} must be running before synthetic USB is attached" >&2 + exit 1 + fi + if grep -q "<product id='0x${product}'" \ + < <(virsh --connect qemu:///system dumpxml "${domain}"); then + echo "${domain} already has synthetic USB ${product}" + return + fi + virsh --connect qemu:///system attach-device "${domain}" "${xml}" --live +} + +detach_one() { + local domain=$1 xml=$2 product + if ! domain_exists "${domain}"; then + return + fi + product=$(sed -nE "s/.*<product id='0x([^']+)'.*/\\1/p" "${xml}") + if domain_running "${domain}" && [[ -n "${product}" ]] && + grep -q "<product id='0x${product}'" \ + < <(virsh --connect qemu:///system dumpxml "${domain}"); then + virsh --connect qemu:///system detach-device "${domain}" "${xml}" \ + --live || true + fi + if [[ -n "${product}" ]] && + grep -q "<product id='0x${product}'" \ + < <(virsh --connect qemu:///system dumpxml "${domain}" --inactive); then + virsh --connect qemu:///system detach-device "${domain}" "${xml}" \ + --config || true + fi +} + +attach_digital() { + ensure_state_dirs + if [[ ! -f /run/windows-pointer-lab/gadgets.env ]]; then + sudo "${SCRIPT_DIR}/usb-gadgets.sh" setup + fi + local windows_xml="${STATE_DIR}/windows-hid.xml" + local linux_xml="${STATE_DIR}/linux-hid.xml" + usb_xml 1050 "${windows_xml}" + usb_xml 1051 "${linux_xml}" + attach_one "${WINDOWS_NAME}" 1050 "${windows_xml}" + attach_one "${LINUX_NAME}" 1051 "${linux_xml}" +} + +detach_digital() { + local windows_xml="${STATE_DIR}/windows-hid.xml" + local linux_xml="${STATE_DIR}/linux-hid.xml" + [[ -f "${windows_xml}" ]] && + detach_one "${WINDOWS_NAME}" "${windows_xml}" + [[ -f "${linux_xml}" ]] && + detach_one "${LINUX_NAME}" "${linux_xml}" + sudo "${SCRIPT_DIR}/usb-gadgets.sh" teardown +} + +status() { + virsh --connect qemu:///system list --all + echo + for domain in "${LINUX_NAME}" "${WINDOWS_NAME}"; do + if domain_exists "${domain}"; then + if domain_running "${domain}"; then + virsh --connect qemu:///system domifaddr "${domain}" \ + --source lease || true + else + echo "${domain}: shut off" + fi + fi + done + echo + sudo "${SCRIPT_DIR}/usb-gadgets.sh" status +} + +case ${1:-} in + provision-linux) + provision_linux + ;; + wait-linux) + wait_linux + ;; + sync-linux) + sync_linux + ;; + sync-windows) + sync_windows + ;; + provision-windows) + provision_windows "${2:-}" + ;; + start) + start_all + ;; + stop) + stop_all + ;; + wait-windows) + wait_windows + ;; + attach-digital) + attach_digital + ;; + detach-digital) + detach_digital + ;; + linux-ip) + linux_ip + ;; + windows-ip) + windows_ip + ;; + vnc) + vnc_endpoint "${2:-}" + ;; + status) + status + ;; + *) + usage + exit 2 + ;; +esac diff --git a/lab/windows-capture-to-common.py b/lab/windows-capture-to-common.py new file mode 100755 index 0000000..1afc80e --- /dev/null +++ b/lab/windows-capture-to-common.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import pathlib + + +def read_metadata(path: pathlib.Path) -> dict[str, int]: + values: dict[str, int] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + key, separator, value = line.partition("=") + if not separator: + raise ValueError(f"{path}:{line_number}: expected key=value") + values[key] = int(value) + return values + + +def convert(capture: pathlib.Path, output: pathlib.Path) -> tuple[int, int, int]: + metadata = read_metadata(capture / "metadata.txt") + required_metadata = { + "initial_cursor_x", + "initial_cursor_y", + "pointer_speed", + "mouse_acceleration", + "system_dpi", + } + missing = required_metadata - metadata.keys() + if missing: + raise ValueError( + f"{capture / 'metadata.txt'}: missing {','.join(sorted(missing))}" + ) + expected_settings = { + "pointer_speed": 10, + "mouse_threshold_1": 6, + "mouse_threshold_2": 10, + "mouse_acceleration": 1, + "system_dpi": 96, + } + mismatched_settings = { + key: (metadata.get(key), expected) + for key, expected in expected_settings.items() + if metadata.get(key) != expected + } + if mismatched_settings: + details = ", ".join( + f"{key}={actual} (expected {expected})" + for key, (actual, expected) in mismatched_settings.items() + ) + raise ValueError(f"Windows reference settings mismatch: {details}") + + with (capture / "raw.csv").open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + required_columns = { + "report", + "time_ns", + "flags", + "raw_x", + "raw_y", + "cursor_x", + "cursor_y", + } + if reader.fieldnames is None or not required_columns.issubset( + reader.fieldnames + ): + raise ValueError( + f"{capture / 'raw.csv'}: missing required columns" + ) + rows = list(reader) + + with (capture / "pointer.csv").open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + required_columns = { + "event", + "time_ns", + "message", + "cursor_x", + "cursor_y", + "flags", + } + if reader.fieldnames is None or not required_columns.issubset( + reader.fieldnames + ): + raise ValueError( + f"{capture / 'pointer.csv'}: missing required columns" + ) + pointer_rows = [ + row for row in reader if int(row["message"]) == 0x0200 + ] + + if len(pointer_rows) != len(rows): + raise ValueError( + f"Windows event mismatch: {len(rows)} raw reports but " + f"{len(pointer_rows)} low-level mouse moves" + ) + + output.parent.mkdir(parents=True, exist_ok=True) + previous_x = metadata["initial_cursor_x"] + previous_y = metadata["initial_cursor_y"] + cumulative_x = 0 + cumulative_y = 0 + with output.open("w", newline="", encoding="utf-8") as stream: + writer = csv.writer(stream) + writer.writerow( + ( + "report", + "time_ns", + "raw_x", + "raw_y", + "output_x", + "output_y", + ) + ) + for expected_report, (row, pointer_row) in enumerate( + zip(rows, pointer_rows, strict=True) + ): + report = int(row["report"]) + if report != expected_report: + raise ValueError( + f"{capture / 'raw.csv'}: expected report " + f"{expected_report}, got {report}" + ) + flags = int(row["flags"]) + if flags & 0x01: + raise ValueError( + f"report {report} is absolute input; relative input required" + ) + cursor_x = int(pointer_row["cursor_x"]) + cursor_y = int(pointer_row["cursor_y"]) + output_x = cursor_x - previous_x + output_y = cursor_y - previous_y + writer.writerow( + ( + report, + int(row["time_ns"]), + int(row["raw_x"]), + int(row["raw_y"]), + output_x, + output_y, + ) + ) + previous_x = cursor_x + previous_y = cursor_y + cumulative_x += output_x + cumulative_y += output_y + + return len(rows), cumulative_x, cumulative_y + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("capture", type=pathlib.Path) + parser.add_argument("output", type=pathlib.Path) + arguments = parser.parse_args() + count, cumulative_x, cumulative_y = convert( + arguments.capture, arguments.output + ) + print( + f"converted {count} Windows reports; final position delta " + f"({cumulative_x},{cumulative_y})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab/windows-login.sh b/lab/windows-login.sh new file mode 100755 index 0000000..5d82e53 --- /dev/null +++ b/lab/windows-login.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=${WINDOWS_POINTER_LAB_ROOT:-/usr/local/var/lib/windows-pointer-lab} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +WINDOWS_NAME=wplab-windows +AUTOUNATTEND="${SCRIPT_DIR}/guest/windows/Autounattend.xml" + +windows_ip() { + virsh --connect qemu:///system domifaddr "${WINDOWS_NAME}" --source lease | + awk '/ipv4/ {sub("/.*", "", $4); print $4; exit}' +} + +windows_ready() { + local address + address=$(windows_ip 2>/dev/null || true) + [[ -n "${address}" ]] && + nc -z -w 1 "${address}" 5985 >/dev/null 2>&1 && + timeout 8 python3 "${SCRIPT_DIR}/windows-remote.py" \ + --host "${address}" ping >/dev/null 2>&1 +} + +send_character() { + local character=$1 upper + case ${character} in + [A-Z]) + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTSHIFT "KEY_${character}" >/dev/null + ;; + [a-z]) + upper=${character^^} + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" "KEY_${upper}" >/dev/null + ;; + [0-9]) + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" "KEY_${character}" >/dev/null + ;; + '!') + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTSHIFT KEY_1 >/dev/null + ;; + '@') + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTSHIFT KEY_2 >/dev/null + ;; + '#') + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTSHIFT KEY_3 >/dev/null + ;; + '$') + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_LEFTSHIFT KEY_4 >/dev/null + ;; + *) + echo "unsupported disposable lab-password character" >&2 + exit 1 + ;; + esac +} + +type_password() { + local password index + password=$( + xmllint --xpath \ + "string(//*[local-name()='LocalAccount']/*[local-name()='Password']/*[local-name()='Value'])" \ + "${AUTOUNATTEND}" + ) + [[ -n "${password}" ]] || { + echo "could not read the disposable lab password" >&2 + exit 1 + } + for ((index = 0; index < ${#password}; index++)); do + send_character "${password:index:1}" + done + virsh --connect qemu:///system send-key \ + "${WINDOWS_NAME}" KEY_ENTER >/dev/null +} + +[[ $(virsh --connect qemu:///system domstate "${WINDOWS_NAME}" 2>/dev/null) == running ]] || { + echo "${WINDOWS_NAME} is not running" >&2 + exit 1 +} +windows_ready && { + echo "Windows remote control is already ready" + exit 0 +} + +endpoint=$("${SCRIPT_DIR}/vm-lab.sh" vnc "${WINDOWS_NAME}") +echo "opening the disposable Windows lab sign-in" +vncdo -t 10 -s "${endpoint}" key enter pause 2 +type_password +for _ in $(seq 1 30); do + sleep 2 + windows_ready && { + echo "Windows interactive session is ready" + exit 0 + } +done + +# If Enter initially submitted an empty password, the first attempt only +# dismissed the resulting dialog. Type once more into the credential field. +echo "retrying the disposable Windows lab sign-in" +vncdo -t 10 -s "${endpoint}" key enter pause 1 +type_password +for _ in $(seq 1 30); do + sleep 2 + windows_ready && { + echo "Windows interactive session is ready" + exit 0 + } +done + +echo "Windows did not become remotely ready after automated sign-in" >&2 +exit 1 diff --git a/lab/windows-remote.py b/lab/windows-remote.py new file mode 100755 index 0000000..398a4bd --- /dev/null +++ b/lab/windows-remote.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import base64 +import pathlib +import sys +import time + +import winrm +from winrm.exceptions import WinRMError + + +DEFAULT_USER = "lab" +DEFAULT_PASSWORD = "PointerLab1!" +# pywinrm uses PowerShell's encoded-command form, whose Windows command-line +# limit is reached well before WinRM's envelope limit for larger chunks. +UPLOAD_CHUNK_BYTES = 1024 + + +def powershell_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def connect(host: str, user: str, password: str) -> winrm.Session: + return winrm.Session( + f"http://{host}:5985/wsman", + auth=(user, password), + transport="ntlm", + ) + + +def run(session: winrm.Session, script: str, quiet: bool = False) -> bytes: + result = session.run_ps(script) + if result.status_code != 0: + sys.stderr.buffer.write(result.std_out) + sys.stderr.buffer.write(result.std_err) + raise RuntimeError( + f"PowerShell exited with status {result.status_code}" + ) + if not quiet: + sys.stdout.buffer.write(result.std_out) + if result.std_err and not result.std_err.startswith(b"#< CLIXML"): + sys.stderr.buffer.write(result.std_err) + return result.std_out + + +def put_file( + session: winrm.Session, + local: pathlib.Path, + remote_path: str, +) -> None: + payload = local.read_bytes() + remote = powershell_quote(remote_path) + chunks = [ + payload[offset : offset + UPLOAD_CHUNK_BYTES] + for offset in range(0, len(payload), UPLOAD_CHUNK_BYTES) + ] or [b""] + + for index, chunk in enumerate(chunks): + encoded = base64.b64encode(chunk).decode("ascii") + mode = "Create" if index == 0 else "Append" + run( + session, + f"$data=[Convert]::FromBase64String('{encoded}');" + f"$stream=[IO.File]::Open({remote}," + f"[IO.FileMode]::{mode},[IO.FileAccess]::Write," + "[IO.FileShare]::None);" + "try{$stream.Write($data,0,$data.Length)}" + "finally{$stream.Dispose()}", + quiet=True, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--host", required=True) + parser.add_argument("--user", default=DEFAULT_USER) + parser.add_argument("--password", default=DEFAULT_PASSWORD) + subparsers = parser.add_subparsers(dest="command", required=True) + + run_parser = subparsers.add_parser("run") + run_parser.add_argument("script") + run_parser.add_argument("--quiet", action="store_true") + + get_parser = subparsers.add_parser("get") + get_parser.add_argument("remote") + get_parser.add_argument("local", type=pathlib.Path) + + put_parser = subparsers.add_parser("put") + put_parser.add_argument("local", type=pathlib.Path) + put_parser.add_argument("remote") + + wait_parser = subparsers.add_parser("wait-file") + wait_parser.add_argument("remote") + wait_parser.add_argument("--timeout", type=float, default=120.0) + + subparsers.add_parser("ping") + arguments = parser.parse_args() + session = connect(arguments.host, arguments.user, arguments.password) + + try: + if arguments.command == "run": + run(session, arguments.script, arguments.quiet) + elif arguments.command == "get": + remote = powershell_quote(arguments.remote) + encoded = run( + session, + "[Convert]::ToBase64String(" + f"[IO.File]::ReadAllBytes({remote}))", + quiet=True, + ) + arguments.local.parent.mkdir(parents=True, exist_ok=True) + arguments.local.write_bytes(base64.b64decode(encoded.strip())) + elif arguments.command == "put": + put_file(session, arguments.local, arguments.remote) + elif arguments.command == "wait-file": + remote = powershell_quote(arguments.remote) + deadline = time.monotonic() + arguments.timeout + while True: + result = session.run_ps( + f"if (Test-Path {remote}) {{ 'ready' }} else {{ exit 3 }}" + ) + if result.status_code == 0: + break + if time.monotonic() >= deadline: + raise TimeoutError( + f"timed out waiting for {arguments.remote}" + ) + time.sleep(0.25) + elif arguments.command == "ping": + run(session, "'ready'", quiet=True) + except ( + OSError, + RuntimeError, + TimeoutError, + ValueError, + WinRMError, + ) as error: + print(f"winrm: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/plugin.cpp b/src/plugin.cpp index bbde391..0ab7323 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -11,8 +11,10 @@ #include <hyprland/src/plugins/PluginAPI.hpp> #include <algorithm> +#include <chrono> #include <cmath> #include <cstdint> +#include <deque> #include <limits> #include <map> #include <optional> @@ -38,6 +40,19 @@ CHyprSignalListener g_configReloaded; Settings g_settings; Engine g_engine; +struct TraceEntry { + std::uint64_t report = 0; + std::int64_t timeNs = 0; + Motion raw; + Motion output; + std::uint16_t dpi = 96; +}; + +constexpr std::size_t TRACE_CAPACITY = 65536; +std::deque<TraceEntry> g_trace; +std::uint64_t g_traceDropped = 0; +bool g_traceEnabled = false; + struct DeviceStats { std::uint64_t processed = 0; Motion lastRaw; @@ -152,6 +167,22 @@ void onMouseMoved(CInputManager* inputManager, IPointer::SMotionEvent event) { event.delta = Vector2D{static_cast<double>(moved.x), static_cast<double>(moved.y)}; + if (g_traceEnabled) { + if (g_trace.size() == TRACE_CAPACITY) { + g_trace.pop_front(); + ++g_traceDropped; + } + g_trace.push_back({ + .report = g_stats.processed, + .timeNs = std::chrono::duration_cast<std::chrono::nanoseconds>( + std::chrono::steady_clock::now().time_since_epoch()) + .count(), + .raw = raw, + .output = moved, + .dpi = display.dpi, + }); + } + ++g_stats.processed; g_stats.lastRaw = raw; g_stats.lastOutput = moved; @@ -208,6 +239,11 @@ void onMouseMoved(CInputManager* inputManager, IPointer::SMotionEvent event) { << "\"version\":\"" << WINDOWS_POINTER_VERSION << "\"," << "\"pointer_speed\":\"" << static_cast<int>(g_settings.pointerSpeed) << "/20\"," << "\"enhance_pointer_precision\":" << epp << ',' + << "\"trace\":{" + << "\"enabled\":" << (g_traceEnabled ? "true" : "false") << ',' + << "\"samples\":" << g_trace.size() << ',' + << "\"capacity\":" << TRACE_CAPACITY << ',' + << "\"dropped\":" << g_traceDropped << "}," << "\"processed\":" << g_stats.processed << ',' << "\"passthrough\":{" << "\"touchpad\":" << g_stats.touchpads << ',' @@ -241,6 +277,8 @@ void onMouseMoved(CInputManager* inputManager, IPointer::SMotionEvent event) { } else { output << "windows-pointer-linux " << WINDOWS_POINTER_VERSION << '\n' << "settings: " << static_cast<int>(g_settings.pointerSpeed) << "/20, epp " << (g_settings.enhancePointerPrecision ? "on" : "off") << '\n' + << "trace: " << (g_traceEnabled ? "recording" : "stopped") << ", " << g_trace.size() << '/' << TRACE_CAPACITY << " samples, " + << g_traceDropped << " dropped\n" << "processed: " << g_stats.processed << '\n' << "passed through: " << g_stats.touchpads << " touchpad, " << g_stats.virtualPointers << " virtual, " << g_stats.otherPointers << " other, " << g_stats.invalidCoordinates << " invalid, " << g_stats.rotatedCoordinates << " rotated\n" @@ -251,6 +289,42 @@ void onMouseMoved(CInputManager* inputManager, IPointer::SMotionEvent event) { return output.str(); } +void resetDiagnostics() { + g_engine.reset(); + g_stats = {}; + g_trace = {}; + g_traceDropped = 0; +} + +[[nodiscard]] auto traceDump(eHyprCtlOutputFormat format) -> std::string { + std::ostringstream output; + if (format == FORMAT_JSON) { + output << "{\"enabled\":" << (g_traceEnabled ? "true" : "false") << ",\"dropped\":" << g_traceDropped << ",\"entries\":["; + bool first = true; + for (const auto& entry : g_trace) { + if (!first) + output << ','; + first = false; + output << '{' + << "\"report\":" << entry.report << ',' + << "\"time_ns\":" << entry.timeNs << ',' + << "\"raw_x\":" << entry.raw.x << ',' + << "\"raw_y\":" << entry.raw.y << ',' + << "\"output_x\":" << entry.output.x << ',' + << "\"output_y\":" << entry.output.y << ',' + << "\"dpi\":" << entry.dpi + << '}'; + } + output << "]}\n"; + } else { + output << "report,time_ns,raw_x,raw_y,output_x,output_y,dpi\n"; + for (const auto& entry : g_trace) + output << entry.report << ',' << entry.timeNs << ',' << entry.raw.x << ',' << entry.raw.y << ',' << entry.output.x << ',' << entry.output.y << ',' + << entry.dpi << '\n'; + } + return output.str(); +} + [[nodiscard]] auto hyprctl(eHyprCtlOutputFormat format, std::string request) -> std::string { constexpr std::string_view name = "windows-pointer-linux"; auto argument = std::string_view{request}.substr(name.size()); @@ -261,12 +335,34 @@ void onMouseMoved(CInputManager* inputManager, IPointer::SMotionEvent event) { return status(format); if (argument == "reset") { - g_engine.reset(); - g_stats = {}; + g_traceEnabled = false; + resetDiagnostics(); return format == FORMAT_JSON ? "{\"ok\":true}\n" : "ok\n"; } - return format == FORMAT_JSON ? "{\"error\":\"usage: windows-pointer-linux [status|reset]\"}\n" : "usage: windows-pointer-linux [status|reset]\n"; + if (argument == "trace start") { + resetDiagnostics(); + g_traceEnabled = true; + return format == FORMAT_JSON ? "{\"ok\":true,\"trace\":\"recording\"}\n" : "ok: trace recording\n"; + } + + if (argument == "trace stop") { + g_traceEnabled = false; + return format == FORMAT_JSON ? "{\"ok\":true,\"trace\":\"stopped\"}\n" : "ok: trace stopped\n"; + } + + if (argument == "trace clear") { + g_trace = {}; + g_traceDropped = 0; + return format == FORMAT_JSON ? "{\"ok\":true,\"trace\":\"cleared\"}\n" : "ok: trace cleared\n"; + } + + if (argument == "trace dump") + return traceDump(format); + + return format == FORMAT_JSON ? + "{\"error\":\"usage: windows-pointer-linux [status|reset|trace start|trace stop|trace clear|trace dump]\"}\n" : + "usage: windows-pointer-linux [status|reset|trace start|trace stop|trace clear|trace dump]\n"; } void registerConfig() { diff --git a/tools/windows-capture.cpp b/tools/windows-capture.cpp new file mode 100644 index 0000000..af46497 --- /dev/null +++ b/tools/windows-capture.cpp @@ -0,0 +1,279 @@ +#define WIN32_LEAN_AND_MEAN +#include <windows.h> + +#include <cstdint> +#include <filesystem> +#include <fstream> +#include <iostream> +#include <limits> +#include <stdexcept> +#include <string> +#include <string_view> +#include <vector> + +namespace { + +struct RawSample { + std::uint64_t sequence; + std::int64_t timeNs; + std::uintptr_t device; + std::uint16_t flags; + std::int32_t x; + std::int32_t y; + POINT cursor; +}; + +struct PointerSample { + std::uint64_t sequence; + std::int64_t timeNs; + std::uint32_t message; + POINT cursor; + std::uint32_t flags; +}; + +std::vector<RawSample> g_raw; +std::vector<PointerSample> g_pointer; +LARGE_INTEGER g_frequency; +LARGE_INTEGER g_started; +std::filesystem::path g_output; +std::filesystem::path g_ready; +HHOOK g_hook = nullptr; +POINT g_initialCursor{}; +int g_screenWidth = 0; +int g_screenHeight = 0; +bool g_cursorApiAvailable = false; + +[[noreturn]] void fail(const std::string& message) { + throw std::runtime_error(message + " (Windows error " + std::to_string(GetLastError()) + ")"); +} + +auto nowNs() -> std::int64_t { + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + const auto ticks = now.QuadPart - g_started.QuadPart; + const auto seconds = ticks / g_frequency.QuadPart; + const auto remainder = ticks % g_frequency.QuadPart; + return static_cast<std::int64_t>( + seconds * 1'000'000'000LL + (remainder * 1'000'000'000LL) / g_frequency.QuadPart); +} + +LRESULT CALLBACK lowLevelMouse(int code, WPARAM message, LPARAM data) { + if (code == HC_ACTION) { + const auto* event = reinterpret_cast<const MSLLHOOKSTRUCT*>(data); + g_pointer.push_back({ + .sequence = g_pointer.size(), + .timeNs = nowNs(), + .message = static_cast<std::uint32_t>(message), + .cursor = event->pt, + .flags = event->flags, + }); + } + return CallNextHookEx(g_hook, code, message, data); +} + +void recordRaw(LPARAM data) { + UINT size = 0; + if (GetRawInputData(reinterpret_cast<HRAWINPUT>(data), RID_INPUT, nullptr, &size, sizeof(RAWINPUTHEADER)) != 0) + fail("GetRawInputData size"); + + std::vector<std::byte> storage(size); + if (GetRawInputData(reinterpret_cast<HRAWINPUT>(data), RID_INPUT, storage.data(), &size, sizeof(RAWINPUTHEADER)) != size) + fail("GetRawInputData payload"); + + const auto* input = reinterpret_cast<const RAWINPUT*>(storage.data()); + if (input->header.dwType != RIM_TYPEMOUSE) + return; + + POINT cursor{ + .x = std::numeric_limits<LONG>::min(), + .y = std::numeric_limits<LONG>::min(), + }; + GetCursorPos(&cursor); + + g_raw.push_back({ + .sequence = g_raw.size(), + .timeNs = nowNs(), + .device = reinterpret_cast<std::uintptr_t>(input->header.hDevice), + .flags = input->data.mouse.usFlags, + .x = input->data.mouse.lLastX, + .y = input->data.mouse.lLastY, + .cursor = cursor, + }); +} + +LRESULT CALLBACK windowProcedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + switch (message) { + case WM_INPUT: + recordRaw(lparam); + return DefWindowProcW(window, message, wparam, lparam); + case WM_TIMER: + PostQuitMessage(0); + return 0; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + return DefWindowProcW(window, message, wparam, lparam); + } +} + +auto parseSeconds(std::string_view value) -> UINT { + std::size_t consumed = 0; + const auto seconds = std::stoul(std::string{value}, &consumed); + if (consumed != value.size() || seconds < 1 || seconds > 3600) + throw std::runtime_error("--seconds must be between 1 and 3600"); + return static_cast<UINT>(seconds); +} + +void writeResults() { + std::filesystem::create_directories(g_output); + + int speed = 0; + int mouse[3]{}; + SystemParametersInfoW(SPI_GETMOUSESPEED, 0, &speed, 0); + SystemParametersInfoW(SPI_GETMOUSE, 0, mouse, 0); + + { + std::ofstream metadata(g_output / "metadata.txt"); + metadata << "pointer_speed=" << speed << '\n' + << "mouse_threshold_1=" << mouse[0] << '\n' + << "mouse_threshold_2=" << mouse[1] << '\n' + << "mouse_acceleration=" << mouse[2] << '\n' + << "screen_width=" << g_screenWidth << '\n' + << "screen_height=" << g_screenHeight << '\n' + << "system_dpi=" << GetDpiForSystem() << '\n' + << "session_id=" << WTSGetActiveConsoleSessionId() << '\n' + << "cursor_api_available=" << g_cursorApiAvailable << '\n' + << "initial_cursor_x=" << g_initialCursor.x << '\n' + << "initial_cursor_y=" << g_initialCursor.y << '\n' + << "raw_reports=" << g_raw.size() << '\n' + << "pointer_events=" << g_pointer.size() << '\n' + << "qpc_frequency=" << g_frequency.QuadPart << '\n'; + } + + { + std::ofstream output(g_output / "raw.csv"); + output << "report,time_ns,device,flags,raw_x,raw_y,cursor_x,cursor_y\n"; + for (const auto& sample : g_raw) + output << sample.sequence << ',' << sample.timeNs << ',' << sample.device << ',' << sample.flags << ',' << sample.x << ',' << sample.y << ',' + << sample.cursor.x << ',' << sample.cursor.y << '\n'; + } + + { + std::ofstream output(g_output / "pointer.csv"); + output << "event,time_ns,message,cursor_x,cursor_y,flags\n"; + for (const auto& sample : g_pointer) + output << sample.sequence << ',' << sample.timeNs << ',' << sample.message << ',' << sample.cursor.x << ',' << sample.cursor.y << ',' + << sample.flags << '\n'; + } +} + +} // namespace + +int main(int argc, char** argv) { + try { + UINT seconds = 30; + g_output = "pointer-lab"; + for (int index = 1; index < argc; ++index) { + const std::string_view argument = argv[index]; + if (argument == "--output" && index + 1 < argc) + g_output = argv[++index]; + else if (argument == "--ready-file" && index + 1 < argc) + g_ready = argv[++index]; + else if (argument == "--seconds" && index + 1 < argc) + seconds = parseSeconds(argv[++index]); + else + throw std::runtime_error( + "usage: windows-pointer-windows-capture " + "[--output directory] [--ready-file path] " + "[--seconds 1..3600]"); + } + + if (!QueryPerformanceFrequency(&g_frequency) || !QueryPerformanceCounter(&g_started)) + fail("QueryPerformanceCounter"); + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST); + g_raw.reserve(static_cast<std::size_t>(seconds) * 1200); + g_pointer.reserve(static_cast<std::size_t>(seconds) * 1200); + + const wchar_t* className = L"WindowsPointerCapture"; + WNDCLASSW windowClass{}; + windowClass.lpfnWndProc = windowProcedure; + windowClass.hInstance = GetModuleHandleW(nullptr); + windowClass.lpszClassName = className; + if (!RegisterClassW(&windowClass)) + fail("RegisterClass"); + + const auto window = CreateWindowExW( + 0, + className, + L"Windows pointer capture", + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + nullptr, + windowClass.hInstance, + nullptr); + if (!window) + fail("CreateWindowEx"); + + RAWINPUTDEVICE mouse{ + .usUsagePage = 0x01, + .usUsage = 0x02, + .dwFlags = RIDEV_INPUTSINK, + .hwndTarget = window, + }; + if (!RegisterRawInputDevices(&mouse, 1, sizeof(mouse))) + fail("RegisterRawInputDevices"); + + g_screenWidth = GetSystemMetrics(SM_CXSCREEN); + g_screenHeight = GetSystemMetrics(SM_CYSCREEN); + g_initialCursor = { + .x = g_screenWidth / 2, + .y = g_screenHeight / 2, + }; + if (SetCursorPos(g_initialCursor.x, g_initialCursor.y)) { + POINT actual{}; + if (GetCursorPos(&actual)) { + g_initialCursor = actual; + g_cursorApiAvailable = true; + } + } + + g_hook = SetWindowsHookExW(WH_MOUSE_LL, lowLevelMouse, GetModuleHandleW(nullptr), 0); + if (!g_hook) + fail("SetWindowsHookEx"); + + if (!SetTimer(window, 1, seconds * 1000, nullptr)) + fail("SetTimer"); + + if (!g_ready.empty()) { + if (g_ready.has_parent_path()) + std::filesystem::create_directories(g_ready.parent_path()); + std::ofstream ready(g_ready); + ready << "armed\n"; + if (!ready) + throw std::runtime_error("could not write ready file"); + } + + std::cout << "capture armed for " << seconds << " seconds\n"; + MSG message; + while (GetMessageW(&message, nullptr, 0, 0) > 0) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + + UnhookWindowsHookEx(g_hook); + g_hook = nullptr; + DestroyWindow(window); + writeResults(); + std::cout << "captured " << g_raw.size() << " raw reports and " << g_pointer.size() << " pointer events in " << g_output.string() << '\n'; + } catch (const std::exception& error) { + std::cerr << "windows-pointer-windows-capture: " << error.what() << '\n'; + return 1; + } + return 0; +}