From 46c51dbbfb0a5200007c5bf4cb179d846c8e2628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Sun, 16 Nov 2025 17:16:54 +0100 Subject: [PATCH 1/7] Simplify arg parsing It is already 'str' (and filtered by qrexec), encoding it back to 'bytes' doesn't really help, it only makes things more complicated. --- sender/webcam.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sender/webcam.py b/sender/webcam.py index ccb7293..eb01f28 100644 --- a/sender/webcam.py +++ b/sender/webcam.py @@ -21,22 +21,21 @@ class Webcam(Service): def __init__(self, *, untrusted_arg: str): if untrusted_arg: - untrusted_arg_bytes = untrusted_arg.encode('ascii', 'strict') def parse_int(untrusted_decimal: bytes) -> int: if not ((1 <= len(untrusted_decimal) <= 4) and untrusted_decimal.isdigit() and - untrusted_decimal[0] != b"0"): + untrusted_decimal[0] != "0"): print("Invalid argument " + untrusted_arg + ": bad number", file=sys.stderr) sys.exit(1) return int(untrusted_decimal, 10) - if len(untrusted_arg_bytes) > 14: + if len(untrusted_arg) > 14: # qrexec has already sanitized the argument to some degree, # so this is safe print("Invalid argument " + untrusted_arg + ": too long (limit 14 bytes)", file=sys.stderr) sys.exit(1) - arg_list = untrusted_arg_bytes.split(b"+", 4) + arg_list = untrusted_arg.split("+", 4) if len(arg_list) != 3: print("Invalid argument " + untrusted_arg + ": wrong number of integers (expected 3)", From 4dcf5656a856117111ba21595fa10ff81db635d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Sun, 16 Nov 2025 17:22:52 +0100 Subject: [PATCH 2/7] Add placeholder for camera selection support Support "port id" parameter for qvc.Webcam service, to select camera device. If used together with preferred resolution, port id must come first. Currently support only one value: "dev-video0" and refuse others. Enumerating devices, and resolving port id to actual device will come later. QubesOS/qubes-issues#10407 --- sender/webcam.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sender/webcam.py b/sender/webcam.py index eb01f28..87247e3 100644 --- a/sender/webcam.py +++ b/sender/webcam.py @@ -20,6 +20,24 @@ class Webcam(Service): untrusted_requested_fps: int def __init__(self, *, untrusted_arg: str): + self.port_id = "dev-video0" + + if untrusted_arg: + if untrusted_arg.startswith("dev-"): + # first arg may be a port id, and then optional resolution arg + if "+" in untrusted_arg: + untrusted_port_id, untrusted_arg = ( + untrusted_arg.split("+", 1) + ) + else: + untrusted_port_id, untrusted_arg = untrusted_arg, None + + # currently support only a single port: "dev-video0" + if untrusted_port_id != "dev-video0": + print(f"Unsupported webcam port ({untrusted_port_id}), " + "only 'dev-video0' supported", file=sys.stderr) + self.port_id = untrusted_port_id + if untrusted_arg: def parse_int(untrusted_decimal: bytes) -> int: if not ((1 <= len(untrusted_decimal) <= 4) and From dcd9cdcca00580451f60876d541d781520311f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Mon, 17 Nov 2025 15:22:16 +0100 Subject: [PATCH 3/7] Implement base integration with qvm-device API This commit includes several parts: 1. Udev handler on the sender side to publish camera info in qubesdb. "port id" for qvm-device API is built from /dev/video0 name, encoded as "dev-video0". At this moment, only /dev/video0 is considered, support for other devices will come later (QubesOS/qubes-issues#10407), and may decide on alternative port naming (preferably keeping dev- prefix). 2. The qvc.WebcamAttach service that calls qubes-video-companion on the receiver side, as a systemd service (to cleanly keep it running even after qvc.WebcamAttach service exits) 3. qvc.WebcamDetach service (to be called on the sender side) that kills the connection. 4. core-admin extension that puts the above elements together and expose as new "webcam" device class. This is using devices API available in Qubes OS 4.3. This feature is not available in R4.2. QubesOS/qubes-issues#10411 --- Makefile | 11 +- core3ext/pyproject.toml | 21 + core3ext/qvc/__init__.py | 421 ++++++++++++++++++ core3ext/qvc/aa/__init__.py | 0 qubes-rpc/services/qvc.WebcamAttach | 11 + qubes-rpc/services/qvc.WebcamDetach | 21 + receiver/qubes-video-companion | 25 +- .../qubes-video-companion-webcam@.service | 7 + receiver/receiver.py | 12 + rpm_spec/qubes-video-companion-dom0.spec.in | 22 + rpm_spec/qubes-video-companion.spec.in | 6 + scripts/udev-handler | 45 ++ sender/service.py | 7 + sender/tray_icon.py | 6 +- sender/udev.rules | 2 + sender/webcam.py | 22 + 16 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 core3ext/pyproject.toml create mode 100644 core3ext/qvc/__init__.py create mode 100644 core3ext/qvc/aa/__init__.py create mode 100644 qubes-rpc/services/qvc.WebcamAttach create mode 100644 qubes-rpc/services/qvc.WebcamDetach create mode 100644 receiver/qubes-video-companion-webcam@.service create mode 100644 scripts/udev-handler create mode 100644 sender/udev.rules diff --git a/Makefile b/Makefile index c40e5ff..6022370 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ DATADIR ?= /usr/share SYSCONFDIR ?= /etc QREXECSERVICEDIR ?= $(SYSCONFDIR)/qubes-rpc QREXECPOLICYDIR ?= $(SYSCONFDIR)/qubes/policy.d +UDEVDIR ?= /usr/lib/udev/rules.d PYTHON ?= python3 INSTALL_DIR = install -d -- @@ -40,6 +41,7 @@ install-vm: install-both $(INSTALL_DATA) receiver/qubes-video-companion.rules $(DESTDIR)/usr/lib/udev/rules.d/80-qubes-video-companion.rules $(INSTALL_DATA) receiver/qubes-video-companion.modprobe $(DESTDIR)/usr/lib/modprobe.d/qubes-video-companion.conf $(INSTALL_DATA) receiver/qubes-video-companion.sudoers $(DESTDIR)/etc/sudoers.d/qubes-video-companion + $(INSTALL_DATA) receiver/qubes-video-companion-webcam@.service $(DESTDIR)/usr/lib/systemd/system/qubes-video-companion-webcam@.service $(INSTALL_DIR) $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.Webcam echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.ScreenShare @@ -56,9 +58,16 @@ install-dom0: install-both install-policy install-tests install-both: $(INSTALL_DIR) $(DESTDIR)$(QREXECSERVICEDIR) - $(INSTALL_PROGRAM) qubes-rpc/services/qvc.Webcam qubes-rpc/services/qvc.ScreenShare $(DESTDIR)$(QREXECSERVICEDIR) + $(INSTALL_PROGRAM) qubes-rpc/services/qvc.Webcam \ + qubes-rpc/services/qvc.ScreenShare \ + $(DESTDIR)$(QREXECSERVICEDIR) + $(INSTALL_PROGRAM) qubes-rpc/services/qvc.WebcamAttach \ + qubes-rpc/services/qvc.WebcamDetach \ + $(DESTDIR)$(QREXECSERVICEDIR) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender $(INSTALL_PROGRAM) sender/*.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender + $(INSTALL_PROGRAM) scripts/udev-handler $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender/ + $(INSTALL_PROGRAM) sender/udev.rules $(DESTDIR)$(UDEVDIR)/80-qubes-video-companion-sender.rules $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/doc/$(PKGNAME) $(INSTALL_DATA) README.md doc/pipeline.md $(DESTDIR)$(DATADIR)/doc/$(PKGNAME) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/doc/$(PKGNAME)/visualizations diff --git a/core3ext/pyproject.toml b/core3ext/pyproject.toml new file mode 100644 index 0000000..a41935a --- /dev/null +++ b/core3ext/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "qvc" +version = "4.3" +description = "QVC plugin for qubes-core-admin" +license = { text = "MIT" } + +[project.urls] +Homepage = "https://www.qubes-os.org/" +Documentation = "https://readthedocs.org" +Repository = "https://github.com/QubesOS/qubes-video-companion.git" +"Bug Tracker" = "https://github.com/QubesOS/qubes-issues/issues" + +[project.entry-points."qubes.ext"] +webcam = "qvc:WebcamDeviceExtension" + +[project.entry-points."qubes.devices"] +webcam = "qvc:WebcamDevice" diff --git a/core3ext/qvc/__init__.py b/core3ext/qvc/__init__.py new file mode 100644 index 0000000..377eea2 --- /dev/null +++ b/core3ext/qvc/__init__.py @@ -0,0 +1,421 @@ +# Copyright (C) 2025 Marek Marczykowski-Górecki +# +# Licensed under the MIT License. See LICENSE file for details. +import asyncio +import contextlib +import os +import re +import string +import subprocess +from typing import Optional, List + +import qubes.device_protocol +import qubes.ext +from qubes.device_protocol import DeviceInterface, Port +from qubes.exc import QubesException +from qubes.ext import utils +from qubes.utils import sanitize_stderr_for_log + +name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") +device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") +connected_to_re = re.compile(rb"^[a-zA-Z][a-zA-Z0-9_.-]*$") + + +class WebcamDevice(qubes.device_protocol.DeviceInfo): + def __init__(self, port: qubes.device_protocol.Port): + if port.devclass != "webcam": + raise qubes.exc.QubesValueError( + f"Incompatible device class for input port: {port.devclass}" + ) + + # init parent class + super().__init__(port) + + self._qdb_path = f"/webcam-devices/{port.port_id}" + + # TODO: available res, fps + + @property + def interfaces(self) -> List[DeviceInterface]: + return [DeviceInterface("u0e0200")] + + @property + def vendor(self) -> str: + if self.parent_device: + return self.parent_device.vendor + return "unknown" + + @property + def manufacturer(self) -> str: + if self.parent_device: + return self.parent_device.manufacturer + return "unknown" + + @property + def product(self) -> str: + if self.parent_device: + return self.parent_device.product + return "unknown" + + @property + def name(self) -> str: + if self.parent_device: + return f"Camera ({self.parent_device.name})" + return "Camera" + + @property + def description(self) -> str: + if self.parent_device: + return f"Camera ({self.parent_device.description})" + return "Camera" + + @property + def serial(self) -> str: + if self.parent_device: + return self.parent_device.serial + return "unknown" + + @property + def device_id(self) -> str: + if self.parent_device: + return self.parent_device.device_id + return super().device_id + + @staticmethod + def _sanitize( + untrusted_parent: bytes, + safe_chars: str = string.ascii_letters + + string.digits + + string.punctuation, + ) -> str: + untrusted_device_desc = untrusted_parent.decode( + "ascii", errors="ignore" + ) + return "".join( + c if c in set(safe_chars) else "_" for c in untrusted_device_desc + ) + + def _get_parent_device(self, port: Port): + if not port.backend_domain or not port.backend_domain.is_running(): + return None + untrusted_parent_info = port.backend_domain.untrusted_qdb.read( + f"/webcam-devices/{port.port_id}/parent" + ) + if not untrusted_parent_info: + return None + parent_devclass, parent_ident = self._sanitize( + untrusted_parent_info + ).split(":", maxsplit=1) + if not parent_ident: + return None + try: + return port.backend_domain.devices[parent_devclass][ + parent_ident + ] + except KeyError: + return qubes.device_protocol.UnknownDevice( + qubes.device_protocol.Port( + port.backend_domain, parent_ident, + devclass=parent_devclass + ) + ) + @property + def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: + """ + The parent device, if any. + """ + if self._parent is None: + self._parent = self._get_parent_device(self.port) + return self._parent + + @property + def attachment(self): + if not self.backend_domain.is_running(): + return None + untrusted_connected_to = self.backend_domain.untrusted_qdb.read( + self._qdb_path + "/connected-to" + ) + if not untrusted_connected_to: + return None + if not connected_to_re.match(untrusted_connected_to): + self.backend_domain.log.warning( + f"Device {self.port_id} has invalid chars in connected-to " + "property" + ) + return None + untrusted_connected_to = untrusted_connected_to.decode( + "ascii", errors="strict" + ) + try: + connected_to = self.backend_domain.app.domains[ + untrusted_connected_to + ] + except KeyError: + self.backend_domain.log.warning( + f"Device {self.port_id} has invalid VM name in connected-to " + f"property: {untrusted_connected_to}" + ) + return None + return connected_to + + +class QVCNotInstalled(QubesException): + pass + + +@contextlib.contextmanager +def allow_qrexec_call(service, arg, source, dest): + fname = f"/run/qubes/policy.d/10-qvc-{hash((service, arg, source, dest))}.policy" + with open(fname, "x") as policy: + policy.write(f"{service} {arg} {source} {dest} allow\n") + try: + yield + finally: + os.unlink(fname) + + +class WebcamDeviceExtension(qubes.ext.Extension): + @qubes.ext.handler("domain-init", "domain-load") + def on_domain_init_load(self, vm, event): + """Initialize watching for changes""" + # pylint: disable=unused-argument + vm.watch_qdb_path("/webcam-devices") + if vm.app.vmm.offline_mode: + self.devices_cache[vm.name] = {} + return + if event == "domain-load": + # avoid building a cache on domain-init, as it isn't fully set yet, + # and definitely isn't running yet + current_devices = { + dev.port_id: dev.attachment + for dev in self.on_device_list_webcam(vm, None) + } + self.devices_cache[vm.name] = current_devices + else: + self.devices_cache[vm.name] = {} + + async def attach_and_notify(self, vm, assignment): + # bypass DeviceCollection logic preventing double attach + device = assignment.device + if assignment.mode.value == "ask-to-attach": + allowed = await utils.confirm_device_attachment( + device, {vm: assignment} + ) + allowed = allowed.strip() + if vm.name != allowed: + return + await self.on_device_attach_webcam( + vm, "device-pre-attach:webcam", device, assignment.options + ) + await vm.fire_event_async( + "device-attach:webcam", device=device, options=assignment.options + ) + + def ensure_detach(self, vm, port): + """ + Run this method if device is no longer detected. + + No additional action required in case of webcam devices. + """ + pass + + @qubes.ext.handler("domain-qdb-change:/webcam-devices") + def on_qdb_change(self, vm, event, path): + """A change in QubesDB means a change in a device list.""" + # pylint: disable=unused-argument + current_devices = dict( + (dev.port_id, dev.attachment) + for dev in self.on_device_list_webcam(vm, None) + ) + utils.device_list_change(self, current_devices, vm, path, WebcamDevice) + + @staticmethod + def device_get(vm, port_id): + untrusted_qubes_device_attrs = vm.untrusted_qdb.list( + "/webcam-devices/{}/".format(port_id) + ) + if not untrusted_qubes_device_attrs: + return None + return WebcamDevice( + qubes.device_protocol.Port( + backend_domain=vm, port_id=port_id, devclass="webcam" + ) + ) + + @qubes.ext.handler("device-list:webcam") + def on_device_list_webcam(self, vm, event): + if not vm.is_running() or not hasattr(vm, "untrusted_qdb"): + return + untrusted_devices = vm.untrusted_qdb.list("/webcam-devices/") + + untrusted_idents = set(untrusted_path.split("/", 3)[2] + for untrusted_path in untrusted_devices) + for untrusted_ident in untrusted_idents: + if not name_re.match(untrusted_ident): + msg = ( + "%s vm's device path name contains unsafe characters. " + "Skipping it." + ) + vm.log.warning(msg) + continue + + port_id = untrusted_ident + + device_info = self.device_get(vm, port_id) + if device_info: + yield device_info + + @qubes.ext.handler("device-get:webcam") + def on_device_get_webcam(self, vm, event, port_id): + # pylint: disable=unused-argument + if not vm.is_running(): + return + + if vm.untrusted_qdb.list("/webcam-devices/" + port_id): + yield WebcamDevice(Port(vm, port_id, "webcam")) + + @staticmethod + def get_all_devices(app): + for vm in app.domains: + if not vm.is_running() or not hasattr(vm, "devices"): + continue + + for dev in vm.devices["webcam"]: + if isinstance(dev, WebcamDevice): + yield dev + + @qubes.ext.handler("device-list-attached:webcam") + def on_device_list_attached(self, vm, event, **kwargs): + # pylint: disable=unused-argument + if not vm.is_running(): + return + + for dev in self.get_all_devices(vm.app): + if dev.attachment == vm: + yield (dev, {}) + + @qubes.ext.handler("device-pre-attach:webcam") + async def on_device_pre_attach_webcam(self, vm, event, device, options): + # pylint: disable=unused-argument + + arg = device.port_id + + # TODO: options for res/fps + if options: + raise QubesException("Options not supported") + + if not vm.is_running() or vm.qid == 0: + # print(f"Qube is not running, skipping attachment of {device}", + # file=sys.stderr) + return + + assert isinstance(device, WebcamDevice) + + if device.attachment: + raise qubes.exc.DeviceAlreadyAttached( + f"Device {device} already attached to {device.attachment}" + ) + + if not vm.features.check_with_template("supported-rpc.qvc.WebcamAttach", False): + raise QVCNotInstalled("qubes-video-companion not installed in the VM") + + # update the cache before the call, to avoid sending duplicated events + # (one on qubesdb watch and the other by the caller of this method) + self.devices_cache[device.backend_domain.name][device.port_id] = vm + + # set qrexec policy to allow this device + with allow_qrexec_call("qvc.Webcam", "+" + arg, f"uuid:{vm.uuid}", f"uuid:{device.backend_domain.uuid}"): + # and actual attach + try: + await vm.run_service_for_stdio( + "qvc.WebcamAttach", + user="root", + input=f"{device.backend_domain.name} " + f"{arg}\n".encode(), + ) + except subprocess.CalledProcessError as e: + # pylint: disable=raise-missing-from + if e.returncode == 127: + raise QVCNotInstalled("qubes-video-companion not installed in the VM") + raise QubesException( + f"Device attach failed: {sanitize_stderr_for_log(e.output)}" + f" {sanitize_stderr_for_log(e.stderr)}" + ) + + @qubes.ext.handler("device-pre-detach:webcam") + async def on_device_detach_webcam(self, vm, event, port): + # pylint: disable=unused-argument + if not vm.is_running() or vm.qid == 0: + return + + for attached, _options in self.on_device_list_attached(vm, event): + if attached.port == port: + break + else: + raise QubesException( + f"Device {port} not connected to VM {vm.name}" + ) + + # update the cache before the call, to avoid sending duplicated events + # (one on qubesdb watch and the other by the caller of this method) + backend = attached.backend_domain + self.devices_cache[backend.name][attached.port_id] = None + + try: + await backend.run_service_for_stdio( + f"qvc.WebcamDetach+{attached.port_id}", + user="root", + ) + except subprocess.CalledProcessError as e: + # pylint: disable=raise-missing-from + raise QubesException( + f"Device detach failed: {sanitize_stderr_for_log(e.output)}" + f" {sanitize_stderr_for_log(e.stderr)}" + ) + + @qubes.ext.handler("device-pre-assign:webcam") + async def on_device_assign_webcam(self, vm, event, device, options): + # pylint: disable=unused-argument + pass + # TODO: verify options + + @qubes.ext.handler("domain-start") + async def on_domain_start(self, vm, _event, **_kwargs): + # pylint: disable=unused-argument + to_attach = {} + assignments = vm.devices["webcam"].get_assigned_devices() + # the most specific assignments first + for assignment in reversed(sorted(assignments)): + for device in assignment.devices: + if isinstance(device, qubes.device_protocol.UnknownDevice): + continue + if device.attachment: + continue + if not assignment.matches(device): + vm.log.warning( + "Unrecognized identity, skipping attachment of device " + f"from the port {assignment}", + ) + continue + # chose first assignment (the most specific) and ignore rest + if device not in to_attach: + # make it unique + to_attach[device] = assignment.clone(device=device) + in_progress = set() + for assignment in to_attach.values(): + in_progress.add( + asyncio.ensure_future(self.attach_and_notify(vm, assignment)) + ) + if in_progress: + await asyncio.wait(in_progress) + + @qubes.ext.handler("domain-shutdown") + async def on_domain_shutdown(self, vm, _event, **_kwargs): + # pylint: disable=unused-argument + vm.fire_event("device-list-change:webcam") + utils.device_list_change(self, {}, vm, None, WebcamDevice) + + @qubes.ext.handler("qubes-close", system=True) + def on_qubes_close(self, app, event): + # pylint: disable=unused-argument + self.devices_cache.clear() diff --git a/core3ext/qvc/aa/__init__.py b/core3ext/qvc/aa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qubes-rpc/services/qvc.WebcamAttach b/qubes-rpc/services/qvc.WebcamAttach new file mode 100644 index 0000000..3eb7713 --- /dev/null +++ b/qubes-rpc/services/qvc.WebcamAttach @@ -0,0 +1,11 @@ +#!/bin/sh -- +set -eu + +read -r domain arg +cursor=$(journalctl -n 0 -q --show-cursor | cut -d ' ' -f 3-) +unit="qubes-video-companion-webcam@$domain+$arg.service" +if ! systemctl -q start "$unit"; then + journalctl --no-pager --after-cursor="$cursor" --unit="$unit" --output=cat | + grep -v 'service: Main process exited\|service: Failed with result\|^Starting' >&2 + exit 1 +fi diff --git a/qubes-rpc/services/qvc.WebcamDetach b/qubes-rpc/services/qvc.WebcamDetach new file mode 100644 index 0000000..a6ddc9a --- /dev/null +++ b/qubes-rpc/services/qvc.WebcamDetach @@ -0,0 +1,21 @@ +#!/bin/sh + +portid="$1" + +if [ "$portid" != "dev-video0" ]; then + echo "Unsupported port id!" >&2 + exit 2 +fi + +pidfile="/run/qubes/qvc-webcam-$portid" +if [ -r "$pidfile" ]; then + pid="$(cat "$pidfile")" + # safety check, just in case the process exited without cleaning up + if ! grep -q webcam.py "/proc/$pid/cmdline" 2>/dev/null; then + # can't kill... + rm -f "$pidfile" + echo "Can't find QVC process (PID $pid), already exited?" >&2 + exit 1 + fi + kill -- "$pid" +fi diff --git a/receiver/qubes-video-companion b/receiver/qubes-video-companion index 37ced9a..89a8e6c 100755 --- a/receiver/qubes-video-companion +++ b/receiver/qubes-video-companion @@ -12,13 +12,15 @@ unset GETOPT_COMPATIBLE name=${0##*/} usage() { - echo "Usage: $name [--resolution=[WIDTHxHEIGHTxFPS]] [--] webcam|screenshare [destination qube]" >&2 + echo "Usage: $name [--instance-arg=...] [--resolution=[WIDTHxHEIGHTxFPS]] [--] webcam|screenshare [destination qube]" >&2 echo "Resolution example: 1920x1080x60" + echo "--instance-arg is used internally when started via systemd" exit "$1" } resolution= -opts=$(getopt "--name=$name" --longoptions=resolution:,help -- r: "$@") || exit +instance_arg= +opts=$(getopt "--name=$name" --longoptions=resolution:,help,instance-arg: -- r: "$@") || exit eval "set -- $opts" while :; do case $1 in @@ -31,6 +33,14 @@ while :; do resolution=${resolution//x/+} shift 2 ;; + --instance-arg) + if [[ -z "$2" ]]; then + echo "$name: missing instance arg value" >&2 + usage 1 >&2 + fi + instance_arg="${2//\\x2b/+}" + shift 2 + ;; -h|--help) usage 0;; --) shift; break;; *) exit 1;; # cannot happen @@ -41,6 +51,17 @@ if [ "$#" -gt 2 ] || [ "$#" -lt 1 ]; then usage 1 >&2; fi video_source="$1" qube=${2-'@default'} +if [[ "$qube" != "@default" ]] && [[ -n "$instance_arg" ]]; then + echo "Cannot use --instance-arg together with destination qube" >&2 + exit 1 +fi + +if [[ -n "$instance_arg" ]]; then + qube="${instance_arg%%+*}" + # in reality full qrexec arg + resolution="${instance_arg#*+}" +fi + case "$video_source" in webcam) qvc_service="qvc.Webcam" diff --git a/receiver/qubes-video-companion-webcam@.service b/receiver/qubes-video-companion-webcam@.service new file mode 100644 index 0000000..8d7d3f1 --- /dev/null +++ b/receiver/qubes-video-companion-webcam@.service @@ -0,0 +1,7 @@ +[Unit] +Description=Qubes Video Companion - webcam + +[Service] +Type=notify +ExecStart=/usr/bin/qubes-video-companion "--instance-arg=%i" webcam +NotifyAccess=all diff --git a/receiver/receiver.py b/receiver/receiver.py index ebc95f0..2d2bf48 100644 --- a/receiver/receiver.py +++ b/receiver/receiver.py @@ -5,11 +5,21 @@ # Licensed under the MIT License. See LICENSE file for details. import sys +import socket import struct import os from typing import NoReturn +def sdnotify(msg): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + addr = os.getenv("NOTIFY_SOCKET") + if addr[0] == "@": + addr = '\0' + addr[1:] + sock.connect(addr) + sock.sendall(msg) + sock.close() + def main(argv) -> NoReturn: dev_path = "/dev/video0" if len(argv) == 2: @@ -21,6 +31,8 @@ def main(argv) -> NoReturn: width, height, fps = read_video_parameters() + if "NOTIFY_SOCKET" in os.environ: + sdnotify(b"READY=1") print( "Receiving video stream at {}x{} {} FPS...".format(width, height, fps), file=sys.stderr, diff --git a/rpm_spec/qubes-video-companion-dom0.spec.in b/rpm_spec/qubes-video-companion-dom0.spec.in index fb3e24b..0cbea22 100644 --- a/rpm_spec/qubes-video-companion-dom0.spec.in +++ b/rpm_spec/qubes-video-companion-dom0.spec.in @@ -17,6 +17,8 @@ BuildArch: noarch BuildRequires: make BuildRequires: python3-setuptools BuildRequires: python3-devel +BuildRequires: python3-pip +BuildRequires: python3-wheel Requires: gstreamer1-plugins-good Requires: v4l-utils @@ -56,24 +58,44 @@ necessary for dom0. %define __python %{__pythonX} %make_build +%if 0%{?fedora} >= 41 +pushd core3ext +%pyproject_wheel +popd +%endif + %install rm -rf $RPM_BUILD_ROOT make DESTDIR=%{?buildroot} install-dom0 install-license +%if 0%{?fedora} >= 41 +pushd core3ext +%pyproject_install +popd +%endif + %files %{_licensedir}/qubes-video-companion/LICENSE %{_docdir}/qubes-video-companion/README.md %{_docdir}/qubes-video-companion/pipeline.md %{_docdir}/qubes-video-companion/visualizations/* %{_sysconfdir}/qubes-rpc/qvc.Webcam +%{_sysconfdir}/qubes-rpc/qvc.WebcamAttach +%{_sysconfdir}/qubes-rpc/qvc.WebcamDetach %{_sysconfdir}/qubes-rpc/qvc.ScreenShare %{_sysconfdir}/qubes/policy.d/90-default-video-companion.policy +/usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py +%{_datadir}/qubes-video-companion/sender/udev-handler %{python3_sitelib}/qvctests %{python3_sitelib}/qvctests-*.egg-info +%if 0%{?fedora} >= 41 +%{python3_sitelib}/qvc/ +%{python3_sitelib}/qvc-*.dist-info/ +%endif %changelog @CHANGELOG@ diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index cbf018d..2dada7e 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -14,6 +14,7 @@ BuildArch: noarch BuildRequires: pandoc BuildRequires: make +BuildRequires: systemd-rpm-macros Requires: qubes-video-companion-sender Requires: qubes-video-companion-receiver Requires: qubes-video-companion-license @@ -62,13 +63,16 @@ This package contains the video-sending portion of Qubes Video Companion. %files sender %{_sysconfdir}/qubes-rpc/qvc.Webcam +%{_sysconfdir}/qubes-rpc/qvc.WebcamDetach %{_sysconfdir}/qubes-rpc/qvc.ScreenShare %{_sysconfdir}/qubes/rpc-config/qvc.Webcam %{_sysconfdir}/qubes/rpc-config/qvc.ScreenShare +/usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py +%{_datadir}/qubes-video-companion/sender/udev-handler %package receiver Summary: Video receiver part of qubes-video-companion @@ -101,11 +105,13 @@ This package contains the video-receiving portion of Qubes Video Companion. %files receiver %{_sysconfdir}/dkms/v4l2loopback.conf +%{_sysconfdir}/qubes-rpc/qvc.WebcamAttach %{_mandir}/man1/qubes-video-companion.1.gz %{_bindir}/qubes-video-companion %{_datadir}/qubes-video-companion/receiver/setup.py %{_datadir}/qubes-video-companion/receiver/receiver.py %{_datadir}/qubes-video-companion/receiver/destroy.py +%{_unitdir}/qubes-video-companion-webcam@.service /usr/share/applications/qubes-video-companion-webcam.desktop /usr/share/applications/qubes-video-companion-screenshare.desktop /usr/lib/udev/rules.d/80-qubes-video-companion.rules diff --git a/scripts/udev-handler b/scripts/udev-handler new file mode 100644 index 0000000..433aaa0 --- /dev/null +++ b/scripts/udev-handler @@ -0,0 +1,45 @@ +#!/bin/sh + +set -eu + +portid="dev-$(basename "${DEVPATH}")" + +if [ "$ACTION" = "remove" ]; then + # remove content and the entry itself + qubesdb-rm "/webcam-devices/$portid/" "/webcam-devices/$portid" + # trigger watch + qubesdb-write "/webcam-devices" "" + exit +fi + +if [ "$ACTION" != "add" ] && [ "$ACTION" != "change" ]; then + echo "Unknown action '$ACTION'" >&2 + exit 2 +fi + +if ! [ -e "/sys/$DEVPATH/device" ]; then + # ignore virtual devices created by QVC itself + if grep -q "^QVC -" "/sys/$DEVPATH/name"; then + exit + fi +fi + +parent= + +# for now support only USB parent device +if [ "${ID_BUS-}" = "usb" ]; then + parent_devpath=$(readlink -f "/sys/$DEVPATH/device") + parent_devid="${parent_devpath##*/}" + parent_devid="${parent_devid%%:*}" + parent="usb:$parent_devid" +fi + +if [ -n "$parent" ]; then + qubesdb-write "/webcam-devices/$portid/parent" "$parent" +fi +if [ "$ACTION" = "add" ]; then + qubesdb-write "/webcam-devices/$portid/connected-to" "" +fi + +# trigger watch +qubesdb-write "/webcam-devices" "" diff --git a/sender/service.py b/sender/service.py index 98e8399..10d0b2a 100644 --- a/sender/service.py +++ b/sender/service.py @@ -52,6 +52,8 @@ def start_service(self, target_domain: str, remote_domain: str) -> None: self._tray_icon = tray_icon.TrayIcon(app, icon, msg) + self.record_connect_state(remote_domain) + def video_source(self) -> str: """ Return the video source @@ -86,6 +88,11 @@ def quit(self) -> None: self._element.set_state(Gst.State.NULL) Gtk.main_quit() + def record_connect_state(self, remote_domain) -> None: + """ + Record state of stream, for the disconnect purpose. + """ + def msg_handler(self, _bus: Gst.Bus, msg: Gst.Message) -> None: """Handle pipeline messages""" diff --git a/sender/tray_icon.py b/sender/tray_icon.py index 2e9b897..f3719cd 100644 --- a/sender/tray_icon.py +++ b/sender/tray_icon.py @@ -11,7 +11,8 @@ # GI requires version declaration before importing # pylint: disable=wrong-import-position -from os import _exit +import signal +import sys from typing import NoReturn import gi @@ -76,11 +77,12 @@ def menu(cls, msg, app) -> object: def die(unused_gtk) -> NoReturn: # We do not care about cleaning up properly here; the OS will do # that for us. We *do* care about exiting ASAP. - _exit(0) + sys.exit(0) entry = Gtk.MenuItem.new_with_label("Stop video transmission") entry.connect("activate", die) menu.connect("destroy", die) + signal.signal(signal.SIGTERM, lambda *_args: die(None)) menu.append(entry) menu.show_all() diff --git a/sender/udev.rules b/sender/udev.rules new file mode 100644 index 0000000..1a7c8d8 --- /dev/null +++ b/sender/udev.rules @@ -0,0 +1,2 @@ +# for now publish only /dev/video0 +KERNEL=="video0", RUN+="/usr/share/qubes-video-companion/sender/udev-handler" diff --git a/sender/webcam.py b/sender/webcam.py index 87247e3..92d5c22 100644 --- a/sender/webcam.py +++ b/sender/webcam.py @@ -6,10 +6,13 @@ """Webcam video source module""" +import atexit +import os import sys import re import subprocess from service import Service +import qubesdb class Webcam(Service): @@ -68,6 +71,8 @@ def parse_int(untrusted_decimal: bytes) -> int: self.untrusted_requested_height = 0 self.untrusted_requested_fps = 0 + self.pidfile = None + Service.main(self) def video_source(self) -> str: @@ -170,6 +175,23 @@ def pipeline(self, width: int, height: int, fps: int, **kwargs): "fdsink", ] + def _cleanup_connect_state(self): + qdb = qubesdb.QubesDB() + qdb.write(f"/webcam-devices/{self.port_id}/connected-to", "") + qdb.write("/webcam-devices", "") + if self.pidfile: + os.unlink(self.pidfile) + + def record_connect_state(self, remote_domain) -> None: + self.pidfile = f"/run/qubes/qvc-webcam-{self.port_id}" + with open(self.pidfile, "w", encoding="ascii") as f_pid: + f_pid.write(f"{os.getpid()}\n") + + qdb = qubesdb.QubesDB() + qdb.write(f"/webcam-devices/{self.port_id}/connected-to", remote_domain) + qdb.write("/webcam-devices", "") + atexit.register(self._cleanup_connect_state) + if __name__ == "__main__": _untrusted_arg = "" From 4a1e540a07165ebe3d58b7d8306fab57fbf77c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 19 Nov 2025 15:11:45 +0100 Subject: [PATCH 4/7] Add format selection to qvm-device integration 1. Publish list of supported formats in qubesdb 2. Collect them and put as .data["formats"] attribute 3. Accept"format" assignment option 4. When "format" option is specified, add it to the qrexec call argument (both for qvc.WebcamAttach, then in the policy for qvc.Webcam) QubesOS/qubes-issues#10411 --- Makefile | 1 + core3ext/qvc/__init__.py | 55 +++++++++++++++-- rpm_spec/qubes-video-companion-dom0.spec.in | 1 + rpm_spec/qubes-video-companion.spec.in | 1 + scripts/udev-handler | 1 + scripts/webcam-formats/webcam_formats.py | 66 ++++++++++++++++++++- 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 6022370..2c23d0c 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ install-both: $(DESTDIR)$(QREXECSERVICEDIR) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender $(INSTALL_PROGRAM) sender/*.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender + $(INSTALL_PROGRAM) scripts/webcam-formats/webcam_formats.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender/ $(INSTALL_PROGRAM) scripts/udev-handler $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender/ $(INSTALL_PROGRAM) sender/udev.rules $(DESTDIR)$(UDEVDIR)/80-qubes-video-companion-sender.rules $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/doc/$(PKGNAME) diff --git a/core3ext/qvc/__init__.py b/core3ext/qvc/__init__.py index 377eea2..263b7d5 100644 --- a/core3ext/qvc/__init__.py +++ b/core3ext/qvc/__init__.py @@ -19,7 +19,7 @@ name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") connected_to_re = re.compile(rb"^[a-zA-Z][a-zA-Z0-9_.-]*$") - +format_re = re.compile(r"\A^[1-9][0-9]{0,3}x[1-9][0-9]{0,3}x[1-9][0-9]{0,2}\Z") class WebcamDevice(qubes.device_protocol.DeviceInfo): def __init__(self, port: qubes.device_protocol.Port): @@ -33,7 +33,7 @@ def __init__(self, port: qubes.device_protocol.Port): self._qdb_path = f"/webcam-devices/{port.port_id}" - # TODO: available res, fps + self._formats = None @property def interfaces(self) -> List[DeviceInterface]: @@ -158,6 +158,38 @@ def attachment(self): return None return connected_to + @property + def formats(self): + if self._formats is None: + untrusted_formats = self.backend_domain.untrusted_qdb.multiread( + self._qdb_path + "/formats/" + ) + formats = [] + for _, untrusted_format in untrusted_formats.items(): + untrusted_format = untrusted_format.decode("ascii", errors="strict") + if not format_re.match(untrusted_format): + self.backend_domain.log.warning("Invalid format") + continue + formats.append(untrusted_format) + self._formats = " ".join(formats) + return self._formats + + @property + def data(self): + """Return extra attributes for serialization""" + return { + "formats": self.formats, + } + + @data.setter + def data(self, data): + """Setter for 'data' attribute, to make DeviceInfo class happy""" + # not really used, but DeviceInfo constructor tries to set it, + # so accept (and ignore) empty value + if data == {}: + return + raise AttributeError("read-only attribute data") + class QVCNotInstalled(QubesException): pass @@ -300,9 +332,14 @@ async def on_device_pre_attach_webcam(self, vm, event, device, options): arg = device.port_id - # TODO: options for res/fps if options: - raise QubesException("Options not supported") + for option, value in options.items(): + if option == "format": + if not format_re.match(value): + raise QubesException("Invalid format value") + arg += "+" + value.replace("x", "+") + else: + raise QubesException("Unsupported option: '{}'".format(option)) if not vm.is_running() or vm.qid == 0: # print(f"Qube is not running, skipping attachment of {device}", @@ -376,8 +413,14 @@ async def on_device_detach_webcam(self, vm, event, port): @qubes.ext.handler("device-pre-assign:webcam") async def on_device_assign_webcam(self, vm, event, device, options): # pylint: disable=unused-argument - pass - # TODO: verify options + # validate options + if options: + for option, value in options.items(): + if option == "format": + if not format_re.match(value): + raise QubesException("Invalid format value") + else: + raise QubesException("Unsupported option: '{}'".format(option)) @qubes.ext.handler("domain-start") async def on_domain_start(self, vm, _event, **_kwargs): diff --git a/rpm_spec/qubes-video-companion-dom0.spec.in b/rpm_spec/qubes-video-companion-dom0.spec.in index 0cbea22..c585a1d 100644 --- a/rpm_spec/qubes-video-companion-dom0.spec.in +++ b/rpm_spec/qubes-video-companion-dom0.spec.in @@ -87,6 +87,7 @@ popd /usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py +%{_datadir}/qubes-video-companion/sender/webcam_formats.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py %{_datadir}/qubes-video-companion/sender/udev-handler diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index 2dada7e..a8e4266 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -70,6 +70,7 @@ This package contains the video-sending portion of Qubes Video Companion. /usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py +%{_datadir}/qubes-video-companion/sender/webcam_formats.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py %{_datadir}/qubes-video-companion/sender/udev-handler diff --git a/scripts/udev-handler b/scripts/udev-handler index 433aaa0..d15e72f 100644 --- a/scripts/udev-handler +++ b/scripts/udev-handler @@ -40,6 +40,7 @@ fi if [ "$ACTION" = "add" ]; then qubesdb-write "/webcam-devices/$portid/connected-to" "" fi +python3 /usr/share/qubes-video-companion/sender/webcam_formats.py --portid="$portid" --device="$DEVNAME" publish # trigger watch qubesdb-write "/webcam-devices" "" diff --git a/scripts/webcam-formats/webcam_formats.py b/scripts/webcam-formats/webcam_formats.py index 9424409..2e58e00 100755 --- a/scripts/webcam-formats/webcam_formats.py +++ b/scripts/webcam-formats/webcam_formats.py @@ -5,8 +5,10 @@ """Get and compare supported webcam formats""" +import argparse import subprocess from collections import OrderedDict +import qubesdb class WebcamFormats: @@ -37,7 +39,7 @@ def __init__(self, formats, video_device="/dev/video0"): while self.__line_idx < self.formats_len: line = self.formats[self.__line_idx] - if line.startswith("Index"): + if line.startswith("["): self.__index() self.__line_idx = self.__line_idx + 1 @@ -51,9 +53,9 @@ def __index(self): while self.__line_idx < self.formats_len: line = self.formats[self.__line_idx] - if line.startswith("Pixel Format"): + if line.startswith("["): # Remove removing surrounding single quotes (') junk - pix_fmt = line.split()[2].replace("'", "") + pix_fmt = line.split()[1].replace("'", "") self.pix_fmt[pix_fmt] = {} if line.startswith("Size"): @@ -137,6 +139,24 @@ def find_best_format(self): self.selected_fps = current_selected_fps break + def publish_formats_info(self, portid): + qdb = qubesdb.QubesDB() + prefix = f"/webcam-devices/{portid}" + # remove old entries + qdb.rm(prefix + "/formats/") + formats = ( + (w, h, fps) + for pix_fmt, size_dict in self.pix_fmt.items() + for (w, h), fps_list in size_dict.items() + for fps in fps_list + ) + format_nr = 0 + for width, height, fps in sorted(set(formats)): + qdb.write(f"{prefix}/formats/{format_nr:02d}", + f"{width}x{height}x{fps}") + format_nr += 1 + + def configure_webcam_best_format(self): """Configure webcam device to use the best format""" @@ -162,3 +182,43 @@ def configure_webcam_best_format(self): ], check=True, ) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--device", help="/dev/video* device path") + parser.add_argument("--portid", + help="QVC device portid for publishing in qubesdb") + parser.add_argument("action", help="Action to perform; supported: publish") + + args = parser.parse_args() + + if args.action != "publish": + parser.error("Unsupported action: " + args.action) + + if args.device and not args.portid: + parser.error( + "portid mandatory for publish action, if alternative device is set" + ) + + if not args.device: + args.device = "/dev/video0" + args.portid = "dev-video0" + + webcam_supported_formats = ( + subprocess.run( + ["v4l2-ctl", "--device", args.device, "--list-formats-ext"], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.decode("utf-8") + .replace("\t", "") + .splitlines() + ) + + webcam_settings = WebcamFormats(webcam_supported_formats, args.device) + webcam_settings.publish_formats_info(args.portid) + + + +if __name__ == "__main__": + main() From 6f2c3eb0b5f3c01d64285af00587142682897d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 19 Nov 2025 20:17:17 +0100 Subject: [PATCH 5/7] tests: skip webcam tests on Whonix earlier Connecting Webcam on whonix is not very useful (defeats most of privacy). Whonix prevents loading vivid module, so test won't work anyway. Skip it earlier to save starting/stopping VMs. --- tests/qvctests/integ.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/qvctests/integ.py b/tests/qvctests/integ.py index 6b8a117..852e0be 100644 --- a/tests/qvctests/integ.py +++ b/tests/qvctests/integ.py @@ -8,6 +8,8 @@ class TC_00_QVCTest(qubes.tests.extra.ExtraTestCase): def setUp(self): super(TC_00_QVCTest, self).setUp() + if "webcam" in self.id() and "whonix" in str(self.template): + self.skipTest("Cannot load 'vivid' module on Whonix") self.source, self.view = self.create_vms( ["source", "view"]) self.source.start() From ed742373302f20a44b6ee4a36fb189c01f121f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 19 Nov 2025 20:40:50 +0100 Subject: [PATCH 6/7] tests: qvm-device integration Similar to other webcam tests, but use qvm-device for attach/detach. QubesOS/qubes-issues#10411 --- tests/qvctests/integ.py | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/qvctests/integ.py b/tests/qvctests/integ.py index 852e0be..9f1fdf2 100644 --- a/tests/qvctests/integ.py +++ b/tests/qvctests/integ.py @@ -4,6 +4,12 @@ import qubes.tests.extra +try: + import qvc + have_qvc = True +except ImportError: + have_qvc = False + class TC_00_QVCTest(qubes.tests.extra.ExtraTestCase): def setUp(self): @@ -25,6 +31,15 @@ def wait_for_video0(self, vm): self.assertEqual(retcode, 0, f"Timeout waiting for /dev/video0 in {vm.name}") + def wait_for_webcam(self, vm): + timeout = 30 + for i in range(timeout): + if len(list(vm._vm.devices["webcam"].get_exposed_devices())): + break + self.loop.run_until_complete(asyncio.sleep(0.5)) + else: + self.fail(f"Timeout waiting for webcam device in {vm.name}") + def wait_for_video0_disconnect(self, vm): vm.run( 'for i in `seq 30`; do ' @@ -134,6 +149,12 @@ def fill_black(x1, y1, x2, y2): return bytes(image) + def get_default_video_format(self, vm, device="/dev/video0"): + """Get current format of a video device""" + cmd = f"v4l2-ctl -d {device} --get-fmt-video | grep -o '[0-9]\\+/[0-9]\\+'" + p = vm.run(cmd, passio_popen=True) + stdout, _ = p.communicate() + return stdout.decode().strip().split('/') def test_020_webcam(self): """Webcam test @@ -185,6 +206,96 @@ def test_020_webcam(self): f.write(destination_image) self.assertLess(diff, 2.5) + @unittest.skipIf(not have_qvc, "qvc not installed") + def test_021_webcam_qvm_device(self): + """Webcam test + + source -> view (webcam) + """ + self.loop.run_until_complete(self.wait_for_session(self.source)) + self.view.start() + self.loop.run_until_complete(self.wait_for_session(self.view)) + ret = self.source.run("modprobe vivid", user="root", wait=True) + if ret != 0: + self.skipTest("Cannot load 'vivid' module") + # wait for device to appear, or a timeout + self.wait_for_webcam(self.source) + + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "attach", self.view.name, + f"{self.source.name}:dev-video0", + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0(self.view) + + destination_image = self.capture_from_video(self.view) + destination_image_dims = self.get_default_video_format(self.view) + destination_image = self.apply_mask(destination_image) + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "detach", self.view.name, + f"{self.source.name}:dev-video0" + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0_disconnect(self.view) + + # vivid supports only one client at a time, so capture source only + # after QVC disconnects + source_image = self.capture_from_video( + self.source, ",width={},height={}".format(*destination_image_dims) + ) + source_image = self.apply_mask(source_image) + diff = self.compare_images(source_image, destination_image) + if diff >= 2.5: + with open(f"/tmp/window-dump-{self.id()}-source", "wb") as f: + f.write(source_image) + with open(f"/tmp/window-dump-{self.id()}-dest", "wb") as f: + f.write(destination_image) + self.assertLess(diff, 2.5) + + @unittest.skipIf(not have_qvc, "qvc not installed") + def test_022_webcam_qvm_device_resolution(self): + """Webcam test + + source -> view (webcam) + """ + self.loop.run_until_complete(self.wait_for_session(self.source)) + self.view.start() + self.loop.run_until_complete(self.wait_for_session(self.view)) + ret = self.source.run("modprobe vivid", user="root", wait=True) + if ret != 0: + self.skipTest("Cannot load 'vivid' module") + # wait for device to appear, or a timeout + self.wait_for_webcam(self.source) + + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "attach", self.view.name, + f"{self.source.name}:dev-video0", + "-o", "format=640x480x30", + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0(self.view) + + destination_image = self.capture_from_video(self.view) + destination_image = self.apply_mask(destination_image) + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "detach", self.view.name, + f"{self.source.name}:dev-video0" + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0_disconnect(self.view) + + # vivid supports only one client at a time, so capture source only + # after QVC disconnects + source_image = self.capture_from_video(self.source, ",width=640,height=480") + source_image = self.apply_mask(source_image) + diff = self.compare_images(source_image, destination_image) + if diff >= 2.5: + with open(f"/tmp/window-dump-{self.id()}-source", "wb") as f: + f.write(source_image) + with open(f"/tmp/window-dump-{self.id()}-dest", "wb") as f: + f.write(destination_image) + self.assertLess(diff, 2.5) + def list_tests(): return ( From daa259196b1611f5b6fc919a14e93cb51730e4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 19 Nov 2025 21:00:48 +0100 Subject: [PATCH 7/7] ci: add interface mockup for pylint --- .gitlab-ci.yml | 1 + ci/test-packages/qubesdb.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 ci/test-packages/qubesdb.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7959fa4..a9b8e6c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,6 +12,7 @@ checks:pylint: before_script: - sudo dnf install -y python3-gobject gtk3 python3-pylint script: + - export PYTHONPATH=ci/test-packages - pylint-3 $(find sender receiver scripts -type f -name '*.py') include: diff --git a/ci/test-packages/qubesdb.py b/ci/test-packages/qubesdb.py new file mode 100644 index 0000000..5cd8880 --- /dev/null +++ b/ci/test-packages/qubesdb.py @@ -0,0 +1,12 @@ +class QubesDB: + def read(self, key): + return b'testvm' + + def rm(self, key): + pass + + def write(self, key, value): + pass + +class Error(Exception): + pass