public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC v2 pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console
Date: Wed, 26 Aug 2026 09:43:40 +0200	[thread overview]
Message-ID: <20260826074347.1256659-9-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com>

Use the Kyber SDK with custom adapters to handle qemu output (avservice)
&& inputs (inputservice).

It's also provide clipboard && audio support

Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
 .gitignore                                    |   12 +
 .gitmodules                                   |    3 +
 Makefile                                      |  130 ++
 avservice/Cargo.lock                          | 1125 +++++++++
 avservice/Cargo.toml                          |   24 +
 avservice/src/main.rs                         |  535 +++++
 debian/changelog                              |    5 +
 debian/control                                |   49 +
 debian/copyright                              |   20 +
 debian/install                                |    2 +
 debian/rules                                  |   29 +
 debian/source/format                          |    1 +
 inputservice/Cargo.lock                       | 2063 +++++++++++++++++
 inputservice/Cargo.toml                       |   30 +
 inputservice/src/clipboard.rs                 |  318 +++
 inputservice/src/main.rs                      |  494 ++++
 kyber-desktop                                 |    1 +
 kyber-qemu-server.mk                          |   56 +
 kycontroller.wrapper                          |   13 +
 ...-reset-log-component-count-on-uninit.patch |   12 +
 ...ller-configure-from-the-command-line.patch |  353 +++
 src/audio.c                                   |  494 ++++
 src/dmabuf.c                                  |  701 ++++++
 src/kqs.h                                     |  194 ++
 src/listener.c                                |  568 +++++
 src/main.c                                    |  193 ++
 src/sink.c                                    |  483 ++++
 src/surface.c                                 |  188 ++
 28 files changed, 8096 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 .gitmodules
 create mode 100644 Makefile
 create mode 100644 avservice/Cargo.lock
 create mode 100644 avservice/Cargo.toml
 create mode 100644 avservice/src/main.rs
 create mode 100644 debian/changelog
 create mode 100644 debian/control
 create mode 100644 debian/copyright
 create mode 100644 debian/install
 create mode 100755 debian/rules
 create mode 100644 debian/source/format
 create mode 100644 inputservice/Cargo.lock
 create mode 100644 inputservice/Cargo.toml
 create mode 100644 inputservice/src/clipboard.rs
 create mode 100644 inputservice/src/main.rs
 create mode 160000 kyber-desktop
 create mode 100644 kyber-qemu-server.mk
 create mode 100755 kycontroller.wrapper
 create mode 100644 patches/0001-txproto-reset-log-component-count-on-uninit.patch
 create mode 100644 patches/0002-kycontroller-configure-from-the-command-line.patch
 create mode 100644 src/audio.c
 create mode 100644 src/dmabuf.c
 create mode 100644 src/kqs.h
 create mode 100644 src/listener.c
 create mode 100644 src/main.c
 create mode 100644 src/sink.c
 create mode 100644 src/surface.c

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2229803
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# Assembled by `make staging` from a built Kyber SDK; not vendored here.
+/staging/
+/kyber-qemu-server
+/src/*.o
+/avservice/target/
+/inputservice/target/
+/pve-kyber-[0-9]*/
+# the SDK writes these beside whatever runs it
+log/
+*.deb
+*.buildinfo
+*.changes
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f7af402
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "kyber-desktop"]
+	path = kyber-desktop
+	url = https://gitlab.com/kyber/apps/kyber-desktop.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..92dd711
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,130 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-qemu-kyber
+ARCH:=$(DEB_HOST_ARCH)
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+# Kyber's SDK: AGPL-3.0-or-later like this package, but a separate upstream
+# with its own cadence, so it is cloned at build time rather than vendored or
+# carried as a submodule. This repo then holds nothing but packaging, and the
+# revision it builds is one greppable line rather than a gitlink.
+#
+# Pinned by commit, so a moved tag cannot change what this builds.
+KYBER_DIR = kyber-desktop
+ROOTFS = $(CURDIR)/$(KYBER_DIR)/rootfs-x86_64-linux-gnu
+
+export PKG_CONFIG_PATH := $(ROOTFS)/lib/pkgconfig:$(ROOTFS)/lib/x86_64-linux-gnu/pkgconfig:$(ROOTFS)/lib64/pkgconfig
+export LD_LIBRARY_PATH := $(ROOTFS)/lib:$(ROOTFS)/lib/x86_64-linux-gnu:$(ROOTFS)/lib64
+
+all: $(DEB)
+
+# --- upstream ---------------------------------------------------------------
+# Fetched once, and left alone after that, following pve-qemu: a build must not
+# depend on re-fetching, and a checkout someone has been working in is theirs.
+#
+# The patch below is what needs the tree pristine, so it resets just the files
+# it touches rather than the whole submodule - which would discard the build
+# output beside them for nothing.
+.PHONY: submodule
+submodule:
+ifeq ($(shell test -f "$(KYBER_DIR)/Cargo.toml" && echo 1 || echo 0), 0)
+	git submodule update --init --recursive $(KYBER_DIR)
+endif
+
+# 0002 is what the per-VM design rests on: a controller configured from its
+# command line. 0001 is a txproto fix. Each applies in the repo it was cut from.
+.PHONY: patch
+patch: submodule
+	git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto checkout --force -- .
+	git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto apply $(CURDIR)/patches/0001-*.patch
+	git -C $(KYBER_DIR)/kysdk/kyctl checkout --force -- .
+	git -C $(KYBER_DIR)/kysdk/kyctl apply $(CURDIR)/patches/0002-*.patch
+
+# --- build ------------------------------------------------------------------
+# Long: FFmpeg and VLC are built from source. The SDK's own script does it, and
+# needs cargo-c and meson 1.10+ - see debian/control.
+.PHONY: sdk
+sdk: patch
+	cd $(KYBER_DIR) && ./build-linux.sh -o $(ROOTFS)
+
+# The adapters stand in for Kyber's own capture and input servers, and
+# kyber-qemu-server is what actually talks to QEMU.
+.PHONY: adapters
+adapters: sdk
+	cd avservice && cargo build --release
+	cd inputservice && cargo build --release
+
+# TX_RPATH empty: an rpath here would be the build tree, and the wrapper
+# already puts the package prefix on LD_LIBRARY_PATH.
+.PHONY: server
+server: sdk
+	$(MAKE) -f kyber-qemu-server.mk TX_RPATH= \
+	    TX_INC=$(ROOTFS)/include TX_LIB=$(ROOTFS)/lib/x86_64-linux-gnu
+
+.PHONY: build
+build: adapters server
+
+# --- packaging --------------------------------------------------------------
+# Only the libraries the four binaries actually resolve against: the build tree
+# also carries a client, a player and VLC plugins a node never loads.
+.PHONY: staging
+staging: build
+	rm -rf staging
+	mkdir -p staging/bin staging/lib
+	install -m 0755 $(ROOTFS)/bin/kycontroller staging/bin/
+	install -m 0755 kyber-qemu-server staging/bin/
+	install -m 0755 avservice/target/release/kqs-avservice staging/bin/
+	install -m 0755 inputservice/target/release/kqs-inputservice staging/bin/
+	ln -sf kqs-avservice staging/bin/kyavserver
+	ln -sf kqs-inputservice staging/bin/kynputserver
+	for b in staging/bin/kycontroller staging/bin/kyber-qemu-server \
+	         staging/bin/kqs-avservice staging/bin/kqs-inputservice; do \
+	    ldd $$b | awk "/=> \//{print \$$3}"; \
+	done | sort -u | grep "^$(ROOTFS)" | xargs -r cp -aL -t staging/lib/
+
+.PHONY: builddir
+builddir:
+	rm -rf $(BUILDDIR)
+	$(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): staging
+	rm -rf $@ $@.tmp
+	mkdir $@.tmp
+	cp -a staging debian Makefile kycontroller.wrapper $@.tmp/
+	mv $@.tmp $@
+
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+	lintian $(DEB) || true
+
+# A source package, for sbuild and for review: Proxmox builds every package
+# this way, so it has to work even when the binary path is what gets used.
+.PHONY: dsc
+dsc:
+	rm -rf $(BUILDDIR) $(DSC)
+	$(MAKE) $(DSC)
+	lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+	sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+	dpkg -i $(DEB)
+
+.PHONY: clean
+clean:
+	rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/ staging/
+	rm -f kyber-qemu-server src/*.o
+	cd avservice && cargo clean
+	cd inputservice && cargo clean
+
+.PHONY: distclean
+distclean: clean
diff --git a/avservice/Cargo.lock b/avservice/Cargo.lock
new file mode 100644
index 0000000..441c68d
--- /dev/null
+++ b/avservice/Cargo.lock
@@ -0,0 +1,1125 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kqs-avservice"
+version = "0.1.0"
+dependencies = [
+ "env_logger",
+ "kyavservice-types",
+ "libc",
+ "libkypc",
+ "log",
+ "tokio",
+ "zbus",
+]
+
+[[package]]
+name = "kyavservice-types"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libkypc"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "log",
+ "rmp-serde",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "windows-sys",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.3",
+ "winnow",
+]
diff --git a/avservice/Cargo.toml b/avservice/Cargo.toml
new file mode 100644
index 0000000..d65503f
--- /dev/null
+++ b/avservice/Cargo.toml
@@ -0,0 +1,24 @@
+# Thin adapter that lets kycontroller drive kyber-qemu-server.
+#
+# Written in Rust purely so it can link the real libkypc rather than
+# reimplement its MessagePack IPC. It owns no media code: on KymuxStartVideo
+# it launches the C server with the kymux URI the controller assigned.
+
+[package]
+name = "kqs-avservice"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "kqs-avservice"
+path = "src/main.rs"
+
+[dependencies]
+libkypc = { path = "../kyber-desktop/kysdk/kyutil/libkypc" }
+kyavservice-types = { path = "../kyber-desktop/kysdk/kymedia/kyavservice-types" }
+tokio = { version = "1", features = ["full"] }
+env_logger = "0.11"
+log = "0.4"
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+libc = "0.2"
diff --git a/avservice/src/main.rs b/avservice/src/main.rs
new file mode 100644
index 0000000..8abc8a4
--- /dev/null
+++ b/avservice/src/main.rs
@@ -0,0 +1,535 @@
+// kqs-avservice - lets Kyber's controller drive kyber-qemu-server.
+//
+// kycontroller does not talk to its AV server over a URI; it spawns a child,
+// passes a Unix socket address in KYBER_PARENT_ADDR, and issues MessagePack
+// commands over it. The kymux URI that video must be published to arrives
+// inside KymuxStartVideo, so nothing downstream can be wired up until we
+// answer that conversation.
+//
+// Rather than reimplement that protocol in C, this adapter links the real
+// libkypc and translates. It carries no media code at all: on KymuxStartVideo
+// it launches the C server with the URI it was handed.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// (AGPL because it links libkypc; the C server stays LGPL in its own process.)
+
+use std::os::unix::process::CommandExt;
+use std::process::{Child, Command as SysCommand, Stdio};
+
+use kyavservice_types::{Command, Display, Event};
+use log::{error, info, warn};
+
+/// Where to find the C server. Overridable so it can be run from a build tree.
+fn server_binary() -> String {
+    std::env::var("KQS_SERVER_BIN").unwrap_or_else(|_| "kyber-qemu-server".to_string())
+}
+
+/// QEMU's D-Bus display name, matching kyber-qemu-server's own default.
+fn qemu_bus_name() -> String {
+    std::env::var("KQS_BUS_NAME").unwrap_or_else(|_| "org.qemu".to_string())
+}
+
+/// One display, the guest's console.
+fn qemu_display(width: i32, height: i32) -> Display {
+    Display {
+        id: 1,
+        name: "QEMU".to_string(),
+        width,
+        height,
+        x: 0,
+        y: 0,
+    }
+}
+
+/// Ask QEMU how big the guest console is.
+///
+/// The controller needs a display to offer before it will start video, and the
+/// client sizes its window from it. Falling back to a plausible default is
+/// better than refusing to enumerate: the guest can resize later and the
+/// stream carries its own dimensions regardless.
+async fn enumerate_qemu_displays() -> Vec<Display> {
+    const FALLBACK: (i32, i32) = (1024, 768);
+
+    // The advertised display must be the size the stream actually is. The
+    // client sizes its window and its video surface from it and does not
+    // rescale to fit, so advertising the guest's true 720x400 while sending a
+    // 1280x800 stream simply crops the right and bottom off the picture.
+    //
+    // With a fixed encoder geometry that means advertising the encoder's size
+    // and letting boot modes arrive stretched. Only when the encoder follows
+    // the guest (--width 0) do the two coincide, and then the guest's own size
+    // is the right thing to report.
+    let fixed = match (stream_dim("KQS_OUT_W", 1280), stream_dim("KQS_OUT_H", 800)) {
+        _ if follow_guest() => None,
+        (Ok(w), Ok(h)) if w > 0 && h > 0 => Some((w, h)),
+        _ => None,
+    };
+
+    let (width, height) = match fixed {
+        Some((w, h)) => {
+            info!("advertising fixed stream geometry {w}x{h}");
+            (w, h)
+        }
+        None => match query_console_size().await {
+            Ok(wh) => {
+                info!("QEMU console is {}x{}", wh.0, wh.1);
+                wh
+            }
+            Err(e) => {
+                warn!(
+                    "could not read QEMU console size ({e}); offering {}x{}",
+                    FALLBACK.0, FALLBACK.1
+                );
+                FALLBACK
+            }
+        },
+    };
+
+    vec![qemu_display(width, height)]
+}
+
+/// Watch the guest console and report resolution changes to the controller.
+///
+/// This is Kyber's own mechanism for host-side resolution changes: the AV
+/// service pushes a fresh display list, the controller broadcasts it to every
+/// client and forwards it to the input services as a HostConfig. The client
+/// then re-letterboxes the video and remaps its cursor into the new space.
+///
+/// It matters even though the encoder never changes size. The stream is
+/// stretched into a fixed geometry, so a 4:3 guest arrives as a 16:10 picture;
+/// telling the client the display is 4:3 makes it squeeze that picture back to
+/// the right shape, and makes the coordinates it sends land where the user
+/// pointed. Without this the aspect ratio is wrong for every guest mode that
+/// does not happen to match --width/--height.
+///
+/// Only the enumerate handler runs this: the controller spawns one AV service
+/// for display enumeration and keeps it alive precisely so it can watch, and
+/// separate ones per stream that should not also report.
+///
+/// This only runs when the encoder follows the guest. A reported display size
+/// has to match the size of the stream, because the client sizes its window and
+/// video surface from it and crops rather than rescales; reporting a guest
+/// resize the fixed-geometry encoder did not follow is exactly that mismatch.
+fn spawn_display_watcher(mut sender: libkypc::EventSender<Event>, initial: (i32, i32)) {
+    tokio::spawn(async move {
+        let mut last = initial;
+        loop {
+            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+            let Ok(cur) = query_console_size().await else {
+                continue;
+            };
+            if cur == last || cur.0 <= 0 || cur.1 <= 0 {
+                continue;
+            }
+
+            info!("guest resized to {}x{}; updating display list", cur.0, cur.1);
+            last = cur;
+
+            if let Err(e) = sender
+                .send(Event::DisplayListUpdated {
+                    displays: vec![qemu_display(cur.0, cur.1)],
+                })
+                .await
+            {
+                warn!("could not report display change: {e:?}");
+                return;
+            }
+        }
+    });
+}
+
+/// Whether to take QEMU's scanouts as dmabuf handles rather than pixels.
+///
+/// Only meaningful when QEMU runs a -gl display on a virgl-capable device;
+/// against a plain virtio-gpu the server waits for ScanoutDMABUF2 calls that
+/// never arrive. Off by default for that reason.
+fn dmabuf() -> bool {
+    matches!(std::env::var("KQS_DMABUF").as_deref(), Ok("1") | Ok("true"))
+}
+
+/// Fixed stream dimension from the environment, defaulting to the same value
+/// kyber-qemu-server uses. Zero means "follow the guest".
+fn stream_dim(var: &str, default: i32) -> Result<i32, std::num::ParseIntError> {
+    match std::env::var(var) {
+        Ok(v) => v.parse(),
+        Err(_) => Ok(default),
+    }
+}
+
+/// Whether the stream should follow the guest's resolution instead of running
+/// at a fixed size.
+///
+/// Kyber has handled dynamic host resolution since 0.10.0, and the controller
+/// never restarts video on a display change - so upstream changes the encoder's
+/// size inside the running stream and lets the client's decoder follow. We
+/// cannot do that in process (rebuilding the pipeline mid-session crashes in
+/// teardown), so the equivalent here is to relaunch kyber-qemu-server against
+/// the same kymux endpoint: the new encoder sends a fresh SPS and IDR, which is
+/// what the client actually has to cope with either way.
+///
+/// On by default: that re-negotiation is what a guest resolution change looks
+/// like in practice, and a stream that does not follow it shows the guest a
+/// letterboxed or clipped desktop for the rest of the session. Set
+/// `KQS_FOLLOW_GUEST=0` to pin the stream to a fixed size instead.
+fn follow_guest() -> bool {
+    !matches!(
+        std::env::var("KQS_FOLLOW_GUEST").as_deref(),
+        Ok("0") | Ok("false")
+    )
+}
+
+/// Connect to the QEMU that this instance is responsible for.
+///
+/// One QEMU per VM means one D-Bus per VM: with `-display dbus,p2p=on` each
+/// guest gets a private socket instead of a name on the shared session bus,
+/// where a second VM would simply collide on org.qemu. KQS_DBUS_ADDR carries
+/// that socket; without it this is the old single-VM behaviour.
+async fn qemu_connection() -> Result<zbus::Connection, zbus::Error> {
+    match std::env::var("KQS_DBUS_ADDR") {
+        Ok(addr) if !addr.is_empty() => {
+            zbus::connection::Builder::address(addr.as_str())?
+                .build()
+                .await
+        }
+        _ => zbus::Connection::session().await,
+    }
+}
+
+async fn query_console_size() -> Result<(i32, i32), Box<dyn std::error::Error>> {
+    let conn = qemu_connection().await?;
+    let proxy = zbus::Proxy::new(
+        &conn,
+        qemu_bus_name(),
+        "/org/qemu/Display1/Console_0",
+        "org.qemu.Display1.Console",
+    )
+    .await?;
+
+    let width: u32 = proxy.get_property("Width").await?;
+    let height: u32 = proxy.get_property("Height").await?;
+    Ok((width as i32, height as i32))
+}
+
+#[derive(Default)]
+struct VideoChild {
+    child: Option<Child>,
+    /// What the running child was started with, so it can be relaunched at a
+    /// new size without another command from the controller.
+    uri: String,
+    bitrate_kbps: u32,
+    /// Geometry the running encoder was built at, or None when it follows the
+    /// guest and settles on whatever the first frame is.
+    geometry: Option<(i32, i32)>,
+}
+
+/// The audio server, which is the same binary in its audio-only mode.
+///
+/// A process of its own rather than a mode of the video one: txproto's context
+/// is process-global (see the C server's sink.c), and Kyber gives audio its own
+/// kymux endpoint anyway, so the two share nothing.
+#[derive(Default)]
+struct AudioChild {
+    child: Option<Child>,
+}
+
+impl AudioChild {
+    fn stop(&mut self) {
+        if let Some(mut c) = self.child.take() {
+            info!("stopping audio server (pid {})", c.id());
+            let _ = c.kill();
+            let _ = c.wait();
+        }
+    }
+
+    fn start(&mut self, uri: &str) -> std::io::Result<()> {
+        self.stop();
+
+        let bin = server_binary();
+        info!("launching {bin} -> {uri} (audio)");
+
+        let mut cmd = SysCommand::new(&bin);
+        cmd.arg("--kymux-audio")
+            .arg(uri)
+            .arg("--bus-name")
+            .arg(qemu_bus_name())
+            .stdout(Stdio::inherit())
+            .stderr(Stdio::inherit());
+
+        if let Ok(addr) = std::env::var("KQS_DBUS_ADDR") {
+            if !addr.is_empty() {
+                cmd.arg("--address").arg(addr);
+            }
+        }
+
+        if let Ok(bps) = std::env::var("KQS_AUDIO_BITRATE") {
+            if !bps.is_empty() {
+                cmd.arg("--audio-bitrate").arg(bps);
+            }
+        }
+
+        // Same reasoning as the video child: die with the parent rather than
+        // outlive a controller that went away.
+        unsafe {
+            cmd.pre_exec(|| {
+                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
+                Ok(())
+            });
+        }
+
+        self.child = Some(cmd.spawn()?);
+        Ok(())
+    }
+}
+
+impl VideoChild {
+    fn stop(&mut self) {
+        if let Some(mut c) = self.child.take() {
+            info!("stopping kyber-qemu-server (pid {})", c.id());
+            let _ = c.kill();
+            let _ = c.wait();
+        }
+    }
+
+    fn start(&mut self, uri: &str, bitrate_kbps: u32) -> std::io::Result<()> {
+        self.uri = uri.to_string();
+        self.bitrate_kbps = bitrate_kbps;
+        self.spawn()
+    }
+
+    fn spawn(&mut self) -> std::io::Result<()> {
+        self.stop();
+
+        // Follow-the-guest starts unpinned and lets the first frame decide;
+        // every relaunch after that pins the size we resized to, so the encoder
+        // is built at it rather than at whatever frame arrives first.
+        let (w, h) = match (follow_guest(), self.geometry) {
+            (true, None) => (0, 0),
+            (true, Some(g)) => g,
+            (false, _) => (
+                stream_dim("KQS_OUT_W", 1280).unwrap_or(1280),
+                stream_dim("KQS_OUT_H", 800).unwrap_or(800),
+            ),
+        };
+
+        let bin = server_binary();
+        info!(
+            "launching {bin} -> {} at {} kbps ({})",
+            self.uri,
+            self.bitrate_kbps,
+            if w > 0 { format!("{w}x{h}") } else { "follow guest".into() }
+        );
+
+        let mut cmd = SysCommand::new(&bin);
+        cmd.arg("--kymux")
+            .arg(&self.uri)
+            .arg("--bitrate")
+            .arg(self.bitrate_kbps.to_string())
+            // Same geometry we advertised, so the client's coordinate space
+            // and the encoder's output agree.
+            .arg("--width")
+            .arg(w.to_string())
+            .arg("--height")
+            .arg(h.to_string())
+            .arg("--bus-name")
+            .arg(qemu_bus_name())
+            .stdout(Stdio::inherit())
+            .stderr(Stdio::inherit());
+
+        // Zero copy. Only usable when QEMU was started with a -gl display and a
+        // virgl-capable device, because that is what makes it hand scanouts
+        // over as dmabuf handles instead of pixels; against a plain virtio-gpu
+        // the server would sit waiting for ScanoutDMABUF2 calls that never
+        // come. It also switches the encoder to VAAPI, so it needs a render
+        // node that can encode H.264.
+        if let Ok(addr) = std::env::var("KQS_DBUS_ADDR") {
+            if !addr.is_empty() {
+                cmd.arg("--address").arg(addr);
+            }
+        }
+
+        if dmabuf() {
+            cmd.arg("--dmabuf");
+            if let Ok(node) = std::env::var("KQS_RENDER_NODE") {
+                cmd.arg("--render-node").arg(node);
+            }
+        }
+
+        // stop() covers the exits we choose to make. It does not cover the
+        // controller terminating us, which is how an AV service usually ends -
+        // and a kyber-qemu-server that outlives its parent is not idle: it keeps
+        // a D-Bus listener on the console and keeps encoding into a kymux
+        // endpoint nobody reads. Left alone they accumulate one per resolution
+        // change, all logging their own geometry into the same file, which is a
+        // good way to misread a log.
+        //
+        // PDEATHSIG has the kernel do it instead, and unlike a signal handler it
+        // still works if we are killed outright.
+        unsafe {
+            cmd.pre_exec(|| {
+                if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
+                    return Err(std::io::Error::last_os_error());
+                }
+                Ok(())
+            });
+        }
+
+        let child = cmd.spawn()?;
+
+        info!("kyber-qemu-server running as pid {}", child.id());
+        self.child = Some(child);
+        Ok(())
+    }
+}
+
+/// Relaunch the video server whenever the guest changes resolution.
+///
+/// Runs in the AV service that owns the stream. The one handling enumeration
+/// reports the same change as a DisplayListUpdated, so the client learns the
+/// new size and the new picture arrive together - which is the pairing the
+/// client actually requires.
+fn spawn_stream_follower(video: std::sync::Arc<tokio::sync::Mutex<VideoChild>>) {
+    tokio::spawn(async move {
+        let mut last: Option<(i32, i32)> = None;
+        loop {
+            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+            let Ok(cur) = query_console_size().await else {
+                continue;
+            };
+            if cur.0 <= 0 || cur.1 <= 0 {
+                continue;
+            }
+            if last.is_none() {
+                last = Some(cur);
+                continue;
+            }
+            if last == Some(cur) {
+                continue;
+            }
+            last = Some(cur);
+
+            let mut v = video.lock().await;
+            if v.child.is_none() {
+                continue;
+            }
+
+            // Relaunching our own child against the same kymux endpoint does
+            // not work: the client has already latched the first producer's
+            // stream and ignores the replacement, so the picture goes black.
+            //
+            // Exit instead. The controller notices the AV service stopped and
+            // drives a real restart, which is the only way the client rebuilds
+            // its decode pipeline - Kyber's README calls restarting the video
+            // server on a topology change simpler than reconfiguring live, and
+            // this is that restart done through the controller rather than
+            // behind its back.
+            info!("guest now {}x{}; exiting so the controller restarts video", cur.0, cur.1);
+            v.stop();
+            std::process::exit(0);
+        }
+    });
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+    info!("kqs-avservice starting; server binary = {}", server_binary());
+
+    let mut worker = libkypc::process::connect_to_commander::<Command, Event>().await?;
+    let (mut receiver, sender) = worker.get_sender_receiver()?;
+
+    let video = std::sync::Arc::new(tokio::sync::Mutex::new(VideoChild::default()));
+    let audio = std::sync::Arc::new(tokio::sync::Mutex::new(AudioChild::default()));
+    let mut watching = false;
+    let mut following = false;
+
+    while let Ok((cmd_id, cmd)) = receiver.recv().await {
+        match cmd {
+            Command::EnumerateDisplays => {
+                let displays = enumerate_qemu_displays().await;
+                let advertised = displays
+                    .first()
+                    .map(|d| (d.width, d.height))
+                    .unwrap_or((0, 0));
+
+                receiver
+                    .accept(cmd_id, Some(Event::DisplayList { displays }))
+                    .await?;
+
+                if !watching && follow_guest() {
+                    spawn_display_watcher(sender.clone(), advertised);
+                    watching = true;
+                }
+            }
+
+            Command::KymuxStartVideo {
+                uri,
+                bitrate,
+                display_id,
+                ..
+            } => {
+                info!("KymuxStartVideo for display {display_id}: {uri}");
+
+                // Kyber speaks bits per second; our server takes kbps.
+                let started = video.lock().await.start(&uri, (bitrate / 1000).max(1));
+                match started {
+                    Ok(()) => {
+                        if !following && follow_guest() {
+                            spawn_stream_follower(video.clone());
+                            following = true;
+                        }
+                        receiver.accept(cmd_id, None).await?
+                    }
+                    Err(e) => {
+                        error!("could not launch video server: {e}");
+                        receiver.reject(cmd_id, None).await?;
+                    }
+                }
+            }
+
+            // Only reached when the client asked for audio and the VM has a
+            // -audiodev dbus to capture from; otherwise the server exits and
+            // says why.
+            Command::KymuxStartAudio { uri } => {
+                info!("KymuxStartAudio: {uri}");
+                match audio.lock().await.start(&uri) {
+                    Ok(()) => receiver.accept(cmd_id, None).await?,
+                    Err(e) => {
+                        error!("could not launch audio server: {e}");
+                        receiver.reject(cmd_id, None).await?;
+                    }
+                }
+            }
+
+            Command::VideoSetBitrate { bitrate } => {
+                warn!("bitrate change to {bitrate} ignored (no runtime control yet)");
+                receiver.accept(cmd_id, None).await?;
+            }
+
+            Command::VideoForceIdr => {
+                warn!("force-IDR ignored (no runtime control yet)");
+                receiver.accept(cmd_id, None).await?;
+            }
+
+            Command::Stop => {
+                info!("Stop");
+                video.lock().await.stop();
+                audio.lock().await.stop();
+                receiver.accept(cmd_id, None).await?;
+                break;
+            }
+
+            other => {
+                warn!("unhandled command: {other:?}");
+                receiver.reject(cmd_id, None).await?;
+            }
+        }
+    }
+
+    video.lock().await.stop();
+    audio.lock().await.stop();
+    info!("kqs-avservice exiting");
+    Ok(())
+}
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..a2a0eac
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-qemu-kyber (0.1.0) trixie; urgency=medium
+
+  * Initial release: Kyber console controller and the QEMU adapters.
+
+ -- Proxmox Support Team <support@proxmox.com>  Tue, 18 Aug 2026 19:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..8b9a762
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,49 @@
+Source: pve-qemu-kyber
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+                build-essential,
+                clang,
+                cmake,
+                git,
+                libclang-dev,
+                libdrm-dev,
+                libevdev-dev,
+                libgbm-dev,
+                libinput-dev,
+                liblcms2-dev,
+                liblua5.4-dev,
+                libpulse-dev,
+                libssl-dev,
+                libudev-dev,
+                libvulkan-dev,
+                libwayland-dev,
+                libxcb-shape0-dev,
+                libxcb-xfixes0-dev,
+                libxkbcommon-dev,
+                nasm,
+                ninja-build,
+                pkgconf,
+                python3-venv,
+                wayland-protocols,
+Standards-Version: 4.7.0.0
+
+Package: pve-qemu-kyber
+Architecture: any
+Depends: ${misc:Depends},
+         ${shlibs:Depends},
+Recommends: pve-kyberproxy,
+Description: Kyber console controller and QEMU adapters
+ The per-VM half of the Kyber console: a controller, and the two adapters that
+ stand in for Kyber own capture and input servers so that a QEMU guest is
+ streamed instead of a physical screen.
+ .
+ The controller is started per VM by qemu-server, listens on a unix socket for
+ its control plane, and is reached from a browser only through pveproxy and
+ pvekyberproxy. It spawns the adapters, which drive QEMU over its D-Bus
+ display.
+ .
+ Everything lives under /usr/lib/pve-qemu-kyber, including a bundled FFmpeg: the
+ Kyber SDK pins versions of its own and must not shadow the system ones.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..82f4df4
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-qemu-kyber
+
+Files: *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License
+ along with this program.  If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..0c8d04a
--- /dev/null
+++ b/debian/install
@@ -0,0 +1,2 @@
+staging/bin/* usr/lib/pve-qemu-kyber/bin/
+staging/lib/* usr/lib/pve-qemu-kyber/lib/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..e9aed14
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,29 @@
+#!/usr/bin/make -f
+
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+export DEB_BUILD_MAINT_OPTIONS = hardening=-all
+
+%:
+	dh $@
+
+override_dh_update_autotools_config:
+
+# The SDK, the adapters and kyber-qemu-server are all built by the top-level
+# Makefile before dpkg-buildpackage is called, which is also what stages them.
+override_dh_auto_build:
+override_dh_auto_test:
+override_dh_auto_clean:
+
+override_dh_install:
+	dh_install
+	install -D -m 0755 kycontroller.wrapper debian/pve-qemu-kyber/usr/bin/kycontroller
+
+# The bundled libraries are the SDK own pinned builds and are not meant to
+# satisfy anything outside this prefix.
+override_dh_shlibdeps:
+	dh_shlibdeps -l/usr/lib/pve-qemu-kyber/lib -Xusr/lib/pve-qemu-kyber/lib
+
+override_dh_strip:
+override_dh_dwz:
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/inputservice/Cargo.lock b/inputservice/Cargo.lock
new file mode 100644
index 0000000..ed6c81c
--- /dev/null
+++ b/inputservice/Cargo.lock
@@ -0,0 +1,2063 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
+dependencies = [
+ "android_log-sys",
+ "env_filter 0.1.4",
+ "log",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arraydeque"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0ffd3d69bd89910509a5d31d1f1353f38ccffdd116dd0099bbd6627f7bd8ad8"
+
+[[package]]
+name = "arrayvec"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9"
+dependencies = [
+ "nodrop",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enum-iterator"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
+dependencies = [
+ "enum-iterator-derive",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter 2.0.0",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "evdev-rs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d28ab5638ed883532ae91b8f0e8b5ffa6e7296c0127855d6f8f9c0a1f468889a"
+dependencies = [
+ "bitflags 2.13.1",
+ "evdev-sys",
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "evdev-sys"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcf0d489f4d9a80ac2b3b35b92fdd8fcf68d33bb67f947afe5cd36e482de576"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "input"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9"
+dependencies = [
+ "bitflags 2.13.1",
+ "input-sys",
+ "libc",
+ "log",
+ "udev",
+]
+
+[[package]]
+name = "input-linux-sys"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c7ef95c35c8ef8d114f5e197a5ac9554dc4afdd19ae78ae1fd0fc0944cb1340"
+dependencies = [
+ "libc",
+ "nix",
+]
+
+[[package]]
+name = "input-sys"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7"
+
+[[package]]
+name = "io-kit-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
+dependencies = [
+ "core-foundation-sys",
+ "mach2",
+]
+
+[[package]]
+name = "io-lifetimes"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keycode"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b07873c3182aec8a0eb1a5a4e7b197d42e9d167ba78497a6ee932a82d94673ed"
+dependencies = [
+ "arraydeque",
+ "arrayvec",
+ "bitflags 1.3.2",
+ "keycode_macro",
+]
+
+[[package]]
+name = "keycode_macro"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e521ea802f5b3c7194e169d75cab431b0ff08d022f2b6047b08754b4988b89df"
+dependencies = [
+ "anyhow",
+ "heck",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "kqs-inputservice"
+version = "0.1.0"
+dependencies = [
+ "env_logger",
+ "kynput",
+ "kynputservice-types",
+ "libkypc",
+ "log",
+ "tokio",
+ "zbus",
+]
+
+[[package]]
+name = "kycom"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "kymux-types",
+ "log",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "kymux-types"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "byteorder",
+ "bytes",
+ "kymux-util",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "kymux-util"
+version = "0.1.0"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "kynput"
+version = "0.1.0"
+dependencies = [
+ "android_logger",
+ "async-trait",
+ "base64",
+ "bytes",
+ "core-foundation",
+ "enum-iterator",
+ "env_logger",
+ "evdev-rs",
+ "input",
+ "input-linux-sys",
+ "io-kit-sys",
+ "js-sys",
+ "keycode",
+ "kycom",
+ "libc",
+ "log",
+ "mio",
+ "png",
+ "raw-window-handle",
+ "regex",
+ "thiserror 2.0.20",
+ "tokio",
+ "users",
+ "vigem-client",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.52.0",
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "kynputservice-types"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libkypc"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "log",
+ "rmp-serde",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.7.1",
+ "pin-utils",
+]
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "udev"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
+dependencies = [
+ "io-lifetimes",
+ "libc",
+ "libudev-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset 0.9.1",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "users"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032"
+dependencies = [
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vigem-client"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "xcb"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
+dependencies = [
+ "bitflags 2.13.1",
+ "libc",
+ "quick-xml",
+ "x11",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.3",
+ "winnow",
+]
diff --git a/inputservice/Cargo.toml b/inputservice/Cargo.toml
new file mode 100644
index 0000000..b069f1b
--- /dev/null
+++ b/inputservice/Cargo.toml
@@ -0,0 +1,30 @@
+# Routes Kyber client input into the QEMU guest.
+#
+# Kyber's own kynputserver injects into the host OS; there is no "guest"
+# InputTarget. So this replaces it: same IPC, same kymux stream decoding
+# (via kynput's Rust API), but the decoded events go to QEMU's D-Bus
+# Keyboard/Mouse interfaces instead of to /dev/uinput.
+
+[package]
+name = "kqs-inputservice"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "kqs-inputservice"
+path = "src/main.rs"
+
+[dependencies]
+libkypc = { path = "../kyber-desktop/kysdk/kyutil/libkypc" }
+kynputservice-types = { path = "../kyber-desktop/kysdk/kynput/kynputservice-types" }
+kynput = { path = "../kyber-desktop/kysdk/kynput/kynput" }
+tokio = { version = "1", features = ["full"] }
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+env_logger = "0.11"
+log = "0.4"
+
+# kynput depends on kycom (kymux's component library), which is not published.
+# It lives in the kymux workspace inside kysdk.
+[patch.crates-io]
+kycom = { path = "../kyber-desktop/kysdk/kymux/kycom" }
diff --git a/inputservice/src/clipboard.rs b/inputservice/src/clipboard.rs
new file mode 100644
index 0000000..2c92bf4
--- /dev/null
+++ b/inputservice/src/clipboard.rs
@@ -0,0 +1,318 @@
+// Bridges QEMU's clipboard onto Kyber's.
+//
+// Kyber ships a host-side clipboard already, but it drives the X11 or Wayland
+// selection of the machine the stream comes from - see kynput's linux backend.
+// A hypervisor node has no desktop and no selection, so the guest's clipboard
+// is reached the same way its display is: over D-Bus, through the vdagent
+// channel QEMU exposes as org.qemu.Display1.Clipboard. That channel only
+// exists when the VM was started with 'clipboard=vnc', and only carries
+// anything when a vdagent is running inside the guest.
+//
+// The interface is symmetric: QEMU calls the peer as often as the peer calls
+// QEMU, so this both proxies to it and exports an object of the same name for
+// it to call back on.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::sync::{Arc, Mutex, Weak};
+use std::time::Duration;
+
+use kynput::types::{ClipboardData, ClipboardFormat};
+use kynput::{ClipboardEvent, InputConsumer, InputPacket, InputTarget, Payload};
+use log::{debug, warn};
+use tokio::sync::oneshot;
+
+/// The selection this bridges. QEMU also offers Primary and Secondary, which
+/// are X11 notions with no counterpart in Kyber's protocol or a browser's.
+const SELECTION_CLIPBOARD: u32 = 0;
+
+/// How long a guest paste waits for the client to hand its clipboard over.
+///
+/// The guest is blocked on this D-Bus call, so it cannot be generous: a client
+/// that has closed its tab would otherwise hang the paste rather than fail it.
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// The mime this offers and accepts for text.
+///
+/// QEMU's vdagent normalises to UTF-8 and the Kyber payload is a Rust String,
+/// so nothing here has to transcode.
+const MIME_TEXT: &str = "text/plain;charset=utf-8";
+const MIME_HTML: &str = "text/html";
+
+fn format_from_mime(mime: &str) -> Option<ClipboardFormat> {
+    // Matched on the prefix: a peer may or may not put the charset on, and
+    // vdagent is not consistent about it between guests.
+    let base = mime.split(';').next().unwrap_or(mime).trim();
+    match base {
+        "text/plain" | "text/plain;charset=utf-8" | "UTF8_STRING" | "STRING" | "TEXT" => {
+            Some(ClipboardFormat::Text)
+        }
+        "text/html" => Some(ClipboardFormat::Html),
+        _ => None,
+    }
+}
+
+fn mime_for(format: ClipboardFormat) -> &'static str {
+    match format {
+        ClipboardFormat::Text => MIME_TEXT,
+        ClipboardFormat::Html => MIME_HTML,
+        // ClipboardFormat is non_exhaustive upstream; text is the safe answer
+        // for anything added later, since every peer understands it.
+        _ => MIME_TEXT,
+    }
+}
+
+#[derive(Default)]
+struct State {
+    /// Bumped for every Grab, which is how QEMU orders competing owners.
+    serial: u32,
+    /// A guest paste waiting on the client. Only one at a time: the guest is
+    /// blocked on it, so a second cannot arrive before this one answers.
+    pending: Option<oneshot::Sender<Option<ClipboardData>>>,
+}
+
+pub struct Bridge {
+    /// Calls into QEMU.
+    proxy: zbus::Proxy<'static>,
+    /// Sends toward the client. Weak, because the stream owns the session;
+    /// None until one is up.
+    stream: Mutex<Option<Weak<dyn InputConsumer>>>,
+    state: Mutex<State>,
+}
+
+impl Bridge {
+    pub async fn new(
+        conn: &zbus::Connection,
+        bus_name: String,
+    ) -> Result<Arc<Self>, Box<dyn std::error::Error>> {
+        let proxy = zbus::Proxy::new(
+            conn,
+            bus_name,
+            "/org/qemu/Display1/Clipboard",
+            "org.qemu.Display1.Clipboard",
+        )
+        .await?;
+
+        let bridge = Arc::new(Self {
+            proxy,
+            stream: Mutex::new(None),
+            state: Mutex::new(State::default()),
+        });
+
+        // Exported before Register, or QEMU may call back before there is an
+        // object to receive it.
+        conn.object_server()
+            .at("/org/qemu/Display1/Clipboard", Listener { bridge: bridge.clone() })
+            .await?;
+
+        bridge.proxy.call_method("Register", &()).await?;
+
+        Ok(bridge)
+    }
+
+    /// Where to send what the guest does. Set once the kymux stream is up.
+    pub fn attach(&self, stream: Weak<dyn InputConsumer>) {
+        *self.stream.lock().unwrap() = Some(stream);
+    }
+
+    fn send(&self, event: ClipboardEvent) {
+        let stream = self.stream.lock().unwrap().as_ref().and_then(Weak::upgrade);
+        let Some(stream) = stream else {
+            debug!("clipboard event with no client attached, dropped");
+            return;
+        };
+        // Target::Client: this is the half travelling away from the host.
+        let pkt = InputPacket::new(InputTarget::Client, Payload::Clipboard(event));
+        if let Err(e) = stream.consume(pkt) {
+            warn!("could not send a clipboard event to the client: {e:?}");
+        }
+    }
+
+    /// Something the client sent us.
+    pub async fn on_client_event(&self, event: ClipboardEvent) {
+        match event {
+            // The client copied. Tell QEMU we hold it now; the guest asks for
+            // the bytes later, if it ever pastes.
+            ClipboardEvent::Change { formats } => {
+                let mimes: Vec<&str> = formats.iter().copied().map(mime_for).collect();
+                let serial = {
+                    let mut state = self.state.lock().unwrap();
+                    state.serial = state.serial.wrapping_add(1);
+                    state.serial
+                };
+                debug!("client grabbed the clipboard: {mimes:?}");
+                if let Err(e) = self
+                    .proxy
+                    .call_method("Grab", &(SELECTION_CLIPBOARD, serial, mimes))
+                    .await
+                {
+                    warn!("QEMU refused the clipboard grab: {e}");
+                }
+            }
+
+            // An answer to a request the guest is blocked on.
+            ClipboardEvent::Data(data) => self.complete_pending(Some(data)),
+            ClipboardEvent::DataUnavailable { .. } => self.complete_pending(None),
+
+            // The client wants what the guest holds.
+            ClipboardEvent::DataRequest { format } => {
+                match self.request_from_guest(format).await {
+                    Some(data) => self.send(ClipboardEvent::Data(data)),
+                    None => self.send(ClipboardEvent::DataUnavailable { format }),
+                }
+            }
+
+            other => debug!("unhandled clipboard event from the client: {other:?}"),
+        }
+    }
+
+    fn complete_pending(&self, data: Option<ClipboardData>) {
+        let pending = self.state.lock().unwrap().pending.take();
+        match pending {
+            Some(tx) => {
+                let _ = tx.send(data);
+            }
+            // Late, or unsolicited. The guest has already been answered.
+            None => debug!("clipboard data with nothing waiting for it"),
+        }
+    }
+
+    async fn request_from_guest(&self, format: ClipboardFormat) -> Option<ClipboardData> {
+        let mimes = [mime_for(format)];
+        let reply: (String, Vec<u8>) = match self
+            .proxy
+            .call_method("Request", &(SELECTION_CLIPBOARD, &mimes[..]))
+            .await
+            .and_then(|m| m.body().deserialize())
+        {
+            Ok(reply) => reply,
+            Err(e) => {
+                debug!("the guest had no clipboard data: {e}");
+                return None;
+            }
+        };
+
+        let (mime, bytes) = reply;
+        let text = match String::from_utf8(bytes) {
+            Ok(text) => text,
+            Err(_) => {
+                warn!("the guest clipboard was not UTF-8, dropped");
+                return None;
+            }
+        };
+
+        // Answered in whatever the guest actually sent, not what was asked
+        // for: a vdagent may downgrade html to text.
+        let mut data = match format_from_mime(&mime).unwrap_or(format) {
+            ClipboardFormat::Html => ClipboardData::Html(text),
+            _ => ClipboardData::Text(text),
+        };
+
+        // Truncated rather than refused: a client that pasted half a huge
+        // selection is better served than one that pasted nothing.
+        if data.is_too_large() {
+            warn!("guest clipboard of {} bytes truncated", data.size());
+            data.truncate();
+        }
+
+        Some(data)
+    }
+
+    /// The guest copied.
+    async fn on_guest_grab(&self, selection: u32, mimes: Vec<String>) {
+        if selection != SELECTION_CLIPBOARD {
+            return;
+        }
+
+        let mut formats: Vec<ClipboardFormat> =
+            mimes.iter().filter_map(|m| format_from_mime(m)).collect();
+        formats.dedup();
+
+        if formats.is_empty() {
+            debug!("guest grabbed the clipboard with no format we carry: {mimes:?}");
+            return;
+        }
+
+        debug!("guest grabbed the clipboard: {formats:?}");
+        self.send(ClipboardEvent::Change { formats });
+    }
+
+    /// The guest is pasting and wants what the client holds.
+    async fn on_guest_request(&self, selection: u32, mimes: Vec<String>) -> Option<(String, Vec<u8>)> {
+        if selection != SELECTION_CLIPBOARD {
+            return None;
+        }
+
+        let format = mimes.iter().find_map(|m| format_from_mime(m))?;
+
+        let rx = {
+            let mut state = self.state.lock().unwrap();
+            // A request already waiting means the last one never answered.
+            // Dropping its sender fails it rather than leaving both stuck.
+            let (tx, rx) = oneshot::channel();
+            state.pending = Some(tx);
+            rx
+        };
+
+        self.send(ClipboardEvent::DataRequest { format });
+
+        let data = match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
+            Ok(Ok(Some(data))) => data,
+            // Refused, or the client went away.
+            Ok(_) => return None,
+            Err(_) => {
+                warn!("the client did not answer a clipboard request in time");
+                self.state.lock().unwrap().pending = None;
+                return None;
+            }
+        };
+
+        let (mime, text) = match data {
+            ClipboardData::Html(s) => (MIME_HTML, s),
+            ClipboardData::Text(s) => (MIME_TEXT, s),
+            other => {
+                debug!("unhandled clipboard payload from the client: {other:?}");
+                return None;
+            }
+        };
+
+        Some((mime.to_owned(), text.into_bytes()))
+    }
+}
+
+/// The object QEMU calls back on. Every method is one QEMU initiated.
+struct Listener {
+    bridge: Arc<Bridge>,
+}
+
+#[zbus::interface(name = "org.qemu.Display1.Clipboard")]
+impl Listener {
+    async fn register(&self) {
+        debug!("QEMU registered its side of the clipboard");
+    }
+
+    async fn unregister(&self) {
+        debug!("QEMU unregistered its side of the clipboard");
+    }
+
+    async fn grab(&self, selection: u32, _serial: u32, mimes: Vec<String>) {
+        self.bridge.on_guest_grab(selection, mimes).await;
+    }
+
+    async fn release(&self, selection: u32) {
+        if selection == SELECTION_CLIPBOARD {
+            debug!("guest released the clipboard");
+        }
+    }
+
+    async fn request(
+        &self,
+        selection: u32,
+        mimes: Vec<String>,
+    ) -> zbus::fdo::Result<(String, Vec<u8>)> {
+        self.bridge
+            .on_guest_request(selection, mimes)
+            .await
+            .ok_or_else(|| zbus::fdo::Error::Failed("no clipboard data".into()))
+    }
+}
diff --git a/inputservice/src/main.rs b/inputservice/src/main.rs
new file mode 100644
index 0000000..c217d7f
--- /dev/null
+++ b/inputservice/src/main.rs
@@ -0,0 +1,494 @@
+// kqs-inputservice - routes Kyber client input into the QEMU guest.
+//
+// Kyber's kynputserver injects into the host OS: InputTarget is only Client or
+// Host, and the Linux backend writes to /dev/uinput. Streaming a guest needs
+// the events to land inside the VM instead, so this stands in for it.
+//
+// It reuses kynput's own Rust API to receive and decode the kymux input
+// stream - so the wire format and packet parsing are Kyber's, not a
+// reimplementation - and then translates the decoded events onto QEMU's
+// org.qemu.Display1.Keyboard / .Mouse D-Bus interfaces.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::sync::{Arc, Mutex, Weak};
+
+use kynput::{
+    ClipboardEvent, InputConsumer, InputKymuxService, InputNetworkStream, InputNetworkStreamMsg,
+    InputNetworkStreamObserver, InputPacket, InputTarget, Payload,
+};
+use kynputservice_types::{Command, Event};
+use log::{debug, error, info, warn};
+use tokio::sync::mpsc;
+
+mod clipboard;
+
+/// Events, already decoded, on their way to QEMU.
+#[derive(Debug)]
+enum GuestInput {
+    Key { scancode: u16, pressed: bool },
+    Button { button: u32, pressed: bool },
+    AbsPosition { x: u32, y: u32 },
+    RelMotion { dx: i32, dy: i32 },
+}
+
+/// Consumer plugged into kynput's kymux stream.
+///
+/// Runs on kynput's own thread, so it only translates and forwards; talking to
+/// D-Bus happens on the async side.
+struct GuestConsumer {
+    tx: mpsc::UnboundedSender<GuestInput>,
+    /// Clipboard events, which go to the D-Bus bridge rather than to the
+    /// injector. None when the VM has no clipboard channel.
+    clip_tx: Option<mpsc::UnboundedSender<ClipboardEvent>>,
+    /// Guest geometry, for clamping absolute positions. Shared and refreshed,
+    /// because guests resize - Ubuntu leaves GRUB's 640x480 for its desktop
+    /// mode - and a stale size clamps the cursor to part of the screen.
+    size: Arc<Mutex<(u32, u32)>>,
+    /// The space the client sends coordinates in: the size of the display the
+    /// AV service advertised, which is not the guest's size while the stream
+    /// runs at a fixed geometry. Shared, because the controller pushes a new
+    /// display list (UpdateHostConfig) whenever the guest changes resolution.
+    space: Arc<Mutex<(u32, u32)>>,
+}
+
+impl InputConsumer for GuestConsumer {
+    fn consume(&self, pkt: InputPacket) -> kynput::Result<()> {
+        debug!("packet: target={:?} type={:?}", pkt.target, pkt.get_type());
+        let ev = match pkt.payload {
+            Payload::Keyboard(k) => Some(GuestInput::Key {
+                scancode: k.scancode,
+                pressed: k.pressed,
+            }),
+
+            Payload::MouseButton(b) => {
+                // QEMU button numbers follow its own input enum:
+                // 0 left, 1 middle, 2 right, 3 wheel-up, 4 wheel-down,
+                // 5 side, 6 extra.
+                use kynput::MouseButtonType::*;
+                let button = match b.button {
+                    Left => 0,
+                    Middle => 1,
+                    Right => 2,
+                    Side => 5,
+                    Extra => 6,
+                };
+                Some(GuestInput::Button {
+                    button,
+                    pressed: b.pressed,
+                })
+            }
+
+            Payload::MousePosition(p) => {
+                let (gw, gh) = *self.size.lock().unwrap();
+                let (sw, sh) = *self.space.lock().unwrap();
+
+                // Map from the advertised display's space into the guest's.
+                let x = p.x.max(0) as u64 * gw.max(1) as u64 / sw.max(1) as u64;
+                let y = p.y.max(0) as u64 * gh.max(1) as u64 / sh.max(1) as u64;
+
+                Some(GuestInput::AbsPosition {
+                    x: (x as u32).min(gw.saturating_sub(1)),
+                    y: (y as u32).min(gh.saturating_sub(1)),
+                })
+            }
+
+            Payload::MouseMove(m) => Some(GuestInput::RelMotion {
+                dx: m.dx as i32,
+                dy: m.dy as i32,
+            }),
+
+            Payload::MouseWheel(w) => {
+                // QEMU has no wheel axis: it is a button press/release pair.
+                let button = if w.dy < 0.0 { 3 } else { 4 };
+                if w.dy != 0.0 {
+                    let _ = self.tx.send(GuestInput::Button {
+                        button,
+                        pressed: true,
+                    });
+                    Some(GuestInput::Button {
+                        button,
+                        pressed: false,
+                    })
+                } else {
+                    None
+                }
+            }
+
+            // Not an injected event: it goes to the D-Bus bridge, which
+            // answers on its own thread because a guest paste blocks on it.
+            Payload::Clipboard(ev) => {
+                if let Some(clip_tx) = &self.clip_tx {
+                    let _ = clip_tx.send(ev);
+                } else {
+                    debug!("clipboard event with no bridge, dropped");
+                }
+                None
+            }
+
+            other => {
+                debug!("ignoring {:?}", other);
+                None
+            }
+        };
+
+        if let Some(ev) = ev {
+            debug!("-> guest: {ev:?}");
+            let _ = self.tx.send(ev);
+        }
+        Ok(())
+    }
+}
+
+/// kynput requires an observer for stream lifecycle messages.
+struct StreamObserver;
+
+impl InputNetworkStreamObserver for StreamObserver {
+    fn on_message(&self, msg: InputNetworkStreamMsg) {
+        info!("input stream: {msg:?}");
+    }
+}
+
+struct QemuInput {
+    keyboard: zbus::Proxy<'static>,
+    mouse: zbus::Proxy<'static>,
+    console: zbus::Proxy<'static>,
+}
+
+impl QemuInput {
+    async fn connect(
+        conn: &zbus::Connection,
+        bus_name: String,
+    ) -> Result<Self, Box<dyn std::error::Error>> {
+        let keyboard = zbus::Proxy::new(
+            conn,
+            bus_name.clone(),
+            "/org/qemu/Display1/Console_0",
+            "org.qemu.Display1.Keyboard",
+        )
+        .await?;
+
+        let bus_name_console = bus_name.clone();
+        let mouse = zbus::Proxy::new(
+            conn,
+            bus_name,
+            "/org/qemu/Display1/Console_0",
+            "org.qemu.Display1.Mouse",
+        )
+        .await?;
+
+        // Width/Height live on Console, not on Keyboard. Querying the wrong
+        // interface fails silently and leaves coordinates clamped to a
+        // fallback size, which shows up as a cursor that cannot reach the
+        // right or bottom of a larger guest.
+        let console = zbus::Proxy::new(
+            conn,
+            bus_name_console,
+            "/org/qemu/Display1/Console_0",
+            "org.qemu.Display1.Console",
+        )
+        .await?;
+
+        Ok(Self { keyboard, mouse, console })
+    }
+
+    async fn console_size(&self) -> Option<(u32, u32)> {
+        let w: u32 = self.console.get_property("Width").await.ok()?;
+        let h: u32 = self.console.get_property("Height").await.ok()?;
+        Some((w, h))
+    }
+
+    async fn apply(&self, ev: GuestInput) {
+        let r = match ev {
+            // kynput carries XT-style scancodes and QEMU's keycode is
+            // "xtkbd + special re-encoding of the high bit", so these line up
+            // without a translation table.
+            GuestInput::Key { scancode, pressed } => {
+                let m = if pressed { "Press" } else { "Release" };
+                self.keyboard.call_method(m, &(scancode as u32,)).await.map(|_| ())
+            }
+            GuestInput::Button { button, pressed } => {
+                let m = if pressed { "Press" } else { "Release" };
+                self.mouse.call_method(m, &(button,)).await.map(|_| ())
+            }
+            GuestInput::AbsPosition { x, y } => {
+                self.mouse.call_method("SetAbsPosition", &(x, y)).await.map(|_| ())
+            }
+            GuestInput::RelMotion { dx, dy } => {
+                self.mouse.call_method("RelMotion", &(dx, dy)).await.map(|_| ())
+            }
+        };
+
+        if let Err(e) = r {
+            warn!("QEMU input call failed: {e}");
+        }
+    }
+}
+
+/// Fixed stream dimension, matching kyber-qemu-server's --width/--height.
+/// Zero or unparseable means the encoder follows the guest instead.
+fn env_dim(var: &str, default: u32) -> Option<u32> {
+    let v = match std::env::var(var) {
+        Ok(v) => v.parse().ok()?,
+        Err(_) => default,
+    };
+    (v > 0).then_some(v)
+}
+
+/// Whether the VM has the vdagent channel QEMU's clipboard needs.
+///
+/// Set by qemu-server from the VM's own 'clipboard=' setting; see
+/// PVE::QemuServer::Kyber::write_env.
+fn clipboard_enabled() -> bool {
+    matches!(
+        std::env::var("KQS_CLIPBOARD").as_deref(),
+        Ok("1") | Ok("true") | Ok("yes")
+    )
+}
+
+fn qemu_bus_name() -> String {
+    std::env::var("KQS_BUS_NAME").unwrap_or_else(|_| "org.qemu".to_string())
+}
+
+/// Owns the kymux stream so it lives as long as the session.
+struct Session {
+    _stream: Arc<InputKymuxService>,
+    _consumer: Arc<GuestConsumer>,
+}
+
+fn start_stream(
+    uri: &str,
+    tx: mpsc::UnboundedSender<GuestInput>,
+    clip_tx: Option<mpsc::UnboundedSender<ClipboardEvent>>,
+    size: Arc<Mutex<(u32, u32)>>,
+    space: Arc<Mutex<(u32, u32)>>,
+    clipboard: Option<&Arc<clipboard::Bridge>>,
+) -> Result<Session, Box<dyn std::error::Error>> {
+    {
+        let s = *space.lock().unwrap();
+        info!("client coordinate space {}x{}", s.0, s.1);
+    }
+
+    let consumer = Arc::new(GuestConsumer {
+        tx,
+        clip_tx,
+        size,
+        space,
+    });
+
+    // Receive what the client sends toward the host - that is the stream we
+    // are standing in for.
+    let stream = Arc::new(InputKymuxService::new(
+        InputTarget::Host,
+        Arc::new(StreamObserver),
+        uri,
+    ));
+
+    let weak: Weak<dyn InputConsumer> = Arc::downgrade(&consumer) as Weak<dyn InputConsumer>;
+    stream.plug_consumer(weak)?;
+
+    // The bridge sends through the same stream, so it needs it now that there
+    // is one. Weak: the session owns it.
+    if let Some(bridge) = clipboard {
+        bridge.attach(Arc::downgrade(&stream) as Weak<dyn InputConsumer>);
+    }
+
+    let runner = stream.clone();
+    std::thread::spawn(move || {
+        if let Err(e) = runner.run() {
+            error!("input stream ended: {e:?}");
+        }
+    });
+
+    Ok(Session {
+        _stream: stream,
+        _consumer: consumer,
+    })
+}
+
+/// Connect to the QEMU that this instance is responsible for.
+///
+/// One QEMU per VM means one D-Bus per VM: with `-display dbus,p2p=on` each
+/// guest gets a private socket instead of a name on the shared session bus,
+/// where a second VM would simply collide on org.qemu. KQS_DBUS_ADDR carries
+/// that socket; without it this is the old single-VM behaviour.
+async fn qemu_connection() -> Result<zbus::Connection, zbus::Error> {
+    match std::env::var("KQS_DBUS_ADDR") {
+        Ok(addr) if !addr.is_empty() => {
+            zbus::connection::Builder::address(addr.as_str())?
+                .build()
+                .await
+        }
+        _ => zbus::Connection::session().await,
+    }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+    info!("kqs-inputservice starting");
+
+    let conn = qemu_connection().await?;
+    let qemu = Arc::new(QemuInput::connect(&conn, qemu_bus_name()).await?);
+
+    // Told to us rather than probed. QEMU exports the clipboard interface for
+    // every dbus display and accepts a peer on it whether or not the VM has a
+    // vdagent channel, so registering successfully proves nothing: without
+    // 'clipboard=vnc' there is no second peer and every copy goes nowhere.
+    // qemu-server knows, and says so here.
+    let clipboard = if clipboard_enabled() {
+        match clipboard::Bridge::new(&conn, qemu_bus_name()).await {
+            Ok(bridge) => {
+                info!("clipboard bridged to the guest");
+                Some(bridge)
+            }
+            Err(e) => {
+                // QEMU takes one clipboard peer at a time, so this is also
+                // what a second console on one VM would see.
+                warn!("could not bridge the clipboard: {e}");
+                None
+            }
+        }
+    } else {
+        info!("no guest clipboard; set 'clipboard=vnc' on the VM to enable it");
+        None
+    };
+    let size = Arc::new(Mutex::new(qemu.console_size().await.unwrap_or((1024, 768))));
+    {
+        let s = *size.lock().unwrap();
+        info!("guest console {}x{}", s.0, s.1);
+    }
+
+    // Track resolution changes so absolute coordinates keep matching the guest.
+    {
+        let qemu = qemu.clone();
+        let size = size.clone();
+        tokio::spawn(async move {
+            let mut last = *size.lock().unwrap();
+            loop {
+                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+                if let Some(cur) = qemu.console_size().await {
+                    if cur != last {
+                        info!("guest resized to {}x{}", cur.0, cur.1);
+                        *size.lock().unwrap() = cur;
+                        last = cur;
+                    }
+                }
+            }
+        });
+    }
+
+    // The client sends coordinates in the space of the display the AV service
+    // advertised, which is the stream's geometry - not the guest's, while the
+    // encoder runs at a fixed size. UpdateHostConfig moves it if that ever
+    // changes; the fallback covers an encoder that follows the guest.
+    // On by default, matching the AV service: the encoder follows the guest
+    // unless it is explicitly pinned, and the pointer has to be measured in
+    // whatever space the encoder ended up using.
+    let follow_guest = !matches!(
+        std::env::var("KQS_FOLLOW_GUEST").as_deref(),
+        Ok("0") | Ok("false")
+    );
+
+    let space = Arc::new(Mutex::new(
+        match (env_dim("KQS_OUT_W", 1280), env_dim("KQS_OUT_H", 800)) {
+            _ if follow_guest => *size.lock().unwrap(),
+            (Some(w), Some(h)) => (w, h),
+            _ => *size.lock().unwrap(),
+        },
+    ));
+
+    let (tx, mut rx) = mpsc::unbounded_channel::<GuestInput>();
+
+    // Clipboard events arrive on kynput's thread and are answered with D-Bus
+    // calls, so they are handed to the runtime the same way input is.
+    let clip_tx = clipboard.as_ref().map(|bridge| {
+        let (clip_tx, mut clip_rx) = mpsc::unbounded_channel::<ClipboardEvent>();
+        let bridge = bridge.clone();
+        tokio::spawn(async move {
+            while let Some(ev) = clip_rx.recv().await {
+                bridge.on_client_event(ev).await;
+            }
+        });
+        clip_tx
+    });
+
+    // Pump decoded events into QEMU.
+    let injector = qemu.clone();
+    tokio::spawn(async move {
+        while let Some(ev) = rx.recv().await {
+            injector.apply(ev).await;
+        }
+    });
+
+    let mut worker = libkypc::process::connect_to_commander::<Command, Event>().await?;
+    let (mut receiver, _sender) = worker.get_sender_receiver()?;
+
+    let mut session: Option<Session> = None;
+
+    while let Ok((cmd_id, cmd)) = receiver.recv().await {
+        match cmd {
+            Command::StartKymux { uri, .. } => {
+                info!("StartKymux: {uri}");
+                match start_stream(
+                    &uri,
+                    tx.clone(),
+                    clip_tx.clone(),
+                    size.clone(),
+                    space.clone(),
+                    clipboard.as_ref(),
+                ) {
+                    Ok(s) => {
+                        session = Some(s);
+                        // The client only builds its clipboard handler when
+                        // this says the host has one.
+                        receiver
+                            .accept(
+                                cmd_id,
+                                Some(Event::Connected {
+                                    clipboard: clipboard.is_some(),
+                                }),
+                            )
+                            .await?;
+                    }
+                    Err(e) => {
+                        error!("could not start input stream: {e}");
+                        receiver.reject(cmd_id, None).await?;
+                    }
+                }
+            }
+
+            // TCP transport is not used by the controller path we support.
+            Command::StartTcp { .. } => {
+                warn!("StartTcp unsupported");
+                receiver.reject(cmd_id, None).await?;
+            }
+
+            // The controller sends this when the AV service reports a display
+            // change, so it carries the space the client has just switched to
+            // sending coordinates in.
+            Command::UpdateHostConfig { host_config } => {
+                if let Some(d) = host_config.display_list.first() {
+                    if d.width > 0 && d.height > 0 {
+                        let new = (d.width as u32, d.height as u32);
+                        info!("client coordinate space now {}x{}", new.0, new.1);
+                        *space.lock().unwrap() = new;
+                    }
+                }
+                receiver.accept(cmd_id, None).await?;
+            }
+
+            Command::Stop(reason) => {
+                info!("Stop ({reason})");
+                session = None;
+                receiver.accept(cmd_id, None).await?;
+                worker.stop().await?;
+                break;
+            }
+        }
+    }
+
+    drop(session);
+    info!("kqs-inputservice exiting");
+    Ok(())
+}
diff --git a/kyber-desktop b/kyber-desktop
new file mode 160000
index 0000000..6c75cc2
--- /dev/null
+++ b/kyber-desktop
@@ -0,0 +1 @@
+Subproject commit 6c75cc276e40ed9cca4e9dcabe3f7e4e1d83c44f
diff --git a/kyber-qemu-server.mk b/kyber-qemu-server.mk
new file mode 100644
index 0000000..761bca0
--- /dev/null
+++ b/kyber-qemu-server.mk
@@ -0,0 +1,56 @@
+# kyber-qemu-server
+#
+# libtxproto is not packaged anywhere yet, so point at a local build tree:
+#   make TXPROTO=upstream/txproto
+# See patches/README.md for the two fixes that tree needs.
+
+comma := ,
+
+# Set by the top-level Makefile from the SDK it just built.
+TX_INC ?= kyber-desktop/rootfs-x86_64-linux-gnu/include
+TX_LIB ?= kyber-desktop/rootfs-x86_64-linux-gnu/lib/x86_64-linux-gnu
+
+PKGS := gio-2.0 gio-unix-2.0 glib-2.0 libavutil libavcodec libavfilter libswscale libdrm
+
+# Empty to build without one: the package sets LD_LIBRARY_PATH in its wrapper
+# and none of the other binaries carry an rpath either.
+TX_RPATH ?= $(abspath $(TX_LIB))
+
+CFLAGS  ?= -O2 -g
+CFLAGS  += -std=gnu11 -Wall -Wextra -Wno-unused-parameter \
+           -I$(TX_INC) $(shell pkg-config --cflags $(PKGS))
+LDFLAGS += -L$(TX_LIB) $(if $(TX_RPATH),-Wl$(comma)-rpath$(comma)$(TX_RPATH))
+LDLIBS  += -ltxproto $(shell pkg-config --libs $(PKGS))
+
+SRCS := src/main.c src/listener.c src/surface.c src/sink.c src/dmabuf.c src/audio.c
+OBJS := $(SRCS:.c=.o)
+BIN  := kyber-qemu-server
+
+TOOLS := tools/tx_inject_probe tools/dmabuf_probe
+
+.PHONY: all clean tools check-txproto
+
+all: check-txproto $(BIN)
+
+check-txproto:
+	@test -f $(TX_LIB)/libtxproto.so || { \
+	  echo "error: $(TX_LIB)/libtxproto.so not found."; \
+	  echo "       build it first - see patches/README.md"; exit 1; }
+
+$(BIN): $(OBJS)
+	$(CC) $(OBJS) -o $@ $(LDFLAGS) $(LDLIBS)
+
+src/%.o: src/%.c src/kqs.h
+	$(CC) $(CFLAGS) -c $< -o $@
+
+tools: $(TOOLS)
+
+tools/tx_inject_probe: tools/tx_inject_probe.c
+	$(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)
+
+# needs the sink and dmabuf import it is testing
+tools/dmabuf_probe: tools/dmabuf_probe.c src/dmabuf.c src/sink.c src/surface.c
+	$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) $(LDLIBS)
+
+clean:
+	rm -f $(OBJS) $(BIN) $(TOOLS)
diff --git a/kycontroller.wrapper b/kycontroller.wrapper
new file mode 100755
index 0000000..c7184c3
--- /dev/null
+++ b/kycontroller.wrapper
@@ -0,0 +1,13 @@
+#!/bin/sh
+# kycontroller spawns "kyavserver" and "kynputserver" from PATH, and the AV
+# adapter spawns "kyber-qemu-server" the same way. All of them live in this
+# package own prefix alongside the libraries they need.
+#
+# The prefix is private on purpose. This is a bundled FFmpeg and VLC, and it
+# must not shadow the ones Debian ships - so nothing goes in /usr/lib and the
+# paths are set here rather than in ld.so.conf.
+PREFIX=/usr/lib/pve-qemu-kyber
+PATH="$PREFIX/bin:$PATH"
+LD_LIBRARY_PATH="$PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+export PATH LD_LIBRARY_PATH
+exec "$PREFIX/bin/kycontroller" "$@"
diff --git a/patches/0001-txproto-reset-log-component-count-on-uninit.patch b/patches/0001-txproto-reset-log-component-count-on-uninit.patch
new file mode 100644
index 0000000..8c0213a
--- /dev/null
+++ b/patches/0001-txproto-reset-log-component-count-on-uninit.patch
@@ -0,0 +1,12 @@
+diff --git a/src/log.c b/src/log.c
+index 8e0a4dd..dc4e0c9 100644
+--- a/src/log.c
++++ b/src/log.c
+@@ -1060,6 +1060,7 @@ void sp_log_uninit(void)
+         av_bprint_finalize(&log_ctx.ic[i].bpf, NULL);
+     }
+     av_freep(&log_ctx.ic);
++    log_ctx.ic_len = 0;
+ 
+     if (log_ctx.log_file) {
+         fflush(log_ctx.log_file);
diff --git a/patches/0002-kycontroller-configure-from-the-command-line.patch b/patches/0002-kycontroller-configure-from-the-command-line.patch
new file mode 100644
index 0000000..1988053
--- /dev/null
+++ b/patches/0002-kycontroller-configure-from-the-command-line.patch
@@ -0,0 +1,353 @@
+diff --git a/kycontroller/Cargo.toml b/kycontroller/Cargo.toml
+index 4584b46..7fa2778 100644
+--- a/kycontroller/Cargo.toml
++++ b/kycontroller/Cargo.toml
+@@ -42,7 +42,7 @@ awc = { version = "3.8.2", optional = true, default-features = false, features =
+ rustls-platform-verifier = { version = "0.6", optional = true }
+ 
+ # CLI tooling specifics
+-clap = { version = "4.5.40", features = ["derive"] }
++clap = { version = "4.5.40", features = ["derive", "env"] }
+ humantime = { version = "2.2.0", optional = true }
+ 
+ kyavservice-types = "0.1"
+diff --git a/kycontroller/src/auth/jwt.rs b/kycontroller/src/auth/jwt.rs
+index 18f301c..a5f72f0 100644
+--- a/kycontroller/src/auth/jwt.rs
++++ b/kycontroller/src/auth/jwt.rs
+@@ -89,6 +89,17 @@ pub struct Config {
+     key: ConfigKey,
+ }
+ 
++impl Config {
++    /// An HS256 configuration built from a key given on the command line,
++    /// for a deployment that has no configuration file at all.
++    pub fn hs256(key: String) -> Self {
++        Self {
++            algorithm: Algorithm::HS256,
++            key: ConfigKey::Plain(key),
++        }
++    }
++}
++
+ impl Default for Config {
+     /// Default configuration for development.
+     ///
+diff --git a/kycontroller/src/auth/mod.rs b/kycontroller/src/auth/mod.rs
+index 059550f..a64081c 100644
+--- a/kycontroller/src/auth/mod.rs
++++ b/kycontroller/src/auth/mod.rs
+@@ -130,6 +130,27 @@ pub struct Config {
+     oidc: OptionalBackendConfig<oidc::Config>,
+ }
+ 
++impl Config {
++    /// Overrides applied after the file is read, so a controller can be
++    /// configured entirely from its command line.
++    #[cfg(feature = "auth-basic")]
++    pub fn set_basic_enabled(&mut self, enabled: bool) {
++        self.basic.enabled = enabled;
++    }
++
++    #[cfg(feature = "auth-oidc")]
++    pub fn set_oidc_enabled(&mut self, enabled: bool) {
++        self.oidc.enabled = enabled;
++    }
++
++    /// Enables the JWT backend against an HS256 secret.
++    #[cfg(feature = "auth-jwt")]
++    pub fn set_jwt_hs256_key(&mut self, key: String) {
++        self.jwt.enabled = true;
++        self.jwt.inner = jwt::Config::hs256(key);
++    }
++}
++
+ #[derive(Debug, serde::Deserialize)]
+ #[serde(default)]
+ struct OptionalBackendConfig<T> {
+diff --git a/kycontroller/src/cli.rs b/kycontroller/src/cli.rs
+index ba1785f..3bdeb73 100644
+--- a/kycontroller/src/cli.rs
++++ b/kycontroller/src/cli.rs
+@@ -29,4 +29,71 @@ pub(crate) struct Cli {
+     /// executable-relative location is used.
+     #[arg(short = 'c', long = "config", value_name = "PATH")]
+     pub config: Option<PathBuf>,
++
++    /// Serve the control plane on this unix socket rather than a TCP port.
++    ///
++    /// The data plane still needs a UDP port - QUIC has no unix-socket form -
++    /// but nothing else is left listening on the network.
++    #[arg(long = "listen-socket", value_name = "PATH")]
++    pub listen_socket: Option<PathBuf>,
++
++    /// Listening port, for both the HTTP control plane and QUIC.
++    #[arg(long = "port", value_name = "PORT")]
++    pub port: Option<u16>,
++
++    /// UDP port for the data plane, when it has to differ from --port.
++    ///
++    /// Also read from the environment, so a template unit that cannot do
++    /// arithmetic on its instance name can still be handed one port per
++    /// controller.
++    #[arg(long = "dataplane-port", value_name = "PORT", env = "KYBER_DATAPLANE_PORT")]
++    pub dataplane_port: Option<u16>,
++
++    /// Address the data plane binds, rather than every interface.
++    ///
++    /// A controller reached only by a proxy on its own node wants loopback,
++    /// and the default here is the wildcard.
++    #[arg(long = "dataplane-addr", value_name = "ADDR", env = "KYBER_DATAPLANE_ADDR")]
++    pub dataplane_addr: Option<std::net::IpAddr>,
++
++    /// Directory served as the web client.
++    #[arg(long = "webclient", value_name = "PATH")]
++    pub webclient: Option<String>,
++
++    /// TLS certificate chain, in PEM form.
++    #[arg(long = "tls-cert", value_name = "PATH")]
++    pub tls_cert: Option<String>,
++
++    /// Private key for --tls-cert, in PEM form.
++    #[arg(long = "tls-key", value_name = "PATH")]
++    pub tls_key: Option<String>,
++
++    /// HS256 secret to verify JWTs against. Enables the JWT backend.
++    ///
++    /// Prefer the environment variable: anything on a command line is
++    /// readable by every user on the machine through /proc, and a signing
++    /// key is exactly the thing that must not be.
++    #[arg(
++        long = "jwt-key",
++        value_name = "SECRET",
++        env = "KYBER_JWT_KEY",
++        hide_env_values = true
++    )]
++    pub jwt_key: Option<String>,
++
++    /// Disable the basic authentication backend.
++    #[arg(long = "no-basic-auth")]
++    pub no_basic_auth: bool,
++
++    /// Disable the OIDC authentication backend.
++    #[arg(long = "no-oidc-auth")]
++    pub no_oidc_auth: bool,
++
++    /// Disable the tray icon.
++    #[arg(long = "no-tray")]
++    pub no_tray: bool,
++
++    /// Disable the watchdog.
++    #[arg(long = "no-watchdog")]
++    pub no_watchdog: bool,
+ }
+diff --git a/kycontroller/src/config.rs b/kycontroller/src/config.rs
+index fff5b05..14469f0 100644
+--- a/kycontroller/src/config.rs
++++ b/kycontroller/src/config.rs
+@@ -59,6 +59,7 @@ pub(crate) struct Config {
+     port: Option<u16>,
+     listen_mode: Option<ListenMode>,
+     dataplane_port: Option<u16>,
++    dataplane_addr: Option<std::net::IpAddr>,
+     webtransport_gen_certificate: Option<bool>,
+     tls_cert: Option<String>,
+     tls_key: Option<String>,
+@@ -70,6 +71,8 @@ pub(crate) struct Config {
+     watchdog: Option<bool>,
+     multi_client: Option<bool>,
+     webclient: Option<String>,
++    #[serde(skip)]
++    listen_socket: Option<PathBuf>,
+ }
+ 
+ impl Config {
+@@ -81,10 +84,67 @@ impl Config {
+         self.listen_mode.unwrap_or_default()
+     }
+ 
++    /// Where the control plane listens, when it is not on a TCP port.
++    pub(crate) fn listen_socket(&self) -> Option<&Path> {
++        self.listen_socket.as_deref()
++    }
++
++    /// Apply the command line over whatever the file said, so a controller can
++    /// be started with no configuration file at all.
++    ///
++    /// Only flags that were actually given have an effect; the rest leave the
++    /// file's answer, or the built-in default, alone.
++    pub(crate) fn apply_cli(&mut self, cli: &crate::cli::Cli) {
++        if let Some(path) = cli.listen_socket.clone() {
++            self.listen_socket = Some(path);
++        }
++        if let Some(port) = cli.port {
++            self.port = Some(port);
++        }
++        if let Some(port) = cli.dataplane_port {
++            self.dataplane_port = Some(port);
++        }
++        if let Some(addr) = cli.dataplane_addr {
++            self.dataplane_addr = Some(addr);
++        }
++        if let Some(webclient) = cli.webclient.clone() {
++            self.webclient = Some(webclient);
++        }
++        if let Some(cert) = cli.tls_cert.clone() {
++            self.tls_cert = Some(cert);
++        }
++        if let Some(key) = cli.tls_key.clone() {
++            self.tls_key = Some(key);
++        }
++        if cli.no_tray {
++            self.tray = Some(false);
++        }
++        if cli.no_watchdog {
++            self.watchdog = Some(false);
++        }
++
++        #[cfg(feature = "auth-jwt")]
++        if let Some(key) = cli.jwt_key.clone() {
++            self.auth.set_jwt_hs256_key(key);
++        }
++        #[cfg(feature = "auth-basic")]
++        if cli.no_basic_auth {
++            self.auth.set_basic_enabled(false);
++        }
++        #[cfg(feature = "auth-oidc")]
++        if cli.no_oidc_auth {
++            self.auth.set_oidc_enabled(false);
++        }
++    }
++
+     pub(crate) fn dataplane_port(&self) -> Option<u16> {
+         self.dataplane_port
+     }
+ 
++    pub(crate) fn dataplane_addr(&self) -> Option<std::net::IpAddr> {
++        self.dataplane_addr
++    }
++
+     pub(crate) fn webtransport_gen_certificate(&self) -> bool {
+         self.webtransport_gen_certificate.unwrap_or(true)
+     }
+diff --git a/kycontroller/src/kymux_controller/mod.rs b/kycontroller/src/kymux_controller/mod.rs
+index a87f1be..36d0651 100644
+--- a/kycontroller/src/kymux_controller/mod.rs
++++ b/kycontroller/src/kymux_controller/mod.rs
+@@ -68,6 +68,7 @@ fn run_connection_task(
+ /// Configuration for starting Kymux infrastructure
+ pub(crate) struct KymuxInfraConfig {
+     pub(crate) listen_mode: ListenMode,
++    pub(crate) listen_addr: Option<IpAddr>,
+     pub(crate) listening_port: u16,
+     pub(crate) webtransport_gen_certificate: bool,
+     pub(crate) cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
+@@ -107,11 +108,14 @@ impl SharedKymuxInfra {
+             return Ok(inner.certificate_hash.clone());
+         }
+ 
+-        let ip_addr = match config.listen_mode {
+-            ListenMode::Ipv4 => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
++        let ip_addr = match (config.listen_addr, config.listen_mode) {
++            // An explicit address, for a data plane that must not be reachable
++            // from off the machine.
++            (Some(addr), _) => addr,
++            (None, ListenMode::Ipv4) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+             // Enable dual-stack: accept both IPv4 and IPv6 on this socket.
+             // On Linux this is the default; on Windows it must be set explicitly.
+-            ListenMode::DualStack => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
++            (None, ListenMode::DualStack) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
+         };
+ 
+         let addr = SocketAddr::new(ip_addr, config.listening_port);
+@@ -236,6 +240,7 @@ async fn start_kymux(
+ 
+     let infra_config = KymuxInfraConfig {
+         listen_mode: ctrl.config.listen_mode(),
++        listen_addr: ctrl.config.dataplane_addr(),
+         listening_port,
+         webtransport_gen_certificate: ctrl.config.webtransport_gen_certificate(),
+         cert_chain: ctrl.tls_config.cert_chain.clone(),
+diff --git a/kycontroller/src/main.rs b/kycontroller/src/main.rs
+index efc898a..98e1492 100644
+--- a/kycontroller/src/main.rs
++++ b/kycontroller/src/main.rs
+@@ -594,6 +594,34 @@ pub(crate) async fn set_bitrate(
+     HttpResponse::Ok().finish()
+ }
+ 
++/// Where the control plane listens.
++enum Listener {
++    Tcp(std::net::TcpListener),
++    Unix(std::os::unix::net::UnixListener),
++}
++
++impl Listener {
++    fn bind(config: &config::Config) -> std::io::Result<Self> {
++        let Some(path) = config.listen_socket() else {
++            return Ok(Self::Tcp(create_listening_socket(config)?));
++        };
++
++        // A socket left behind by a controller that did not exit cleanly would
++        // otherwise make this one fail to start, for the whole life of the
++        // node - the path is the address, and nothing else will remove it.
++        if let Err(err) = std::fs::remove_file(path) {
++            if err.kind() != std::io::ErrorKind::NotFound {
++                return Err(err);
++            }
++        }
++
++        let listener = std::os::unix::net::UnixListener::bind(path)?;
++        listener.set_nonblocking(true)?;
++
++        Ok(Self::Unix(listener))
++    }
++}
++
+ fn create_listening_socket(config: &config::Config) -> std::io::Result<std::net::TcpListener> {
+     use socket2::{Domain, Protocol, Socket, Type};
+     use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+@@ -714,7 +742,13 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+     }
+ 
+     // Load configuration
+-    let config = config::load(cli.config.as_deref())?;
++    let mut config = config::load(cli.config.as_deref())?;
++
++    // The command line wins over the file, and is enough on its own: a
++    // controller started per VM has nothing worth writing a file for, and
++    // generating one per VM only creates something to leave behind.
++    config.apply_cli(&cli);
++    let config = config;
+ 
+     // Load TLS config
+     let tls_cert = config.tls_cert().unwrap_or(STREAMER_CERT);
+@@ -734,7 +768,7 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+     let process_factory = Arc::new(ProcessFactory::new(false));
+ 
+     let listening_port = config.port();
+-    let listener = create_listening_socket(&config)?;
++    let listener = Listener::bind(&config)?;
+     let webclient_override = config.webclient().map(PathBuf::from);
+ 
+     let ctrl = web::Data::new(Mutex::new(Controller::new(
+@@ -858,8 +892,17 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+                 app
+             }
+         })
+-        .listen_rustls_0_23(listener, server_config)?
+-        .run();
++        ;
++
++    // A unix socket carries no TLS: it is not reachable from the network, and
++    // the file's permissions are what decide who may speak to it. On a TCP
++    // port the certificate is still the only thing standing between the
++    // control plane and anyone who can route to it.
++    let server = match listener {
++        Listener::Tcp(listener) => server.listen_rustls_0_23(listener, server_config)?,
++        Listener::Unix(listener) => server.listen_uds(listener)?,
++    }
++    .run();
+ 
+     let server_handle = server.handle();
+     let tray_enabled = tray::is_supported() && ctrl_for_tray.lock().await.config.tray_enabled();
diff --git a/src/audio.c b/src/audio.c
new file mode 100644
index 0000000..2089149
--- /dev/null
+++ b/src/audio.c
@@ -0,0 +1,494 @@
+/*
+ * QEMU D-Bus audio out listener, encoded to Opus and muxed to kymux.
+ *
+ * Kyber's own audio path captures a PulseAudio monitor on the machine it runs
+ * on (kyavservice/src/audio.rs). A hypervisor node has no such monitor and the
+ * guest's audio is not on it, so this takes the same route the display does:
+ * QEMU hands over one end of a socketpair, speaks peer-to-peer D-Bus on it and
+ * calls into org.qemu.Display1.AudioOutListener, exactly as the display
+ * listener works - see listener.c, which this deliberately mirrors.
+ *
+ * The frames go straight into the encoder's src_frames FIFO rather than
+ * through a txproto IO system, which is what sink.c does for video and what
+ * lets this exist at all: txproto has no source that reads from D-Bus.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <errno.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <gio/gunixfdlist.h>
+
+#include <libtxproto/txproto.h>
+#include <libtxproto/encode.h>
+#include <libtxproto/fifo_frame.h>
+
+#include <libavutil/channel_layout.h>
+#include <libavutil/opt.h>
+
+#include "kqs.h"
+
+/*
+ * Opus codes 48kHz only, and QEMU resamples to whatever the audiodev asks
+ * for, so the pipeline is pinned here rather than resampled in between.
+ * 20ms is Opus's default frame and what Kyber's own audio path asks for.
+ */
+#define KQS_AUDIO_RATE       48000
+#define KQS_AUDIO_CHANNELS   2
+#define KQS_AUDIO_FRAME_MS   20
+#define KQS_AUDIO_FRAME_SAMPLES (KQS_AUDIO_RATE * KQS_AUDIO_FRAME_MS / 1000)
+
+static const char kqs_audio_xml[] =
+    "<node>"
+    "  <interface name='org.qemu.Display1.AudioOutListener'>"
+    "    <method name='Init'>"
+    "      <arg type='t' name='id' direction='in'/>"
+    "      <arg type='y' name='bits' direction='in'/>"
+    "      <arg type='b' name='is_signed' direction='in'/>"
+    "      <arg type='b' name='is_float' direction='in'/>"
+    "      <arg type='u' name='freq' direction='in'/>"
+    "      <arg type='y' name='nchannels' direction='in'/>"
+    "      <arg type='u' name='bytes_per_frame' direction='in'/>"
+    "      <arg type='u' name='bytes_per_second' direction='in'/>"
+    "      <arg type='b' name='be' direction='in'/>"
+    "    </method>"
+    "    <method name='Fini'>"
+    "      <arg type='t' name='id' direction='in'/>"
+    "    </method>"
+    "    <method name='SetEnabled'>"
+    "      <arg type='t' name='id' direction='in'/>"
+    "      <arg type='b' name='enabled' direction='in'/>"
+    "    </method>"
+    "    <method name='SetVolume'>"
+    "      <arg type='t' name='id' direction='in'/>"
+    "      <arg type='b' name='mute' direction='in'/>"
+    "      <arg type='ay' name='volume' direction='in'/>"
+    "    </method>"
+    "    <method name='Write'>"
+    "      <arg type='t' name='id' direction='in'/>"
+    "      <arg type='ay' name='data' direction='in'/>"
+    "    </method>"
+    "  </interface>"
+    "</node>";
+
+struct KqsAudio {
+    TXMainContext *tx;      /* our own: tx_init() is once per process */
+    char *kymux_uri;
+    int bitrate_bps;
+
+    GDBusConnection *peer;
+    GDBusNodeInfo *node;
+    guint reg_id;
+
+    AVBufferRef *encoder;
+    AVBufferRef *muxer;
+    AVBufferRef *fifo;      /* borrowed: encoder's src_frames */
+
+    /* The stream QEMU announced in Init, and whether it is playing. */
+    guint64 stream_id;
+    gboolean have_stream;
+    gboolean enabled;
+    guint32 freq;
+    guint8 channels;
+    guint32 bytes_per_frame;
+
+    /*
+     * Opus takes fixed-size frames, and QEMU writes whatever the guest
+     * produced, so samples are accumulated here until a frame's worth exists.
+     */
+    GByteArray *pending;
+    int64_t samples_sent;
+
+    uint64_t frames;
+    uint64_t dropped;
+};
+
+/* -- pipeline ------------------------------------------------------------ */
+
+static bool audio_build(KqsAudio *a, GError **error)
+{
+    AVDictionary *opts = NULL;
+    /* Same options Kyber's own audio path uses, so a client sees no
+     * difference between a guest stream and a desktop one. */
+    av_dict_set_int(&opts, "b", a->bitrate_bps, 0);
+    av_dict_set(&opts, "application", "audio", 0);
+    av_dict_set_int(&opts, "frame_duration", KQS_AUDIO_FRAME_MS, 0);
+    av_dict_set(&opts, "vbr", "on", 0);
+
+    /*
+     * No sample rate or format here: TxEncoderOptions carries video fields
+     * only, and txproto configures an encoder from the first frame on its
+     * src_frames FIFO - which is why Init pushes a seed before committing.
+     */
+    TxEncoderOptions eopts = {
+        .enc_name = "libopus",
+        .name     = "guest-audio",
+        .options  = opts,
+        .pix_fmt  = AV_PIX_FMT_NONE,
+    };
+
+    a->encoder = tx_encoder_create(a->tx, &eopts);
+    if (!a->encoder) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "libopus unavailable");
+        return false;
+    }
+
+    /*
+     * The packet sink refuses an encoder without AV_CODEC_FLAG_GLOBAL_HEADER
+     * ("Packet sink requires global header"), and linking an encoder to one
+     * asks for it explicitly - link.c calls encoder_mode_negotiate(src, 0).
+     * So the producer sets it, as sink.c does; the flag is applied when the
+     * encoder configures itself from the first frame.
+     */
+    ((EncodingContext *)a->encoder->data)->need_global_header = 1;
+
+    a->muxer = tx_packetsink_create(a->tx, a->kymux_uri);
+    if (!a->muxer) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "packet sink for %s failed", a->kymux_uri);
+        return false;
+    }
+
+    if (tx_link(a->tx, a->encoder, a->muxer, 0) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_link failed");
+        return false;
+    }
+
+    a->fifo = ((EncodingContext *)a->encoder->data)->src_frames;
+    return true;
+}
+
+/*
+ * txproto derives the encoder's parameters from frame->opaque_ref, not from an
+ * AVCodecContext - there is none when the first frame lands. init_avctx()
+ * dereferences it unconditionally, so a frame without one segfaults inside
+ * tx_commit(); its audio branch reads time_base and bits_per_sample from here.
+ * sink.c's attach_timing() does the same for video.
+ *
+ * pts is counted in samples, so the time base is 1/rate.
+ */
+static bool audio_attach_timing(AVFrame *f)
+{
+    FormatExtraData *fe = av_mallocz(sizeof(*fe));
+    if (!fe)
+        return false;
+
+    fe->time_base       = (AVRational){ 1, KQS_AUDIO_RATE };
+    fe->avg_frame_rate  = (AVRational){ KQS_AUDIO_RATE, KQS_AUDIO_FRAME_SAMPLES };
+    fe->bits_per_sample = 16;
+
+    f->opaque_ref = av_buffer_create((uint8_t *)fe, sizeof(*fe), NULL, NULL, 0);
+    if (!f->opaque_ref) {
+        av_free(fe);
+        return false;
+    }
+    return true;
+}
+
+/* An empty frame of the shape the encoder should configure itself for. */
+static AVFrame *audio_blank_frame(void)
+{
+    AVFrame *f = av_frame_alloc();
+    if (!f)
+        return NULL;
+
+    f->format      = AV_SAMPLE_FMT_S16;
+    f->sample_rate = KQS_AUDIO_RATE;
+    f->nb_samples  = KQS_AUDIO_FRAME_SAMPLES;
+    av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS);
+
+    if (av_frame_get_buffer(f, 0) < 0) {
+        av_frame_free(&f);
+        return NULL;
+    }
+
+    av_samples_set_silence(f->data, 0, f->nb_samples, KQS_AUDIO_CHANNELS,
+                           AV_SAMPLE_FMT_S16);
+    f->pts = 0;
+
+    if (!audio_attach_timing(f)) {
+        av_frame_free(&f);
+        return NULL;
+    }
+    return f;
+}
+
+/* One 20ms frame out of the accumulator. */
+static AVFrame *audio_take_frame(KqsAudio *a)
+{
+    const guint need = KQS_AUDIO_FRAME_SAMPLES * KQS_AUDIO_CHANNELS
+                       * sizeof(int16_t);
+    if (a->pending->len < need)
+        return NULL;
+
+    AVFrame *f = av_frame_alloc();
+    if (!f)
+        return NULL;
+
+    f->format      = AV_SAMPLE_FMT_S16;
+    f->sample_rate = KQS_AUDIO_RATE;
+    f->nb_samples  = KQS_AUDIO_FRAME_SAMPLES;
+    av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS);
+
+    if (av_frame_get_buffer(f, 0) < 0) {
+        av_frame_free(&f);
+        return NULL;
+    }
+
+    memcpy(f->data[0], a->pending->data, need);
+    g_byte_array_remove_range(a->pending, 0, need);
+
+    /* In samples, which is the encoder's time base. */
+    f->pts = a->samples_sent;
+    a->samples_sent += KQS_AUDIO_FRAME_SAMPLES;
+
+    if (!audio_attach_timing(f)) {
+        av_frame_free(&f);
+        return NULL;
+    }
+    return f;
+}
+
+/* -- D-Bus --------------------------------------------------------------- */
+
+static void audio_init_stream(KqsAudio *a, GVariant *params)
+{
+    guint64 id;
+    guint8 bits, nchannels;
+    gboolean is_signed, is_float, be;
+    guint32 freq, bytes_per_frame, bytes_per_second;
+
+    /* t id, y bits, b signed, b float, u freq, y channels, u bpf, u bps, b be */
+    g_variant_get(params, "(tybbuyuub)", &id, &bits, &is_signed, &is_float,
+                  &freq, &nchannels, &bytes_per_frame, &bytes_per_second, &be);
+
+    g_message("audio stream %" G_GUINT64_FORMAT ": %uHz %uch %ubit "
+              "(signed=%d float=%d be=%d)",
+              id, freq, nchannels, bits, is_signed, is_float, be);
+
+    /*
+     * QEMU converts to whatever the audiodev was configured for, so this is
+     * the format asked for on the command line. Anything else would need a
+     * resampler, and saying so is better than sending noise.
+     */
+    if (bits != 16 || is_float || !is_signed || be ||
+        freq != KQS_AUDIO_RATE || nchannels != KQS_AUDIO_CHANNELS) {
+        g_warning("unsupported audio format; expected %dHz %dch s16le",
+                  KQS_AUDIO_RATE, KQS_AUDIO_CHANNELS);
+        return;
+    }
+
+    if (!a->encoder) {
+        g_autoptr(GError) error = NULL;
+        if (!audio_build(a, &error)) {
+            g_warning("audio pipeline failed: %s", error->message);
+            return;
+        }
+
+        /*
+         * The encoder takes its parameters from the first frame, and the
+         * muxer takes its from the encoder, so tx_commit() blocks until one
+         * exists. 20ms of silence costs nothing and unblocks both.
+         */
+        AVFrame *seed = audio_blank_frame();
+        if (!seed || sp_frame_fifo_push(a->fifo, seed) < 0) {
+            g_warning("could not seed the audio encoder");
+            av_frame_free(&seed);
+            return;
+        }
+        av_frame_free(&seed);
+        a->samples_sent = KQS_AUDIO_FRAME_SAMPLES;
+
+        if (tx_commit(a->tx) < 0) {
+            g_warning("tx_commit failed for audio");
+            return;
+        }
+        g_message("audio: streaming to %s", a->kymux_uri);
+    }
+
+    a->stream_id       = id;
+    a->have_stream     = TRUE;
+    a->freq            = freq;
+    a->channels        = nchannels;
+    a->bytes_per_frame = bytes_per_frame;
+}
+
+static void audio_write(KqsAudio *a, GVariant *params)
+{
+    guint64 id;
+    g_autoptr(GVariant) data = NULL;
+
+    g_variant_get(params, "(t@ay)", &id, &data);
+
+    if (!a->have_stream || id != a->stream_id || !a->enabled)
+        return;
+
+    gsize len = 0;
+    const guint8 *pcm = g_variant_get_fixed_array(data, &len, 1);
+    if (!len)
+        return;
+
+    g_byte_array_append(a->pending, pcm, len);
+
+    AVFrame *f;
+    while ((f = audio_take_frame(a))) {
+        if (sp_frame_fifo_push(a->fifo, f) < 0) {
+            /* Full: the encoder is behind. Dropping is right for audio -
+             * queueing would only add latency that never comes back. */
+            a->dropped++;
+            av_frame_free(&f);
+            continue;
+        }
+        av_frame_free(&f);
+        a->frames++;
+    }
+}
+
+static void audio_method(GDBusConnection *conn, const char *sender,
+                         const char *path, const char *iface,
+                         const char *method, GVariant *params,
+                         GDBusMethodInvocation *inv, gpointer user_data)
+{
+    KqsAudio *a = user_data;
+
+    if (!g_strcmp0(method, "Init")) {
+        audio_init_stream(a, params);
+    } else if (!g_strcmp0(method, "Write")) {
+        audio_write(a, params);
+    } else if (!g_strcmp0(method, "SetEnabled")) {
+        guint64 id;
+        gboolean enabled;
+        g_variant_get(params, "(tb)", &id, &enabled);
+        if (a->have_stream && id == a->stream_id) {
+            a->enabled = enabled;
+            g_message("audio %s", enabled ? "playing" : "stopped");
+            /* Stale samples would be played at the wrong time on resume. */
+            if (!enabled)
+                g_byte_array_set_size(a->pending, 0);
+        }
+    } else if (!g_strcmp0(method, "Fini")) {
+        guint64 id;
+        g_variant_get(params, "(t)", &id);
+        if (a->have_stream && id == a->stream_id) {
+            a->have_stream = FALSE;
+            a->enabled = FALSE;
+            g_byte_array_set_size(a->pending, 0);
+        }
+    }
+    /* SetVolume is accepted and ignored: the guest's mixer is the guest's. */
+
+    g_dbus_method_invocation_return_value(inv, NULL);
+}
+
+static const GDBusInterfaceVTable audio_vtable = {
+    .method_call = audio_method,
+};
+
+/* -- lifecycle ----------------------------------------------------------- */
+
+KqsAudio *kqs_audio_new(GDBusConnection *bus, const char *bus_name,
+                        const char *kymux_uri, int bitrate_bps,
+                        GError **error)
+{
+    KqsAudio *a = g_new0(KqsAudio, 1);
+    a->kymux_uri   = g_strdup(kymux_uri);
+    a->bitrate_bps = bitrate_bps;
+    a->pending     = g_byte_array_new();
+
+    /* See sink.c: tx_init() is not re-entrant, so this process serves audio
+     * and nothing else. avservice launches it separately from the video one. */
+    a->tx = tx_new();
+    if (!a->tx || tx_init(a->tx) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_init failed");
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    /* The pipeline waits for Init: only then is the guest's format known,
+     * and an encoder cannot be committed without a frame to configure from. */
+
+    int sv[2];
+    if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "socketpair: %s", g_strerror(errno));
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    g_autoptr(GUnixFDList) fds = g_unix_fd_list_new_from_array(&sv[1], 1);
+    g_autoptr(GVariant) reply = g_dbus_connection_call_with_unix_fd_list_sync(
+        bus, bus_name, "/org/qemu/Display1/Audio",
+        "org.qemu.Display1.Audio", "RegisterOutListener",
+        g_variant_new("(h)", 0), NULL, G_DBUS_CALL_FLAGS_NONE, -1,
+        fds, NULL, NULL, error);
+
+    if (!reply) {
+        g_prefix_error(error, "RegisterOutListener failed: ");
+        close(sv[0]);
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    g_autoptr(GSocket) sock = g_socket_new_from_fd(sv[0], error);
+    if (!sock) {
+        close(sv[0]);
+        kqs_audio_free(a);
+        return NULL;
+    }
+    g_autoptr(GSocketConnection) sconn = g_socket_connection_factory_create_connection(sock);
+
+    /* QEMU is the authentication server on its end, as for the display. */
+    a->peer = g_dbus_connection_new_sync(
+        G_IO_STREAM(sconn), NULL,
+        G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT,
+        NULL, NULL, error);
+    if (!a->peer) {
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    a->node = g_dbus_node_info_new_for_xml(kqs_audio_xml, error);
+    if (!a->node) {
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    a->reg_id = g_dbus_connection_register_object(
+        a->peer, "/org/qemu/Display1/AudioOutListener",
+        a->node->interfaces[0], &audio_vtable, a, NULL, error);
+    if (!a->reg_id) {
+        kqs_audio_free(a);
+        return NULL;
+    }
+
+    g_message("audio: registered with QEMU, waiting for a stream");
+    return a;
+}
+
+void kqs_audio_stats(const KqsAudio *a, uint64_t *frames, uint64_t *dropped)
+{
+    if (frames)  *frames  = a ? a->frames : 0;
+    if (dropped) *dropped = a ? a->dropped : 0;
+}
+
+void kqs_audio_free(KqsAudio *a)
+{
+    if (!a)
+        return;
+
+    if (a->fifo)
+        sp_frame_fifo_push(a->fifo, NULL);   /* flush sentinel, as sink.c */
+
+    if (a->reg_id && a->peer)
+        g_dbus_connection_unregister_object(a->peer, a->reg_id);
+    g_clear_pointer(&a->node, g_dbus_node_info_unref);
+    g_clear_object(&a->peer);
+
+    if (a->pending)
+        g_byte_array_free(a->pending, TRUE);
+    g_free(a->kymux_uri);
+    g_free(a);
+}
diff --git a/src/dmabuf.c b/src/dmabuf.c
new file mode 100644
index 0000000..f7cc761
--- /dev/null
+++ b/src/dmabuf.c
@@ -0,0 +1,701 @@
+/*
+ * DMA-BUF import: QEMU scanout descriptors -> VAAPI surfaces.
+ *
+ * This is the path that makes the design worth building. QEMU hands over file
+ * descriptors for the guest's scanout buffer instead of pixels, and the
+ * encoder reads them where they already are. No CPU copy, no sws_scale.
+ *
+ * The mapping goes DRM_PRIME -> VAAPI in two steps because that is how
+ * libavutil models it: wrap the fds in an AVDRMFrameDescriptor, then
+ * av_hwframe_map() that into a VAAPI frames context. txproto's encoder then
+ * sees an ordinary hardware frame.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <drm_fourcc.h>
+#include <errno.h>
+#include <linux/dma-buf.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <libavfilter/avfilter.h>
+#include <libavfilter/buffersink.h>
+#include <libavfilter/buffersrc.h>
+#include <libavutil/hwcontext.h>
+#include <libavutil/opt.h>
+#include <libavutil/hwcontext_drm.h>
+#include <libavutil/pixdesc.h>
+
+#include "kqs.h"
+
+void kqs_dmabuf_init(KqsDmabuf *d)
+{
+    memset(d, 0, sizeof(*d));
+    for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++)
+        d->fds[i] = -1;
+}
+
+void kqs_dmabuf_clear(KqsDmabuf *d)
+{
+    for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++) {
+        if (d->fds[i] >= 0)
+            close(d->fds[i]);
+    }
+    kqs_dmabuf_init(d);
+}
+
+/*
+ * QEMU sends DRM fourccs. Only the formats a virtio-gpu scanout actually
+ * produces are listed; anything else should fail loudly rather than be
+ * silently misinterpreted as the wrong colour order.
+ */
+/*
+ * The alpha-less twin of a scanout fourcc, or the fourcc itself.
+ *
+ * A scanout is already composited, so its alpha channel carries nothing, and
+ * the conversion to NV12 discards it regardless - but VAAPI on this driver
+ * refuses to import an alpha-bearing buffer at all:
+ *
+ *   Failed to create surface from DRM object: 2 (resource allocation failed)
+ *
+ * and then no frame is ever mapped, the encoder never commits, and the client
+ * sits on a frozen picture with nothing in the log to suggest a format
+ * problem. The guest picks the format per mode rather than per session -
+ * 1280x800 and 1920x1080 arrive as XRGB, 800x600 and 1280x768 as ARGB - so
+ * this presents as a resolution that randomly fails to stream.
+ *
+ * This has to be applied to the DRM descriptor, not just to the AVPixelFormat:
+ * libva imports by the fourcc in the descriptor, so declaring an alpha-less
+ * AVPixelFormat while still handing over an ARGB layer changes nothing.
+ */
+uint32_t kqs_fourcc_opaque(uint32_t fourcc)
+{
+    switch (fourcc) {
+    case DRM_FORMAT_ARGB8888: return DRM_FORMAT_XRGB8888;
+    case DRM_FORMAT_ABGR8888: return DRM_FORMAT_XBGR8888;
+    default:                  return fourcc;
+    }
+}
+
+enum AVPixelFormat kqs_fourcc_to_av(uint32_t fourcc)
+{
+    switch (fourcc) {
+    /*
+     * Alpha is dropped on purpose. A scanout is already composited, so its
+     * alpha channel carries nothing, and it is discarded again by the
+     * conversion to NV12 - but asking VAAPI to import an alpha-bearing
+     * surface fails outright on this driver:
+     *
+     *   Failed to create surface from DRM object: 2 (resource allocation
+     *   failed)
+     *
+     * and then no frame is ever mapped, so the encoder never commits and the
+     * client sits on a frozen picture. The guest chooses between XRGB and
+     * ARGB per mode for reasons of its own - 1280x800 and 1920x1080 came
+     * through as XRGB, 800x600 and 1280x768 as ARGB - which is what made this
+     * look like a random failure rather than a format one.
+     */
+    case DRM_FORMAT_XRGB8888:
+    case DRM_FORMAT_ARGB8888: return AV_PIX_FMT_BGR0;
+    case DRM_FORMAT_XBGR8888:
+    case DRM_FORMAT_ABGR8888: return AV_PIX_FMT_RGB0;
+    case DRM_FORMAT_NV12:     return AV_PIX_FMT_NV12;
+    default:                  return AV_PIX_FMT_NONE;
+    }
+}
+
+const char *kqs_fourcc_str(uint32_t f, char buf[5])
+{
+    buf[0] = (char)(f & 0xff);
+    buf[1] = (char)((f >> 8) & 0xff);
+    buf[2] = (char)((f >> 16) & 0xff);
+    buf[3] = (char)((f >> 24) & 0xff);
+    buf[4] = '\0';
+    return buf;
+}
+
+struct KqsHwCtx {
+    AVBufferRef *drm_device;
+    AVBufferRef *vaapi_device;
+    AVBufferRef *drm_frames;      /* rebuilt when geometry changes */
+    AVBufferRef *vaapi_frames;
+    int frames_w, frames_h;
+    enum AVPixelFormat frames_sw;
+
+    /* RGB -> NV12, on the GPU. Rebuilt alongside the frames contexts. */
+    AVFilterGraph   *graph;
+    AVFilterContext *gsrc;
+    AVFilterContext *gsink;
+    bool graph_vflip;
+    int  graph_out_w, graph_out_h;
+    bool warned_linear;
+    bool warned_upload;
+};
+
+KqsHwCtx *kqs_hw_new(const char *render_node, GError **error)
+{
+    KqsHwCtx *h = g_new0(KqsHwCtx, 1);
+    int err;
+
+    err = av_hwdevice_ctx_create(&h->drm_device, AV_HWDEVICE_TYPE_DRM,
+                                 render_node, NULL, 0);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "cannot open DRM device %s", render_node);
+        g_free(h);
+        return NULL;
+    }
+
+    /*
+     * Deriving VAAPI from the same DRM device keeps both on one GPU, which is
+     * what makes the import free. Creating VAAPI independently can land on a
+     * different node and silently turn the map into a copy.
+     */
+    err = av_hwdevice_ctx_create_derived(&h->vaapi_device,
+                                         AV_HWDEVICE_TYPE_VAAPI,
+                                         h->drm_device, 0);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "cannot derive VAAPI from %s", render_node);
+        av_buffer_unref(&h->drm_device);
+        g_free(h);
+        return NULL;
+    }
+
+    return h;
+}
+
+void kqs_hw_free(KqsHwCtx *h)
+{
+    if (!h)
+        return;
+    avfilter_graph_free(&h->graph);
+    av_buffer_unref(&h->drm_frames);
+    av_buffer_unref(&h->vaapi_frames);
+    av_buffer_unref(&h->vaapi_device);
+    av_buffer_unref(&h->drm_device);
+    g_free(h);
+}
+
+AVBufferRef *kqs_hw_vaapi_device(KqsHwCtx *h)
+{
+    return h->vaapi_device;
+}
+
+static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip,
+                            int out_w, int out_h, GError **error);
+
+static bool hw_ensure_frames(KqsHwCtx *h, int w, int h_px,
+                             enum AVPixelFormat sw, bool vflip,
+                             int out_w, int out_h, GError **error)
+{
+    if (h->drm_frames && h->vaapi_frames &&
+        h->frames_w == w && h->frames_h == h_px && h->frames_sw == sw)
+        return h->graph && h->graph_vflip == vflip &&
+               h->graph_out_w == out_w && h->graph_out_h == out_h
+             ? true
+             : hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error);
+
+    avfilter_graph_free(&h->graph);
+    av_buffer_unref(&h->drm_frames);
+    av_buffer_unref(&h->vaapi_frames);
+
+    h->drm_frames = av_hwframe_ctx_alloc(h->drm_device);
+    if (!h->drm_frames) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames alloc");
+        return false;
+    }
+    AVHWFramesContext *dfc = (AVHWFramesContext *)h->drm_frames->data;
+    dfc->format    = AV_PIX_FMT_DRM_PRIME;
+    dfc->sw_format = sw;
+    dfc->width     = w;
+    dfc->height    = h_px;
+    if (av_hwframe_ctx_init(h->drm_frames) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames init");
+        return false;
+    }
+
+    /*
+     * VAAPI encoders want NV12; the scanout is packed RGB. Mapping cannot
+     * change the colour space, so the frames context is created with the
+     * scanout's own sw_format and the encoder converts on the GPU.
+     */
+    h->vaapi_frames = av_hwframe_ctx_alloc(h->vaapi_device);
+    if (!h->vaapi_frames) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "VAAPI frames alloc");
+        return false;
+    }
+    AVHWFramesContext *vfc = (AVHWFramesContext *)h->vaapi_frames->data;
+    vfc->format    = AV_PIX_FMT_VAAPI;
+    vfc->sw_format = sw;
+    vfc->width     = w;
+    vfc->height    = h_px;
+    if (av_hwframe_ctx_init(h->vaapi_frames) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "VAAPI frames init (%s %dx%d)",
+                    av_get_pix_fmt_name(sw), w, h_px);
+        return false;
+    }
+
+    h->frames_w  = w;
+    h->frames_h  = h_px;
+    h->frames_sw = sw;
+
+    /* The graph is bound to that frames context, so it goes with it. */
+    return hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error);
+}
+
+static void drm_desc_free(void *opaque, uint8_t *data)
+{
+    av_free(data);
+}
+
+/*
+ * Wrap the descriptor QEMU sent as an AVFrame. The fds stay owned by the
+ * KqsDmabuf - libavutil does not close them here - so the caller must keep
+ * them alive until the returned frame is unreferenced.
+ */
+static AVFrame *drm_frame_from(KqsHwCtx *h, const KqsDmabuf *d, GError **error, uint64_t modifier)
+{
+    AVDRMFrameDescriptor *desc = av_mallocz(sizeof(*desc));
+    if (!desc) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc alloc");
+        return NULL;
+    }
+
+    desc->nb_objects = d->n_fds;
+    for (int i = 0; i < d->n_fds; i++) {
+        desc->objects[i].fd              = d->fds[i];
+        desc->objects[i].size            = 0;
+        desc->objects[i].format_modifier = modifier;
+    }
+
+    desc->nb_layers            = 1;
+    desc->layers[0].format     = kqs_fourcc_opaque(d->fourcc);
+    desc->layers[0].nb_planes  = d->num_planes;
+    for (int p = 0; p < d->num_planes; p++) {
+        /* One fd may back every plane, or each plane may have its own. */
+        desc->layers[0].planes[p].object_index = (d->n_fds == 1) ? 0 : p;
+        desc->layers[0].planes[p].offset       = d->offsets[p];
+        desc->layers[0].planes[p].pitch        = d->strides[p];
+    }
+
+    AVFrame *f = av_frame_alloc();
+    if (!f) {
+        av_free(desc);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame alloc");
+        return NULL;
+    }
+
+    f->format = AV_PIX_FMT_DRM_PRIME;
+    f->width  = d->backing_width;
+    f->height = d->backing_height;
+    f->data[0] = (uint8_t *)desc;
+    f->buf[0] = av_buffer_create((uint8_t *)desc, sizeof(*desc),
+                                 drm_desc_free, NULL, 0);
+    if (!f->buf[0]) {
+        av_free(desc);
+        av_frame_free(&f);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc buffer");
+        return NULL;
+    }
+
+    f->hw_frames_ctx = av_buffer_ref(h->drm_frames);
+    if (!f->hw_frames_ctx) {
+        av_frame_free(&f);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frames ref");
+        return NULL;
+    }
+
+    return f;
+}
+
+/*
+ * A virtio-gpu scanout is packed RGB and h264_vaapi has no RGB input profile -
+ * it answers "Input surface format is rgba" and then "No usable encoding
+ * profile found". Something has to convert, and it has to stay on the GPU or
+ * the whole zero-copy path is pointless.
+ *
+ * Doing it here rather than as a stage in the txproto pipeline is deliberate.
+ * A filtergraph between the import and the encoder was tried and deadlocks:
+ * seeding the filter's input leaves the encoder with no frame to configure
+ * from, so tx_commit() blocks exactly as it does on an empty encoder FIFO,
+ * one stage deeper. Converting before the frame is ever handed over keeps the
+ * pipeline a plain encoder that can still be seeded directly.
+ */
+static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip,
+                            int out_w, int out_h, GError **error)
+{
+    int err;
+
+    avfilter_graph_free(&h->graph);
+
+    h->graph = avfilter_graph_alloc();
+    if (!h->graph) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter graph alloc");
+        return false;
+    }
+
+    /*
+     * Build the source in two steps rather than from an args string. The hw
+     * frames context can only be attached through AVBufferSrcParameters, and
+     * that has to happen before the filter is initialised - which rules out
+     * avfilter_graph_create_filter(), since it inits for you.
+     */
+    h->gsrc = avfilter_graph_alloc_filter(h->graph, avfilter_get_by_name("buffer"),
+                                          "in");
+    if (!h->gsrc) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc alloc");
+        return false;
+    }
+
+    av_opt_set_int(h->gsrc, "width",  w,    AV_OPT_SEARCH_CHILDREN);
+    av_opt_set_int(h->gsrc, "height", h_px, AV_OPT_SEARCH_CHILDREN);
+    av_opt_set(h->gsrc, "pix_fmt", av_get_pix_fmt_name(AV_PIX_FMT_VAAPI),
+               AV_OPT_SEARCH_CHILDREN);
+    av_opt_set(h->gsrc, "time_base",    "1/1000000", AV_OPT_SEARCH_CHILDREN);
+    av_opt_set(h->gsrc, "pixel_aspect", "1/1",       AV_OPT_SEARCH_CHILDREN);
+
+    AVBufferSrcParameters *par = av_buffersrc_parameters_alloc();
+    if (!par) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc params");
+        return false;
+    }
+    par->format        = AV_PIX_FMT_VAAPI;
+    par->width         = w;
+    par->height        = h_px;
+    par->hw_frames_ctx = h->vaapi_frames;
+    err = av_buffersrc_parameters_set(h->gsrc, par);
+    av_freep(&par);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "buffersrc params set (%d)", err);
+        return false;
+    }
+
+    err = avfilter_init_str(h->gsrc, NULL);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "buffersrc init (%d)", err);
+        return false;
+    }
+
+    /*
+     * Scale to the encoder's geometry, not the scanout's. The encoder is built
+     * once per session and cannot be resized mid-stream, so this is what has
+     * to absorb a guest that changes resolution - exactly what swscale does on
+     * the CPU path.
+     *
+     * Leaving w/h unset passes the scanout size straight through, and the
+     * encoder then quietly emits nothing usable: OVMF hands over at 640x480,
+     * the guest comes up at 1280x800, and the client is black while every log
+     * line still reports a healthy pipeline.
+     */
+    g_autofree char *scale_arg =
+        g_strdup_printf("w=%d:h=%d:format=nv12", out_w, out_h);
+
+    AVFilterContext *scale = NULL;
+    err = avfilter_graph_create_filter(&scale,
+                                       avfilter_get_by_name("scale_vaapi"),
+                                       "nv12", scale_arg, NULL, h->graph);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "scale_vaapi create (%d) - is FFmpeg built with VAAPI "
+                    "filters?", err);
+        return false;
+    }
+
+    err = avfilter_graph_create_filter(&h->gsink,
+                                       avfilter_get_by_name("buffersink"),
+                                       "out", NULL, NULL, h->graph);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersink create");
+        return false;
+    }
+
+    /*
+     * A virgl scanout has its origin at the bottom left, the way OpenGL does,
+     * and QEMU says so per scanout with y0_top. Video wants the top row first,
+     * so an unflipped GL guest arrives upside down. transpose_vaapi does it as
+     * part of the same VPP pass, so it stays on the GPU and costs nothing
+     * measurable.
+     */
+    /*
+     * vflip is the correction, and the caller decides when to ask for it -
+     * see the note on y0_top there. KQS_DMABUF_FLIP overrides the direction,
+     * and "none" disables it for the vhost-user helper, whose buffers arrive
+     * upright with the flag clear.
+     */
+    const char *dir = g_getenv("KQS_DMABUF_FLIP");
+    if (!dir || !*dir)
+        dir = "vflip";
+
+    AVFilterContext *flip = NULL;
+    if (vflip && g_strcmp0(dir, "none") != 0) {
+        g_autofree char *dir_arg = g_strdup_printf("dir=%s", dir);
+
+        err = avfilter_graph_create_filter(&flip,
+                                           avfilter_get_by_name("transpose_vaapi"),
+                                           "flip", dir_arg, NULL, h->graph);
+        if (err < 0) {
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                        "transpose_vaapi create (%d)", err);
+            return false;
+        }
+    }
+
+    if (avfilter_link(h->gsrc, 0, scale, 0) < 0 ||
+        (flip && avfilter_link(scale, 0, flip, 0) < 0) ||
+        avfilter_link(flip ? flip : scale, 0, h->gsink, 0) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter link");
+        return false;
+    }
+
+    /*
+     * scale_vaapi allocates its output frames from a context derived from the
+     * input's device, so the device reference has to be visible to the graph
+     * before configuration.
+     */
+    h->graph->filters[0]->hw_device_ctx = av_buffer_ref(h->vaapi_device);
+
+    err = avfilter_graph_config(h->graph, NULL);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "filter graph config (%d)", err);
+        return false;
+    }
+
+    h->graph_vflip = vflip;
+    h->graph_out_w = out_w;
+    h->graph_out_h = out_h;
+    return true;
+}
+
+/* Push one imported RGB surface through the graph, get NV12 back. */
+static AVFrame *hw_to_nv12(KqsHwCtx *h, AVFrame *va, GError **error)
+{
+    int err = av_buffersrc_add_frame_flags(h->gsrc, va,
+                                           AV_BUFFERSRC_FLAG_KEEP_REF);
+    av_frame_free(&va);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "buffersrc push failed (%d)", err);
+        return NULL;
+    }
+
+    AVFrame *out = av_frame_alloc();
+    if (!out) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "nv12 frame alloc");
+        return NULL;
+    }
+
+    err = av_buffersink_get_frame(h->gsink, out);
+    if (err < 0) {
+        av_frame_free(&out);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "buffersink pull failed (%d)", err);
+        return NULL;
+    }
+
+    return out;
+}
+
+/*
+ * What to do when the driver will not import the buffer at all.
+ *
+ * radeonsi needs a 256-byte aligned pitch to import a linear surface, and a
+ * scanout's pitch is exactly width * 4, so 800x600 (3200) and 1360x768 (5440)
+ * are refused however they are labelled. Mapping the memory and uploading it
+ * costs a copy per frame, which is the whole point of this path - but a mode
+ * that encodes slowly beats a mode that never appears.
+ *
+ * The upload reads straight out of the mapping: the sw frame borrows those
+ * pointers rather than owning a second buffer, so there is one copy in total,
+ * the one av_hwframe_transfer_data makes into the surface.
+ */
+static AVFrame *upload_from_map(KqsHwCtx *h, const KqsDmabuf *d,
+                                enum AVPixelFormat sw, GError **error)
+{
+    void *maps[KQS_DMABUF_MAX_PLANES] = { NULL };
+    size_t lens[KQS_DMABUF_MAX_PLANES] = { 0 };
+    AVFrame *cpu = NULL, *va = NULL;
+
+    for (int p = 0; p < d->num_planes; p++) {
+        int obj = (d->n_fds == 1) ? 0 : p;
+        size_t need = (size_t)d->offsets[p]
+                    + (size_t)d->strides[p] * (size_t)d->backing_height;
+        if (need > lens[obj])
+            lens[obj] = need;
+    }
+
+    for (int i = 0; i < d->n_fds; i++) {
+        if (!lens[i])
+            continue;
+        maps[i] = mmap(NULL, lens[i], PROT_READ, MAP_SHARED, d->fds[i], 0);
+        if (maps[i] == MAP_FAILED) {
+            maps[i] = NULL;
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                        "mmap of the scanout dmabuf failed: %s", g_strerror(errno));
+            goto out;
+        }
+        /* The GPU wrote it; ask for a coherent read before touching it. */
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ };
+        ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync);
+    }
+
+    cpu = av_frame_alloc();
+    va  = av_frame_alloc();
+    if (!cpu || !va) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame alloc");
+        goto out;
+    }
+
+    cpu->format = sw;
+    cpu->width  = d->backing_width;
+    cpu->height = d->backing_height;
+    for (int p = 0; p < d->num_planes; p++) {
+        int obj = (d->n_fds == 1) ? 0 : p;
+        cpu->data[p]     = (uint8_t *)maps[obj] + d->offsets[p];
+        cpu->linesize[p] = (int)d->strides[p];
+    }
+
+    if (av_hwframe_get_buffer(h->vaapi_frames, va, 0) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "VAAPI surface alloc");
+        goto out;
+    }
+
+    int err = av_hwframe_transfer_data(va, cpu, 0);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "upload to VAAPI failed (%dx%d, pitch %u)",
+                    d->backing_width, d->backing_height, d->strides[0]);
+        goto out;
+    }
+
+    if (!h->warned_upload) {
+        h->warned_upload = true;
+        g_message("dmabuf: pitch %u is not 256-byte aligned, uploading instead "
+                  "of mapping - this mode costs a copy per frame",
+                  d->strides[0]);
+    }
+
+    av_frame_free(&cpu);
+    for (int i = 0; i < d->n_fds; i++) {
+        if (!maps[i])
+            continue;
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ };
+        ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync);
+        munmap(maps[i], lens[i]);
+    }
+    return va;
+
+out:
+    av_frame_free(&cpu);
+    av_frame_free(&va);
+    for (int i = 0; i < d->n_fds; i++) {
+        if (!maps[i])
+            continue;
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ };
+        ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync);
+        munmap(maps[i], lens[i]);
+    }
+    return NULL;
+}
+
+AVFrame *kqs_dmabuf_to_vaapi(KqsHwCtx *h, const KqsDmabuf *d,
+                             int out_w, int out_h, GError **error)
+{
+    enum AVPixelFormat sw = kqs_fourcc_to_av(d->fourcc);
+    char fcc[5];
+
+    if (sw == AV_PIX_FMT_NONE) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+                    "unsupported scanout fourcc '%s' (0x%08x)",
+                    kqs_fourcc_str(d->fourcc, fcc), d->fourcc);
+        return NULL;
+    }
+
+    /*
+     * Measured, not deduced: on the in-process virgl path the scanouts that
+     * arrive upside down are exactly the ones QEMU marks y0_top - the ARGB
+     * buffers virgl renders, from plymouth onward. GRUB and the early kernel
+     * come through XRGB with y0_top false and are already upright, so the
+     * flag reads inverted here against its documented meaning.
+     */
+    if (!hw_ensure_frames(h, d->backing_width, d->backing_height, sw,
+                          d->y0_top, out_w, out_h, error))
+        return NULL;
+
+    AVFrame *drm = drm_frame_from(h, d, error, d->modifier);
+    if (!drm)
+        return NULL;
+
+    AVFrame *va = av_frame_alloc();
+    if (!va) {
+        av_frame_free(&drm);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc");
+        return NULL;
+    }
+
+    va->format        = AV_PIX_FMT_VAAPI;
+    va->width         = d->backing_width;
+    va->height        = d->backing_height;
+    va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames);
+
+    int err = av_hwframe_map(va, drm, AV_HWFRAME_MAP_READ);
+    av_frame_free(&drm);
+
+    /*
+     * DRM_FORMAT_MOD_INVALID means "no modifier was negotiated", so the driver
+     * has to guess the layout, and on AMD it guesses a tiled render target.
+     * These scanouts are linear - the stride is exactly width * 4 - so when
+     * the guess loses, say so explicitly and try once more.
+     *
+     * This only ever runs after a failure, so a buffer that imports on the
+     * driver's own terms keeps taking the original path untouched.
+     */
+    if (err < 0 && d->modifier == DRM_FORMAT_MOD_INVALID) {
+        av_frame_free(&va);
+
+        AVFrame *lin = drm_frame_from(h, d, error, DRM_FORMAT_MOD_LINEAR);
+        if (!lin)
+            return NULL;
+
+        va = av_frame_alloc();
+        if (!va) {
+            av_frame_free(&lin);
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc");
+            return NULL;
+        }
+        va->format        = AV_PIX_FMT_VAAPI;
+        va->width         = d->backing_width;
+        va->height        = d->backing_height;
+        va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames);
+
+        err = av_hwframe_map(va, lin, AV_HWFRAME_MAP_READ);
+        av_frame_free(&lin);
+
+        if (err >= 0 && !h->warned_linear) {
+            h->warned_linear = true;
+            g_message("dmabuf: import needed an explicit linear modifier");
+        }
+    }
+
+    if (err < 0) {
+        av_frame_free(&va);
+
+        va = upload_from_map(h, d, sw, error);
+        if (!va) {
+            g_prefix_error(error,
+                           "av_hwframe_map DRM_PRIME->VAAPI failed "
+                           "(fourcc '%s', modifier 0x%" G_GINT64_MODIFIER "x) "
+                           "and the upload fallback did not work either: ",
+                           kqs_fourcc_str(d->fourcc, fcc),
+                           (uint64_t)d->modifier);
+            return NULL;
+        }
+    }
+
+    /* The encoder wants NV12; hand it NV12 rather than what the guest drew. */
+    return hw_to_nv12(h, va, error);
+}
diff --git a/src/kqs.h b/src/kqs.h
new file mode 100644
index 0000000..df945fc
--- /dev/null
+++ b/src/kqs.h
@@ -0,0 +1,194 @@
+/*
+ * kyber-qemu-server - route a QEMU guest display into a txproto pipeline
+ *
+ * Copyright (C) 2026 Alexandre Derumier
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ *
+ * LGPL-2.1+ to match libtxproto, which this links against. Kyber's AGPL
+ * service layer stays in other processes; see the design note.
+ */
+
+#pragma once
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <libavutil/frame.h>
+#include <libavutil/pixfmt.h>
+#include <libavutil/pixdesc.h>
+#include <string.h>
+#include <unistd.h>
+
+#define KQS_LISTENER_PATH  "/org/qemu/Display1/Listener"
+#define KQS_LISTENER_IFACE "org.qemu.Display1.Listener"
+
+/*
+ * Pixman format codes, as QEMU puts them on the wire:
+ *
+ *   PIXMAN_FORMAT(bpp,type,a,r,g,b) =
+ *       (bpp << 24) | (type << 16) | (a << 12) | (r << 8) | (g << 4) | b
+ *
+ * The AVPixelFormat mapping assumes a little-endian host: pixman names
+ * channels most-significant-first inside a 32-bit word, FFmpeg names them in
+ * memory order, so the two read as reverses of one another.
+ */
+#define KQS_PIXMAN_a8r8g8b8 0x20028888u
+#define KQS_PIXMAN_x8r8g8b8 0x20020888u
+#define KQS_PIXMAN_a8b8g8r8 0x20038888u
+#define KQS_PIXMAN_x8b8g8r8 0x20030888u
+
+enum AVPixelFormat kqs_pixman_to_av(uint32_t pixman_format);
+
+/* ---------------------------------------------------------------- surface */
+
+/*
+ * The guest display.
+ *
+ * QEMU sends one full frame via Scanout and then incremental damage
+ * rectangles via Update, so the complete image only ever exists here. Frames
+ * go to the encoder on a timer rather than per-damage: a video codec wants a
+ * steady cadence, not a burst per mouse move.
+ */
+typedef struct KqsSurface {
+    int width;
+    int height;
+    int stride;
+    int bpp;
+    enum AVPixelFormat pix_fmt;
+    uint8_t *data;
+    bool dirty;
+    uint64_t generation;   /* bumped on geometry change; forces sink rebuild */
+} KqsSurface;
+
+void kqs_surface_init(KqsSurface *s);
+void kqs_surface_clear(KqsSurface *s);
+
+bool kqs_surface_scanout(KqsSurface *s, uint32_t width, uint32_t height,
+                         uint32_t stride, uint32_t pixman_format,
+                         const uint8_t *data, gsize len, GError **error);
+
+/*
+ * Grow the surface to a geometry QEMU reported but never scanned out.
+ *
+ * A mode change only produces a Scanout when the damage happens to cover the
+ * whole screen; text modes redraw in pieces, so the geometry changes and only
+ * Updates arrive. Those get clipped to the old surface - which is how GRUB
+ * loses its right-hand columns.
+ */
+bool kqs_surface_resize(KqsSurface *s, uint32_t width, uint32_t height,
+                        GError **error);
+
+bool kqs_surface_update(KqsSurface *s, int x, int y, int width, int height,
+                        uint32_t stride, uint32_t pixman_format,
+                        const uint8_t *data, gsize len, GError **error);
+
+/* ----------------------------------------------------------------- dmabuf */
+
+#define KQS_DMABUF_MAX_PLANES 4
+
+/*
+ * A QEMU ScanoutDMABUF2 descriptor. Owns its file descriptors.
+ *
+ * QEMU re-sends this only when the scanout buffer itself changes; damage in
+ * between arrives as UpdateDMABUF, which carries no payload because the
+ * pixels are already visible to us through these fds.
+ */
+typedef struct KqsDmabuf {
+    int fds[KQS_DMABUF_MAX_PLANES];
+    int n_fds;
+    uint32_t offsets[KQS_DMABUF_MAX_PLANES];
+    uint32_t strides[KQS_DMABUF_MAX_PLANES];
+    int num_planes;
+    uint32_t fourcc;
+    uint64_t modifier;
+    int x, y, width, height;
+    int backing_width, backing_height;
+    bool y0_top;
+    bool valid;
+    bool dirty;
+    uint64_t generation;
+} KqsDmabuf;
+
+void kqs_dmabuf_init(KqsDmabuf *d);
+void kqs_dmabuf_clear(KqsDmabuf *d);
+
+uint32_t kqs_fourcc_opaque(uint32_t fourcc);
+enum AVPixelFormat kqs_fourcc_to_av(uint32_t fourcc);
+const char *kqs_fourcc_str(uint32_t fourcc, char buf[5]);
+
+typedef struct KqsHwCtx KqsHwCtx;
+
+KqsHwCtx *kqs_hw_new(const char *render_node, GError **error);
+void kqs_hw_free(KqsHwCtx *h);
+AVBufferRef *kqs_hw_vaapi_device(KqsHwCtx *h);
+AVFrame *kqs_dmabuf_to_vaapi(KqsHwCtx *h, const KqsDmabuf *d,
+                             int out_w, int out_h, GError **error);
+
+/* ------------------------------------------------------------------- sink */
+
+/*
+ * txproto encoder + muxer, fed by pushing AVFrames straight into the
+ * encoder's public src_frames FIFO. Built lazily on the first frame, because
+ * geometry and pixel format are only known once QEMU has sent a Scanout.
+ */
+typedef struct KqsSink KqsSink;
+
+typedef struct KqsSinkConfig {
+    const char *encoder;     /* libx264, h264_vaapi, hevc_nvenc, ... */
+    const char *out_url;
+    const char *out_format;  /* NULL lets libavformat guess from out_url */
+    const char *kymux_uri;   /* kymux://host:port/hex-endpoint; wins over out_url */
+    int bitrate_kbps;
+    int fps;
+    /*
+     * Encoder geometry, when fixed ahead of time. A guest changes resolution
+     * as it boots, and the encoder cannot follow: rebuilding it mid-session
+     * kills the pipeline. Building at the first frame's size therefore locks
+     * the stream to GRUB's 640x480 and upscales the desktop from it.
+     *
+     * Pinning the geometry instead means every frame is scaled into it, so the
+     * boot modes are upscaled - briefly, and nobody is reading GRUB closely -
+     * and the resolution the guest settles on arrives 1:1. Zero means "take it
+     * from the first frame", the old behaviour.
+     */
+    int out_w, out_h;
+    KqsHwCtx *hw;            /* non-NULL selects the zero-copy path */
+} KqsSinkConfig;
+
+KqsSink *kqs_sink_new(const KqsSinkConfig *cfg);
+bool kqs_sink_push(KqsSink *sink, const KqsSurface *s, int64_t pts_us,
+                   GError **error);
+bool kqs_sink_push_dmabuf(KqsSink *sink, const KqsDmabuf *d, int64_t pts_us,
+                          GError **error);
+void kqs_sink_stats(const KqsSink *sink, uint64_t *pushed, uint64_t *dropped);
+void kqs_sink_free(KqsSink *sink);
+
+/* --- audio ------------------------------------------------------------- */
+/*
+ * Guest audio, from QEMU's D-Bus audio out listener to a kymux endpoint of
+ * its own. Independent of the video path: Kyber gives audio a separate
+ * endpoint, so the two never share a muxer.
+ */
+typedef struct KqsAudio KqsAudio;
+
+KqsAudio *kqs_audio_new(GDBusConnection *bus, const char *bus_name,
+                        const char *kymux_uri, int bitrate_bps,
+                        GError **error);
+void kqs_audio_stats(const KqsAudio *a, uint64_t *frames, uint64_t *dropped);
+void kqs_audio_free(KqsAudio *a);
+
+/* --------------------------------------------------------------- listener */
+
+typedef struct KqsListener KqsListener;
+
+/*
+ * Connect to a running QEMU, register as a display listener, and drive the
+ * sink. `bus_name` is normally "org.qemu"; `console` selects Console_N.
+ */
+KqsListener *kqs_listener_new(GDBusConnection *bus, const char *bus_name,
+                              int console, KqsSink *sink, int fps,
+                              bool want_dmabuf, GMainLoop *loop,
+                              GError **error);
+void kqs_listener_free(KqsListener *l);
diff --git a/src/listener.c b/src/listener.c
new file mode 100644
index 0000000..839c16f
--- /dev/null
+++ b/src/listener.c
@@ -0,0 +1,568 @@
+/*
+ * QEMU D-Bus display listener.
+ *
+ * QEMU's Console.RegisterListener takes one end of a socketpair and brings up
+ * a peer-to-peer D-Bus connection on it, acting as the authentication SERVER
+ * (ui/dbus-console.c). We are therefore the CLIENT on our end, and we export
+ * org.qemu.Display1.Listener for QEMU to call into.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <errno.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <gio/gunixfdlist.h>
+
+#include "kqs.h"
+
+/*
+ * The `Interfaces` property is how QEMU chooses what to send us: it calls
+ * dbus_display_listener_implements() before setting up shared maps or DMA-BUF
+ * (see setup_shared_map and setup_scanout_dmabuf_v2 in ui/dbus-listener.c).
+ *
+ * Advertising nothing keeps QEMU on Scanout/Update, which works with any
+ * display device and needs no guest driver support. Advertising
+ * ScanoutDMABUF2 asks for scanout file descriptors instead - which QEMU only
+ * has when it is running a GL-capable device, so it needs -display dbus,gl=on
+ * with virtio-vga-gl and a guest driving virtio-gpu 3D.
+ */
+static const char kqs_listener_xml[] =
+    "<node>"
+    "  <interface name='org.qemu.Display1.Listener'>"
+    "    <method name='Scanout'>"
+    "      <arg type='u' name='width' direction='in'/>"
+    "      <arg type='u' name='height' direction='in'/>"
+    "      <arg type='u' name='stride' direction='in'/>"
+    "      <arg type='u' name='pixman_format' direction='in'/>"
+    "      <arg type='ay' name='data' direction='in'/>"
+    "    </method>"
+    "    <method name='Update'>"
+    "      <arg type='i' name='x' direction='in'/>"
+    "      <arg type='i' name='y' direction='in'/>"
+    "      <arg type='i' name='width' direction='in'/>"
+    "      <arg type='i' name='height' direction='in'/>"
+    "      <arg type='u' name='stride' direction='in'/>"
+    "      <arg type='u' name='pixman_format' direction='in'/>"
+    "      <arg type='ay' name='data' direction='in'/>"
+    "    </method>"
+    "    <method name='Disable'/>"
+    "    <method name='MouseSet'>"
+    "      <arg type='i' name='x' direction='in'/>"
+    "      <arg type='i' name='y' direction='in'/>"
+    "      <arg type='i' name='on' direction='in'/>"
+    "    </method>"
+    "    <method name='CursorDefine'>"
+    "      <arg type='i' name='width' direction='in'/>"
+    "      <arg type='i' name='height' direction='in'/>"
+    "      <arg type='i' name='hot_x' direction='in'/>"
+    "      <arg type='i' name='hot_y' direction='in'/>"
+    "      <arg type='ay' name='data' direction='in'/>"
+    "    </method>"
+    "    <method name='UpdateDMABUF'>"
+    "      <arg type='i' name='x' direction='in'/>"
+    "      <arg type='i' name='y' direction='in'/>"
+    "      <arg type='i' name='width' direction='in'/>"
+    "      <arg type='i' name='height' direction='in'/>"
+    "    </method>"
+    "    <property name='Interfaces' type='as' access='read'/>"
+    "  </interface>"
+    "  <interface name='org.qemu.Display1.Listener.Unix.ScanoutDMABUF2'>"
+    "    <method name='ScanoutDMABUF2'>"
+    "      <arg type='ah' name='dmabuf' direction='in'/>"
+    "      <arg type='u' name='x' direction='in'/>"
+    "      <arg type='u' name='y' direction='in'/>"
+    "      <arg type='u' name='width' direction='in'/>"
+    "      <arg type='u' name='height' direction='in'/>"
+    "      <arg type='au' name='offset' direction='in'/>"
+    "      <arg type='au' name='stride' direction='in'/>"
+    "      <arg type='u' name='num_planes' direction='in'/>"
+    "      <arg type='u' name='fourcc' direction='in'/>"
+    "      <arg type='u' name='backing_width' direction='in'/>"
+    "      <arg type='u' name='backing_height' direction='in'/>"
+    "      <arg type='t' name='modifier' direction='in'/>"
+    "      <arg type='b' name='y0_top' direction='in'/>"
+    "    </method>"
+    "  </interface>"
+    "</node>";
+
+struct KqsListener {
+    GDBusConnection *peer;
+    GDBusNodeInfo *node;
+    guint reg_id;
+    guint tick_id;
+
+    KqsSurface surface;
+    KqsDmabuf dmabuf;
+    bool want_dmabuf;
+    guint dmabuf_reg_id;
+    KqsSink *sink;
+    GMainLoop *loop;
+
+    int64_t epoch_us;
+    uint64_t scanouts;
+    uint64_t updates;
+    uint64_t dmabuf_scanouts;
+
+    /* Last logged dmabuf layout, so a change in it gets a line of its own. */
+    uint32_t logged_fourcc;
+    uint64_t logged_modifier;
+    uint32_t logged_bw, logged_bh;
+
+    /* Last logged scanout geometry, so mode changes are reported once each. */
+    guint32 last_w, last_h;
+};
+
+/* ------------------------------------------------------------ dispatching */
+
+static const uint8_t *fixed_bytes(GVariant *v, gsize *len)
+{
+    return g_variant_get_fixed_array(v, len, sizeof(uint8_t));
+}
+
+/*
+ * QEMU passes scanout fds out-of-band; the 'ah' arguments are indices into
+ * the message's fd list. We take ownership of the dup'd fds and hold them
+ * until the next scanout replaces them.
+ */
+static void handle_scanout_dmabuf2(KqsListener *l, GVariant *params,
+                                   GDBusMethodInvocation *inv)
+{
+    g_autoptr(GVariant) fd_idx = NULL;
+    g_autoptr(GVariant) offsets = NULL;
+    g_autoptr(GVariant) strides = NULL;
+    guint32 x, y, w, h, num_planes, fourcc, bw, bh;
+    guint64 modifier;
+    gboolean y0_top;
+
+    g_variant_get(params, "(@ahuuuu@au@auuuuutb)",
+                  &fd_idx, &x, &y, &w, &h, &offsets, &strides,
+                  &num_planes, &fourcc, &bw, &bh, &modifier, &y0_top);
+
+    GDBusMessage *msg = g_dbus_method_invocation_get_message(inv);
+    GUnixFDList *fds = g_dbus_message_get_unix_fd_list(msg);
+
+    gsize n_idx = g_variant_n_children(fd_idx);
+    if (!fds || n_idx == 0 || n_idx > KQS_DMABUF_MAX_PLANES ||
+        num_planes == 0 || num_planes > KQS_DMABUF_MAX_PLANES) {
+        g_dbus_method_invocation_return_error(
+            inv, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS,
+            "bad dmabuf descriptor (%" G_GSIZE_FORMAT " fds, %u planes)",
+            n_idx, num_planes);
+        return;
+    }
+
+    KqsDmabuf d;
+    kqs_dmabuf_init(&d);
+
+    for (gsize i = 0; i < n_idx; i++) {
+        gint32 handle;
+        g_variant_get_child(fd_idx, i, "h", &handle);
+
+        GError *err = NULL;
+        int fd = g_unix_fd_list_get(fds, handle, &err);   /* dup'd for us */
+        if (fd < 0) {
+            g_warning("ScanoutDMABUF2: fd %d unavailable: %s", handle,
+                      err ? err->message : "?");
+            g_clear_error(&err);
+            kqs_dmabuf_clear(&d);
+            g_dbus_method_invocation_return_error(
+                inv, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "bad fd");
+            return;
+        }
+        d.fds[i] = fd;
+    }
+    d.n_fds = (int)n_idx;
+
+    for (guint32 p = 0; p < num_planes; p++) {
+        g_variant_get_child(offsets, p, "u", &d.offsets[p]);
+        g_variant_get_child(strides, p, "u", &d.strides[p]);
+    }
+
+    d.num_planes     = (int)num_planes;
+    d.fourcc         = fourcc;
+    d.modifier       = modifier;
+    d.x = (int)x; d.y = (int)y;
+    d.width = (int)w; d.height = (int)h;
+    d.backing_width  = (int)bw;
+    d.backing_height = (int)bh;
+    d.y0_top         = y0_top;
+    d.valid          = true;
+    d.dirty          = true;
+
+    /* Geometry change forces the sink to rebuild its pipeline. */
+    bool geometry_changed = !l->dmabuf.valid ||
+        l->dmabuf.backing_width  != d.backing_width ||
+        l->dmabuf.backing_height != d.backing_height ||
+        l->dmabuf.fourcc         != d.fourcc;
+
+    d.generation = l->dmabuf.generation + (geometry_changed ? 1 : 0);
+
+    kqs_dmabuf_clear(&l->dmabuf);      /* closes the previous fds */
+    l->dmabuf = d;
+
+    /*
+     * Log the first scanout, and then every time the layout changes. Logging
+     * only the first one hid the buffer that actually matters: the guest can
+     * switch fourcc mid-session - XRGB for some modes, ARGB for others - and
+     * when the ARGB one fails to import, the only descriptor in the log is the
+     * XRGB one that worked.
+     */
+    if (l->dmabuf_scanouts++ == 0 ||
+        fourcc != l->logged_fourcc || modifier != l->logged_modifier ||
+        bw != l->logged_bw || bh != l->logged_bh) {
+        char fcc[5];
+        g_message("dmabuf scanout: %ux%u (backing %ux%u) fourcc '%s' "
+                  "modifier 0x%" G_GINT64_MODIFIER "x planes=%u fds=%d "
+                  "offsets=[%u,%u] strides=[%u,%u] y0_top=%d",
+                  w, h, bw, bh, kqs_fourcc_str(fourcc, fcc),
+                  (guint64)modifier, num_planes, d.n_fds,
+                  d.offsets[0], num_planes > 1 ? d.offsets[1] : 0,
+                  d.strides[0], num_planes > 1 ? d.strides[1] : 0,
+                  (int)d.y0_top);
+        l->logged_fourcc   = fourcc;
+        l->logged_modifier = modifier;
+        l->logged_bw       = bw;
+        l->logged_bh       = bh;
+    }
+
+    g_dbus_method_invocation_return_value(inv, NULL);
+}
+
+static void handle_call(GDBusConnection *conn, const char *sender,
+                        const char *path, const char *iface,
+                        const char *method, GVariant *params,
+                        GDBusMethodInvocation *inv, gpointer user_data)
+{
+    KqsListener *l = user_data;
+    GError *err = NULL;
+
+    if (g_str_equal(method, "Scanout")) {
+        guint32 w, h, stride, fmt;
+        g_autoptr(GVariant) data = NULL;
+        g_variant_get(params, "(uuuu@ay)", &w, &h, &stride, &fmt, &data);
+
+        gsize len;
+        const uint8_t *bytes = fixed_bytes(data, &len);
+
+        if (!kqs_surface_scanout(&l->surface, w, h, stride, fmt, bytes, len,
+                                 &err)) {
+            g_warning("Scanout rejected: %s", err->message);
+            g_clear_error(&err);
+        } else if (l->scanouts++ == 0 || w != l->last_w || h != l->last_h) {
+            /*
+             * Every geometry change, not just the first: a stride that is not
+             * width*bpp means QEMU is handing us a wider backing store than
+             * the mode, and scaling from the mode's width then drops the right
+             * edge. Text modes are where that shows up.
+             */
+            g_message("scanout: %ux%u stride=%u (%u expected) pixman=0x%08x -> %s",
+                      w, h, stride, w * l->surface.bpp, fmt,
+                      av_get_pix_fmt_name(l->surface.pix_fmt));
+            l->last_w = w;
+            l->last_h = h;
+        }
+
+        g_dbus_method_invocation_return_value(inv, NULL);
+        return;
+    }
+
+    if (g_str_equal(method, "Update")) {
+        gint32 x, y, w, h;
+        guint32 stride, fmt;
+        g_autoptr(GVariant) data = NULL;
+        g_variant_get(params, "(iiiiuu@ay)", &x, &y, &w, &h, &stride, &fmt,
+                      &data);
+
+        gsize len;
+        const uint8_t *bytes = fixed_bytes(data, &len);
+
+        /*
+         * Damage that runs past the surface means the guest changed mode and
+         * we were never told: QEMU only promotes an Update to a Scanout when
+         * the damage covers the whole screen, and a text mode redraws in
+         * pieces. Left alone, kqs_surface_update() clips to the old width and
+         * the right-hand columns are simply dropped - which is GRUB rendering
+         * with its right edge missing.
+         *
+         * Grow to fit instead. The size damage implies is a lower bound on the
+         * real mode, so this converges as further damage arrives.
+         */
+        if (x >= 0 && y >= 0 && w > 0 && h > 0 &&
+            (x + w > l->surface.width || y + h > l->surface.height)) {
+            const uint32_t nw = MAX(x + w, l->surface.width);
+            const uint32_t nh = MAX(y + h, l->surface.height);
+
+            g_message("update %dx%d+%d+%d exceeds %dx%d surface; growing to %ux%u",
+                      w, h, x, y, l->surface.width, l->surface.height, nw, nh);
+
+            if (!kqs_surface_resize(&l->surface, nw, nh, &err)) {
+                g_warning("could not grow surface: %s", err->message);
+                g_clear_error(&err);
+            }
+        }
+
+        if (!kqs_surface_update(&l->surface, x, y, w, h, stride, fmt, bytes,
+                                len, &err)) {
+            g_debug("Update rejected: %s", err->message);
+            g_clear_error(&err);
+        } else {
+            l->updates++;
+        }
+
+        g_dbus_method_invocation_return_value(inv, NULL);
+        return;
+    }
+
+    if (g_str_equal(method, "Disable")) {
+        g_message("console disabled by QEMU");
+        kqs_surface_clear(&l->surface);
+        g_dbus_method_invocation_return_value(inv, NULL);
+        return;
+    }
+
+    /* Cursor and pointer state belong to kynput in phase 3; accept and
+     * discard so QEMU does not treat us as broken. */
+    if (g_str_equal(method, "MouseSet") ||
+        g_str_equal(method, "CursorDefine")) {
+        g_dbus_method_invocation_return_value(inv, NULL);
+        return;
+    }
+
+    if (g_str_equal(method, "ScanoutDMABUF2")) {
+        handle_scanout_dmabuf2(l, params, inv);
+        return;
+    }
+
+    /*
+     * Damage on a dmabuf carries no payload: the pixels are already visible
+     * to us through the fds we hold. It is purely a "something changed" tick.
+     */
+    if (g_str_equal(method, "UpdateDMABUF")) {
+        if (l->dmabuf.valid) {
+            l->dmabuf.dirty = true;
+            l->updates++;
+        }
+        g_dbus_method_invocation_return_value(inv, NULL);
+        return;
+    }
+
+    g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
+                                          G_DBUS_ERROR_UNKNOWN_METHOD,
+                                          "unhandled method %s", method);
+}
+
+static GVariant *handle_get_property(GDBusConnection *conn, const char *sender,
+                                     const char *path, const char *iface,
+                                     const char *prop, GError **error,
+                                     gpointer user_data)
+{
+    if (g_str_equal(prop, "Interfaces")) {
+        KqsListener *l = user_data;
+        const char *ifaces[] = {
+            "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2", NULL
+        };
+        return l->want_dmabuf ? g_variant_new_strv(ifaces, 1)
+                              : g_variant_new_strv(NULL, 0);
+    }
+
+    g_set_error(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_PROPERTY,
+                "unknown property %s", prop);
+    return NULL;
+}
+
+static const GDBusInterfaceVTable kqs_vtable = {
+    .method_call  = handle_call,
+    .get_property = handle_get_property,
+    .set_property = NULL,
+};
+
+/* ----------------------------------------------------------------- pacing */
+
+/*
+ * QEMU emits damage as it happens; a video encoder wants a cadence, so emit on
+ * a timer.
+ *
+ * Skipping ticks where nothing changed looks like free money and is not. A
+ * client can join or rejoin at any moment - the controller restarts a stream on
+ * a resolution change, and the user can ask for one - and it has nothing to
+ * decode until a keyframe arrives. Keyframes come out of the encoder's keyint
+ * schedule, which only advances when we feed it. So on a guest that is merely
+ * sitting there, an encoder gated on damage emits nothing at all and the new
+ * client stays black indefinitely, however healthy the rest of the pipeline is.
+ * The controller does ask for an IDR at exactly these moments, but we have no
+ * runtime control channel to the encoder to honour it.
+ *
+ * Feeding unchanged frames is cheap where it counts: static content is all
+ * skipped macroblocks, tens of bytes per frame, so it costs bitrate close to
+ * nothing. It does cost CPU for the pixel conversion, which is what --fps is
+ * for.
+ */
+static gboolean on_tick(gpointer user_data)
+{
+    KqsListener *l = user_data;
+    GError *err = NULL;
+    const int64_t pts = g_get_monotonic_time() - l->epoch_us;
+    bool ok;
+
+    if (l->dmabuf.valid) {
+        ok = kqs_sink_push_dmabuf(l->sink, &l->dmabuf, pts, &err);
+        l->dmabuf.dirty = false;
+    } else {
+        if (!l->surface.data)
+            return G_SOURCE_CONTINUE;
+        ok = kqs_sink_push(l->sink, &l->surface, pts, &err);
+        l->surface.dirty = false;
+    }
+
+    if (!ok) {
+        g_warning("sink: %s", err->message);
+        g_clear_error(&err);
+        g_main_loop_quit(l->loop);
+        l->tick_id = 0;      /* GLib drops it for us; do not remove twice */
+        return G_SOURCE_REMOVE;
+    }
+
+    return G_SOURCE_CONTINUE;
+}
+
+static void on_peer_closed(GDBusConnection *conn, gboolean remote,
+                           GError *error, gpointer user_data)
+{
+    KqsListener *l = user_data;
+
+    g_message("QEMU closed the listener connection%s%s",
+              error ? ": " : "", error ? error->message : "");
+    g_main_loop_quit(l->loop);
+}
+
+/* ------------------------------------------------------------------ setup */
+
+KqsListener *kqs_listener_new(GDBusConnection *bus, const char *bus_name,
+                              int console, KqsSink *sink, int fps,
+                              bool want_dmabuf, GMainLoop *loop,
+                              GError **error)
+{
+    int sv[2];
+    if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) {
+        g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno),
+                    "socketpair: %s", g_strerror(errno));
+        return NULL;
+    }
+
+    /* The fd list takes ownership of sv[1]. */
+    g_autoptr(GUnixFDList) fds = g_unix_fd_list_new_from_array(&sv[1], 1);
+    g_autofree char *path =
+        g_strdup_printf("/org/qemu/Display1/Console_%d", console);
+
+    g_autoptr(GVariant) reply = g_dbus_connection_call_with_unix_fd_list_sync(
+        bus, bus_name, path, "org.qemu.Display1.Console", "RegisterListener",
+        g_variant_new("(h)", 0), NULL, G_DBUS_CALL_FLAGS_NONE, -1,
+        fds, NULL, NULL, error);
+
+    if (!reply) {
+        close(sv[0]);
+        g_prefix_error(error, "RegisterListener on %s failed: ", path);
+        return NULL;
+    }
+
+    KqsListener *l = g_new0(KqsListener, 1);
+    l->sink = sink;
+    l->loop = loop;
+    l->want_dmabuf = want_dmabuf;
+    l->epoch_us = g_get_monotonic_time();
+    kqs_surface_init(&l->surface);
+    kqs_dmabuf_init(&l->dmabuf);
+
+    g_autoptr(GSocket) sock = g_socket_new_from_fd(sv[0], error);
+    if (!sock) {
+        close(sv[0]);
+        g_free(l);
+        return NULL;
+    }
+    g_autoptr(GSocketConnection) stream =
+        g_socket_connection_factory_create_connection(sock);
+
+    /*
+     * QEMU is the authentication server and generated the GUID, so pass NULL
+     * and connect as client. Delay message processing until the object is
+     * exported, or QEMU's first Scanout can arrive before we can answer it.
+     */
+    l->peer = g_dbus_connection_new_sync(
+        G_IO_STREAM(stream), NULL,
+        G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
+        G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING,
+        NULL, NULL, error);
+
+    if (!l->peer) {
+        g_free(l);
+        return NULL;
+    }
+
+    g_dbus_connection_set_exit_on_close(l->peer, FALSE);
+
+    l->node = g_dbus_node_info_new_for_xml(kqs_listener_xml, error);
+    if (!l->node) {
+        kqs_listener_free(l);
+        return NULL;
+    }
+
+    l->reg_id = g_dbus_connection_register_object(
+        l->peer, KQS_LISTENER_PATH, l->node->interfaces[0], &kqs_vtable,
+        l, NULL, error);
+
+    if (l->reg_id == 0) {
+        kqs_listener_free(l);
+        return NULL;
+    }
+
+    if (want_dmabuf) {
+        GDBusInterfaceInfo *dmabuf_iface =
+            g_dbus_node_info_lookup_interface(
+                l->node, "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2");
+
+        l->dmabuf_reg_id = g_dbus_connection_register_object(
+            l->peer, KQS_LISTENER_PATH, dmabuf_iface, &kqs_vtable, l, NULL,
+            error);
+
+        if (l->dmabuf_reg_id == 0) {
+            kqs_listener_free(l);
+            return NULL;
+        }
+    }
+
+    g_signal_connect(l->peer, "closed", G_CALLBACK(on_peer_closed), l);
+    g_dbus_connection_start_message_processing(l->peer);
+
+    l->tick_id = g_timeout_add(MAX(1, 1000 / fps), on_tick, l);
+
+    g_message("registered as display listener on %s (fps cap %d, %s path)",
+              path, fps, want_dmabuf ? "dmabuf" : "cpu");
+    return l;
+}
+
+void kqs_listener_free(KqsListener *l)
+{
+    if (!l)
+        return;
+
+    if (l->tick_id)
+        g_source_remove(l->tick_id);
+    if (l->dmabuf_reg_id && l->peer)
+        g_dbus_connection_unregister_object(l->peer, l->dmabuf_reg_id);
+    if (l->reg_id && l->peer)
+        g_dbus_connection_unregister_object(l->peer, l->reg_id);
+    if (l->node)
+        g_dbus_node_info_unref(l->node);
+    if (l->peer)
+        g_object_unref(l->peer);
+
+    g_message("listener: %" G_GUINT64_FORMAT " scanouts, %" G_GUINT64_FORMAT
+              " dmabuf scanouts, %" G_GUINT64_FORMAT " updates",
+              l->scanouts, l->dmabuf_scanouts, l->updates);
+
+    kqs_surface_clear(&l->surface);
+    kqs_dmabuf_clear(&l->dmabuf);
+    g_free(l);
+}
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..1dea654
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,193 @@
+/*
+ * kyber-qemu-server - entry point.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <signal.h>
+#include <stdlib.h>
+
+#include <glib-unix.h>
+
+#include "kqs.h"
+
+static char *opt_bus_name  = NULL;
+static char *opt_address   = NULL;
+static char *opt_out       = NULL;
+static char *opt_format    = NULL;
+static char *opt_encoder   = NULL;
+static int   opt_console   = 0;
+static int   opt_fps       = 60;
+static int   opt_bitrate   = 8000;
+/*
+ * Default to a normal desktop mode rather than to the guest's first frame:
+ * see KqsSinkConfig.out_w. --width 0 restores follow-the-guest.
+ */
+static int   opt_width     = 1280;
+static int   opt_height    = 800;
+static gboolean opt_dmabuf = FALSE;
+static char *opt_render_node = NULL;
+static char *opt_kymux     = NULL;
+static char *opt_kymux_audio = NULL;
+static int   opt_audio_bitrate = 128000;
+
+static const GOptionEntry entries[] = {
+    { "bus-name", 'n', 0, G_OPTION_ARG_STRING, &opt_bus_name,
+      "QEMU bus name (default: org.qemu)", "NAME" },
+    { "address", 'a', 0, G_OPTION_ARG_STRING, &opt_address,
+      "connect to this D-Bus address instead of the session bus", "ADDR" },
+    { "console", 'c', 0, G_OPTION_ARG_INT, &opt_console,
+      "console index (default: 0)", "N" },
+    { "out", 'o', 0, G_OPTION_ARG_STRING, &opt_out,
+      "output URL (default: kqs.mkv)", "URL" },
+    { "format", 'f', 0, G_OPTION_ARG_STRING, &opt_format,
+      "muxer format; guessed from --out if omitted", "FMT" },
+    { "encoder", 'e', 0, G_OPTION_ARG_STRING, &opt_encoder,
+      "encoder name (default: libx264)", "ENC" },
+    { "fps", 0, 0, G_OPTION_ARG_INT, &opt_fps,
+      "maximum frames emitted per second (default: 60)", "N" },
+    { "bitrate", 'b', 0, G_OPTION_ARG_INT, &opt_bitrate,
+      "target bitrate in kbps (default: 8000)", "KBPS" },
+    { "kymux", 'k', 0, G_OPTION_ARG_STRING, &opt_kymux,
+      "stream to kymux instead of a file, e.g. "
+      "kymux://127.0.0.1:9000/1 (endpoint is hex)", "URI" },
+    { "kymux-audio", 'A', 0, G_OPTION_ARG_STRING, &opt_kymux_audio,
+      "stream guest audio to this kymux endpoint instead of the display. "
+      "Needs QEMU started with -audiodev dbus", "URI" },
+    { "audio-bitrate", 0, 0, G_OPTION_ARG_INT, &opt_audio_bitrate,
+      "Opus bitrate in bits per second", "BPS" },
+    { "dmabuf", 'D', 0, G_OPTION_ARG_NONE, &opt_dmabuf,
+      "request DMA-BUF scanouts and encode on the GPU (needs QEMU "
+      "-display dbus,gl=on with a GL device)", NULL },
+    { "width", 0, 0, G_OPTION_ARG_INT, &opt_width,
+      "encoder width, 0 to follow the guest (default: 1280)", "PX" },
+    { "height", 0, 0, G_OPTION_ARG_INT, &opt_height,
+      "encoder height, 0 to follow the guest (default: 800)", "PX" },
+    { "render-node", 0, 0, G_OPTION_ARG_STRING, &opt_render_node,
+      "DRM render node for --dmabuf (default: /dev/dri/renderD128)", "PATH" },
+    { NULL }
+};
+
+static gboolean on_signal(gpointer loop)
+{
+    g_message("shutting down");
+    g_main_loop_quit(loop);
+    return G_SOURCE_REMOVE;
+}
+
+int main(int argc, char **argv)
+{
+    g_autoptr(GError) error = NULL;
+    g_autoptr(GOptionContext) octx =
+        g_option_context_new("- stream a QEMU guest display via txproto");
+
+    g_option_context_add_main_entries(octx, entries, NULL);
+    if (!g_option_context_parse(octx, &argc, &argv, &error)) {
+        g_printerr("%s\n", error->message);
+        return 2;
+    }
+
+    if (!opt_bus_name) opt_bus_name = g_strdup("org.qemu");
+    if (!opt_out)      opt_out      = g_strdup("kqs.mkv");
+    if (!opt_render_node) opt_render_node = g_strdup("/dev/dri/renderD128");
+    if (!opt_encoder)
+        opt_encoder = g_strdup(opt_dmabuf ? "h264_vaapi" : "libx264");
+
+    if (opt_fps < 1 || opt_fps > 240) {
+        g_printerr("--fps must be between 1 and 240\n");
+        return 2;
+    }
+
+    g_autoptr(GDBusConnection) bus = NULL;
+    if (opt_address) {
+        bus = g_dbus_connection_new_for_address_sync(
+            opt_address,
+            G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
+            G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION,
+            NULL, NULL, &error);
+    } else {
+        bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, &error);
+    }
+
+    if (!bus) {
+        g_printerr("cannot reach QEMU's bus: %s\n", error->message);
+        g_printerr("hint: start QEMU with -display dbus\n");
+        return 1;
+    }
+
+    if (opt_kymux_audio) {
+        /*
+         * Audio only. A process serves one or the other: txproto's context is
+         * process-global (see sink.c), and Kyber gives audio its own kymux
+         * endpoint anyway, so there is nothing to share.
+         */
+        g_autoptr(GMainLoop) aloop = g_main_loop_new(NULL, FALSE);
+        KqsAudio *audio = kqs_audio_new(bus, opt_bus_name, opt_kymux_audio,
+                                        opt_audio_bitrate, &error);
+        if (!audio) {
+            g_printerr("%s\n", error->message);
+            g_printerr("hint: start QEMU with -audiodev dbus and an audio device\n");
+            return 1;
+        }
+
+        g_unix_signal_add(SIGINT,  on_signal, aloop);
+        g_unix_signal_add(SIGTERM, on_signal, aloop);
+        g_main_loop_run(aloop);
+
+        uint64_t frames = 0, dropped = 0;
+        kqs_audio_stats(audio, &frames, &dropped);
+        g_message("audio: %" G_GUINT64_FORMAT " frames encoded, %"
+                  G_GUINT64_FORMAT " dropped", frames, dropped);
+        kqs_audio_free(audio);
+        return 0;
+    }
+
+    KqsHwCtx *hw = NULL;
+    if (opt_dmabuf) {
+        hw = kqs_hw_new(opt_render_node, &error);
+        if (!hw) {
+            g_printerr("%s\n", error->message);
+            return 1;
+        }
+        g_message("zero-copy path via %s", opt_render_node);
+    }
+
+    KqsSinkConfig scfg = {
+        .encoder      = opt_encoder,
+        .out_url      = opt_out,
+        .out_format   = opt_format,
+        .kymux_uri    = opt_kymux,
+        .bitrate_kbps = opt_bitrate,
+        .fps          = opt_fps,
+        .out_w        = opt_width,
+        .out_h        = opt_height,
+        .hw           = hw,
+    };
+
+    KqsSink *sink = kqs_sink_new(&scfg);
+    g_autoptr(GMainLoop) loop = g_main_loop_new(NULL, FALSE);
+
+    KqsListener *listener = kqs_listener_new(bus, opt_bus_name, opt_console,
+                                             sink, opt_fps, opt_dmabuf, loop,
+                                             &error);
+    if (!listener) {
+        g_printerr("%s\n", error->message);
+        kqs_sink_free(sink);
+        return 1;
+    }
+
+    g_unix_signal_add(SIGINT,  on_signal, loop);
+    g_unix_signal_add(SIGTERM, on_signal, loop);
+
+    g_main_loop_run(loop);
+
+    uint64_t pushed = 0, dropped = 0;
+    kqs_sink_stats(sink, &pushed, &dropped);
+    g_message("frames: %" G_GUINT64_FORMAT " encoded, %" G_GUINT64_FORMAT
+              " dropped", pushed, dropped);
+
+    kqs_listener_free(listener);
+    kqs_sink_free(sink);
+    kqs_hw_free(hw);
+    return 0;
+}
diff --git a/src/sink.c b/src/sink.c
new file mode 100644
index 0000000..12d0244
--- /dev/null
+++ b/src/sink.c
@@ -0,0 +1,483 @@
+/*
+ * txproto sink: pushes guest frames into an encoder's src_frames FIFO.
+ *
+ * Two producers feed the same submit path:
+ *   - the software path converts a KqsSurface with sws_scale
+ *   - the zero-copy path maps a QEMU dmabuf straight into a VAAPI surface
+ *
+ * The ordering is not arbitrary, and getting it wrong hangs the process.
+ * tx_commit() initialises the muxer, the muxer needs stream parameters from
+ * the encoder, and the encoder only derives those in context_full_config(),
+ * which peeks the first frame off src_frames and blocks until one exists.
+ * So: create, link, seed one frame, THEN commit.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <libtxproto/txproto.h>
+#include <libtxproto/encode.h>
+#include <libtxproto/fifo_frame.h>
+
+#include <libavutil/opt.h>
+#include <libswscale/swscale.h>
+
+#include "kqs.h"
+
+/* Encoder input format for the software path. */
+#define KQS_ENC_PIX_FMT AV_PIX_FMT_YUV420P
+
+struct KqsSink {
+    KqsSinkConfig cfg;
+    char *encoder_name;
+    char *out_url;
+    char *out_format;
+    char *kymux_uri;
+    KqsHwCtx *hw;              /* borrowed; non-NULL means zero-copy */
+
+    TXMainContext *tx;
+    AVBufferRef *encoder;
+    AVBufferRef *muxer;        /* file muxer or kymux packet sink */
+    AVBufferRef *fifo;         /* borrowed: encoder's src_frames */
+
+    struct SwsContext *sws;
+    int sws_w, sws_h;
+    int sws_out_w, sws_out_h;
+    enum AVPixelFormat sws_in;
+
+    bool committed;
+    uint64_t built_generation;
+    int built_w, built_h;
+    int64_t last_pts;          /* enforces strictly increasing timestamps */
+
+    uint64_t pushed;
+    uint64_t dropped;
+};
+
+KqsSink *kqs_sink_new(const KqsSinkConfig *cfg)
+{
+    KqsSink *s = g_new0(KqsSink, 1);
+
+    s->cfg          = *cfg;
+    s->encoder_name = g_strdup(cfg->encoder);
+    s->out_url      = g_strdup(cfg->out_url);
+    s->out_format   = g_strdup(cfg->out_format);
+    s->kymux_uri    = g_strdup(cfg->kymux_uri);
+    s->hw           = cfg->hw;
+    s->sws_in       = AV_PIX_FMT_NONE;
+    s->last_pts     = INT64_MIN;
+
+    return s;
+}
+
+/*
+ * Drop the pipeline. `final` frees the txproto context itself; otherwise it is
+ * kept so a rebuild can reuse it.
+ *
+ * The context has to survive a rebuild. txproto's logging is process-global
+ * and sp_log_init() opens by tearing it down, so a second tx_init() is not a
+ * supported path - patches/0002 stops it segfaulting outright, but the
+ * pipeline it produced then ran without ever delivering a frame. Destroying
+ * just the components and rebuilding them in place keeps tx_init() to exactly
+ * one call per process, which is the only pattern txproto is exercised on.
+ */
+static void sink_teardown_full(KqsSink *s, bool final)
+{
+    if (s->tx) {
+        if (s->muxer)
+            tx_destroy(s->tx, &s->muxer);
+        if (s->encoder)
+            tx_destroy(s->tx, &s->encoder);
+
+        if (final) {
+            tx_free(s->tx);
+            s->tx = NULL;
+        }
+    }
+
+    s->encoder = s->muxer = s->fifo = NULL;
+    s->committed = false;
+    s->last_pts  = INT64_MIN;
+}
+
+static void sink_teardown(KqsSink *s)
+{
+    sink_teardown_full(s, false);
+}
+
+/*
+ * Timer ticks can coalesce, so two frames occasionally carry the same
+ * microsecond. Muxers reject non-monotonic DTS, so nudge instead.
+ */
+static int64_t next_pts(KqsSink *s, int64_t pts_us)
+{
+    if (pts_us <= s->last_pts)
+        pts_us = s->last_pts + 1;
+    s->last_pts = pts_us;
+    return pts_us;
+}
+
+/*
+ * txproto reads pacing metadata from opaque_ref, not from an AVCodecContext -
+ * there isn't one when the first frame lands.
+ */
+static bool attach_timing(KqsSink *s, AVFrame *f, int64_t pts_us,
+                          GError **error)
+{
+    FormatExtraData *fe = av_mallocz(sizeof(*fe));
+    if (!fe) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "extradata alloc");
+        return false;
+    }
+    fe->time_base       = (AVRational){ 1, 1000000 };
+    fe->avg_frame_rate  = (AVRational){ s->cfg.fps, 1 };
+    fe->bits_per_sample = 8;
+
+    f->opaque_ref = av_buffer_create((uint8_t *)fe, sizeof(*fe), NULL, NULL, 0);
+    if (!f->opaque_ref) {
+        av_free(fe);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "opaque_ref");
+        return false;
+    }
+
+    f->pts = next_pts(s, pts_us);
+    return true;
+}
+
+/*
+ * Software path. txproto does no pixel conversion - encode.c asserts
+ * in_f->format == avctx->pix_fmt - so the producer converts.
+ */
+static AVFrame *surface_to_frame(KqsSink *s, const KqsSurface *surf,
+                                 int64_t pts_us, GError **error)
+{
+    /*
+     * Once committed, keep emitting the geometry the encoder was built with
+     * and rescale into it. A mid-session teardown is not an option on the
+     * kymux path: the endpoint and codec configuration are negotiated once
+     * per session, so rebuilding drops the stream the client is decoding.
+     */
+    const int out_w = s->committed ? s->built_w
+                    : s->cfg.out_w ? s->cfg.out_w : surf->width;
+    const int out_h = s->committed ? s->built_h
+                    : s->cfg.out_h ? s->cfg.out_h : surf->height;
+
+    if (!s->sws || s->sws_w != surf->width || s->sws_h != surf->height ||
+        s->sws_in != surf->pix_fmt || s->sws_out_w != out_w ||
+        s->sws_out_h != out_h) {
+        sws_freeContext(s->sws);
+        s->sws = sws_getContext(surf->width, surf->height, surf->pix_fmt,
+                                out_w, out_h, KQS_ENC_PIX_FMT,
+                                SWS_BILINEAR, NULL, NULL, NULL);
+        if (!s->sws) {
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                        "sws_getContext failed");
+            return NULL;
+        }
+        s->sws_w  = surf->width;
+        s->sws_h  = surf->height;
+        s->sws_in = surf->pix_fmt;
+        s->sws_out_w = out_w;
+        s->sws_out_h = out_h;
+    }
+
+    AVFrame *f = av_frame_alloc();
+    if (!f) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "av_frame_alloc");
+        return NULL;
+    }
+
+    f->width  = out_w;
+    f->height = out_h;
+    f->format = KQS_ENC_PIX_FMT;
+
+    if (av_frame_get_buffer(f, 0) < 0) {
+        av_frame_free(&f);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame buffer alloc");
+        return NULL;
+    }
+
+    const uint8_t *src[4] = { surf->data, NULL, NULL, NULL };
+    const int src_stride[4] = { surf->stride, 0, 0, 0 };
+    sws_scale(s->sws, src, src_stride, 0, surf->height, f->data, f->linesize);
+
+    if (!attach_timing(s, f, pts_us, error)) {
+        av_frame_free(&f);
+        return NULL;
+    }
+    return f;
+}
+
+static bool sink_build(KqsSink *s, AVFrame *seed, uint64_t generation,
+                       GError **error)
+{
+    /* One context per process: see sink_teardown_full(). */
+    if (!s->tx) {
+        s->tx = tx_new();
+        if (!s->tx || tx_init(s->tx) < 0) {
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_init failed");
+            return false;
+        }
+    }
+
+    AVDictionary *copts = NULL;
+    av_dict_set_int(&copts, "b", (int64_t)s->cfg.bitrate_kbps * 1000, 0);
+    if (!s->hw) {
+        /* x264 knobs; VAAPI rejects them. */
+        av_dict_set(&copts, "preset", "ultrafast", 0);
+        av_dict_set(&copts, "tune", "zerolatency", 0);
+    }
+
+    /*
+     * A client joining mid-stream cannot decode until it sees an IDR, and
+     * x264's default GOP of 250 frames can be many seconds away - which shows
+     * up as a black window that never resolves. Until force-IDR is plumbed
+     * through from the controller, emit one per second so a late joiner syncs
+     * promptly. Cheap here: an idle desktop codes an IDR in a few hundred
+     * bytes.
+     */
+    av_dict_set_int(&copts, "g", s->cfg.fps, 0);
+
+    TxEncoderOptions eopts = {
+        .enc_name = s->encoder_name,
+        .name     = "guest",
+        .options  = copts,
+        /*
+         * Leave pix_fmt unset on the hardware path. txproto only
+         * auto-detects the surface's real sw_format when ctx->pix_fmt is
+         * NONE (encode.c:101); setting it to AV_PIX_FMT_VAAPI overrides that
+         * and ends up as hwfc->sw_format = vaapi, which cannot init.
+         */
+        .pix_fmt  = s->hw ? AV_PIX_FMT_NONE : KQS_ENC_PIX_FMT,
+    };
+
+    s->encoder = tx_encoder_create(s->tx, &eopts);
+    if (!s->encoder) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "encoder '%s' unavailable", s->encoder_name);
+        return false;
+    }
+
+    /*
+     * kymux carries codec configuration out-of-band, so it needs SPS/PPS in
+     * extradata rather than inline: sp_packet_sink_set_encoding_ctx() refuses
+     * an encoder without AV_CODEC_FLAG_GLOBAL_HEADER ("Packet sink requires
+     * global header"). The flag is applied when the encoder configures itself
+     * from the first frame, so set it now, before anything is pushed.
+     */
+    if (s->kymux_uri)
+        ((EncodingContext *)s->encoder->data)->need_global_header = 1;
+
+    /*
+     * Two possible sinks. The packet sink is what Kyber's own AV server uses
+     * (kyavservice/src/kymux.rs): a TCP connection to kymux carrying framed
+     * packets, which kymux then muxes onto its QUIC session to the client.
+     * The file muxer is for offline inspection.
+     */
+    if (s->kymux_uri) {
+        s->muxer = tx_packetsink_create(s->tx, s->kymux_uri);
+        if (!s->muxer) {
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                        "cannot connect packet sink to '%s' "
+                        "(is kymux listening?)", s->kymux_uri);
+            return false;
+        }
+    } else {
+        s->muxer = tx_muxer_create(s->tx, s->out_url, s->out_format, NULL, NULL);
+        if (!s->muxer) {
+            g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                        "cannot open output '%s'", s->out_url);
+            return false;
+        }
+    }
+
+    if (tx_link(s->tx, s->encoder, s->muxer,
+                &(TXLinkOptions){ .autostart = 1 }) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_link failed");
+        return false;
+    }
+
+    /*
+     * The hardware path used to stop here: a virtio-gpu scanout is packed RGB,
+     * h264_vaapi has no RGB input profile, and it answered "Input surface
+     * format is rgba" then "No usable encoding profile found".
+     *
+     * A txproto filtergraph running scale_vaapi between the import and the
+     * encoder was the obvious shape and deadlocks - seeding the filter's input
+     * does not give the encoder a frame to configure from, so tx_commit()
+     * blocks exactly as it does on an empty encoder FIFO, one stage deeper.
+     * The conversion happens in kqs_dmabuf_to_vaapi() instead, which hands us
+     * NV12 and leaves this a plain encoder that can still be seeded directly.
+     */
+
+    s->fifo = ((EncodingContext *)s->encoder->data)->src_frames;
+
+    /* Seed before commit, or commit deadlocks. See file header. */
+    int err = sp_frame_fifo_push(s->fifo, seed);
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "seed push failed (%d)", err);
+        return false;
+    }
+
+    if (tx_commit(s->tx) < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_commit failed");
+        return false;
+    }
+
+    s->committed        = true;
+    s->built_generation = generation;
+    s->built_w          = seed->width;
+    s->built_h          = seed->height;
+    s->pushed++;
+
+    g_message("sink: %dx%d %s -> %s (%s, %d kbps)",
+              seed->width, seed->height,
+              av_get_pix_fmt_name(seed->format),
+              s->kymux_uri ? s->kymux_uri : s->out_url,
+              s->encoder_name, s->cfg.bitrate_kbps);
+
+    return true;
+}
+
+/*
+ * Act on a guest resolution change, before any frame is converted.
+ *
+ * Returns false if this frame should be skipped entirely.
+ */
+static bool sink_check_geometry(KqsSink *s, uint64_t generation)
+{
+    if (!s->committed || generation == s->built_generation)
+        return true;
+
+    /*
+     * Rebuilding was impossible until txproto's sp_log_uninit() stopped
+     * leaving a stale component count behind (see patches/0002): the second
+     * tx_init() in a process segfaulted. Opt-in until a real client is shown
+     * to follow the new geometry - Kyber's README suggests restarting the
+     * video server on topology changes rather than reconfiguring live.
+     */
+    if (!s->kymux_uri || g_strcmp0(g_getenv("KQS_REBUILD"), "1") == 0) {
+        g_message("sink: geometry changed, rebuilding pipeline");
+        sink_teardown(s);
+        return true;
+    }
+
+    /*
+     * Rescale into the established size instead. The endpoint and codec
+     * configuration are negotiated once per session, so a mid-session
+     * teardown drops the stream the client is decoding.
+     */
+    g_message("sink: guest resized, rescaling to %dx%d", s->built_w, s->built_h);
+    s->built_generation = generation;
+    return true;
+}
+
+/*
+ * Common tail for both producers. Takes ownership of `f`.
+ *
+ * A guest resolution change is a teardown, not a reconfigure: the encoder
+ * derived geometry from its first frame and has no path to revise it.
+ */
+static bool sink_submit(KqsSink *s, AVFrame *f, uint64_t generation,
+                        GError **error)
+{
+    bool ok = true;
+
+    if (!s->committed) {
+        ok = sink_build(s, f, generation, error);
+        av_frame_free(&f);
+        return ok;
+    }
+
+    int err = sp_frame_fifo_push(s->fifo, f);
+    av_frame_free(&f);
+
+    if (err == AVERROR(ENOBUFS)) {
+        /* Encoder is behind. Dropping is correct: never stall QEMU. */
+        s->dropped++;
+        return true;
+    }
+    if (err < 0) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "frame push failed (%d)", err);
+        return false;
+    }
+
+    s->pushed++;
+    return ok;
+}
+
+bool kqs_sink_push(KqsSink *s, const KqsSurface *surf, int64_t pts_us,
+                   GError **error)
+{
+    if (!surf->data)
+        return true;
+
+    /*
+     * Decide about the geometry change before converting, not after. The
+     * conversion scales into the size the encoder was built at, so a frame
+     * made first would seed the rebuilt pipeline with the old geometry - the
+     * pipeline rebuilds, and comes back at exactly the size it was leaving.
+     */
+    if (!sink_check_geometry(s, surf->generation))
+        return true;
+
+    AVFrame *f = surface_to_frame(s, surf, pts_us, error);
+    if (!f)
+        return false;
+
+    return sink_submit(s, f, surf->generation, error);
+}
+
+bool kqs_sink_push_dmabuf(KqsSink *s, const KqsDmabuf *d, int64_t pts_us,
+                          GError **error)
+{
+    if (!d->valid)
+        return true;
+
+    if (!s->hw) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "dmabuf frame with no hardware context");
+        return false;
+    }
+
+    /* Same rule as the CPU path: once committed, the encoder's geometry wins. */
+    const int out_w = s->committed ? s->built_w
+                    : s->cfg.out_w ? s->cfg.out_w : d->backing_width;
+    const int out_h = s->committed ? s->built_h
+                    : s->cfg.out_h ? s->cfg.out_h : d->backing_height;
+
+    AVFrame *f = kqs_dmabuf_to_vaapi(s->hw, d, out_w, out_h, error);
+    if (!f)
+        return false;
+
+    if (!attach_timing(s, f, pts_us, error)) {
+        av_frame_free(&f);
+        return false;
+    }
+
+    return sink_submit(s, f, d->generation, error);
+}
+
+void kqs_sink_stats(const KqsSink *s, uint64_t *pushed, uint64_t *dropped)
+{
+    if (pushed)  *pushed  = s->pushed;
+    if (dropped) *dropped = s->dropped;
+}
+
+void kqs_sink_free(KqsSink *s)
+{
+    if (!s)
+        return;
+
+    if (s->fifo)
+        sp_frame_fifo_push(s->fifo, NULL);   /* flush sentinel */
+
+    sink_teardown_full(s, true);
+    sws_freeContext(s->sws);
+    g_free(s->encoder_name);
+    g_free(s->out_url);
+    g_free(s->out_format);
+    g_free(s->kymux_uri);
+    g_free(s);
+}
diff --git a/src/surface.c b/src/surface.c
new file mode 100644
index 0000000..101124c
--- /dev/null
+++ b/src/surface.c
@@ -0,0 +1,188 @@
+/*
+ * Guest display surface: applies QEMU Scanout/Update into a persistent image.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <string.h>
+
+#include "kqs.h"
+
+enum AVPixelFormat kqs_pixman_to_av(uint32_t f)
+{
+    switch (f) {
+    /* 32-bit word ARGB -> memory B,G,R,A on little-endian */
+    case KQS_PIXMAN_a8r8g8b8: return AV_PIX_FMT_BGRA;
+    case KQS_PIXMAN_x8r8g8b8: return AV_PIX_FMT_BGR0;
+    case KQS_PIXMAN_a8b8g8r8: return AV_PIX_FMT_RGBA;
+    case KQS_PIXMAN_x8b8g8r8: return AV_PIX_FMT_RGB0;
+    default:                  return AV_PIX_FMT_NONE;
+    }
+}
+
+void kqs_surface_init(KqsSurface *s)
+{
+    memset(s, 0, sizeof(*s));
+    s->pix_fmt = AV_PIX_FMT_NONE;
+}
+
+void kqs_surface_clear(KqsSurface *s)
+{
+    g_free(s->data);
+    kqs_surface_init(s);
+}
+
+static bool surface_realloc(KqsSurface *s, uint32_t width, uint32_t height,
+                            enum AVPixelFormat pf, GError **error)
+{
+    if (pf == AV_PIX_FMT_NONE) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+                    "unsupported pixel format");
+        return false;
+    }
+    if (width == 0 || height == 0 || width > 16384 || height > 16384) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
+                    "implausible geometry %ux%u", width, height);
+        return false;
+    }
+
+    if (s->data && s->width == (int)width && s->height == (int)height &&
+        s->pix_fmt == pf)
+        return true;                     /* geometry unchanged */
+
+    /*
+     * Carry the old picture into the new buffer, scaled, instead of starting
+     * black.
+     *
+     * QEMU repaints by damage, and after a mode change it only sends the parts
+     * that change - it will not resend what it already drew. A blank buffer
+     * therefore stays blank wherever nothing happens to be repainted, which is
+     * what a black screen with a live mouse cursor actually is: the cursor's
+     * damage is the only thing arriving.
+     *
+     * Nearest-neighbour is deliberate. This content is wrong by definition -
+     * it is the previous mode's picture - and it only has to hold for the
+     * fraction of a second before real damage covers it. Quality is beside the
+     * point; not being black is the point.
+     */
+    uint8_t *old      = s->data;
+    const int old_w   = s->width;
+    const int old_h   = s->height;
+    const int old_str = s->stride;
+    const enum AVPixelFormat old_pf = s->pix_fmt;
+
+    s->width   = (int)width;
+    s->height  = (int)height;
+    s->bpp     = 4;                      /* every format above is 32bpp */
+    s->stride  = s->width * s->bpp;
+    s->pix_fmt = pf;
+    s->data    = g_malloc0((gsize)s->stride * s->height);
+    s->generation++;                     /* tells the sink to rebuild */
+
+    if (old && old_w > 0 && old_h > 0 && old_pf == pf) {
+        for (int y = 0; y < s->height; y++) {
+            const int sy = (int)((int64_t)y * old_h / s->height);
+            const uint8_t *src = old + (gsize)sy * old_str;
+            uint32_t *dst = (uint32_t *)(s->data + (gsize)y * s->stride);
+
+            for (int x = 0; x < s->width; x++) {
+                const int sx = (int)((int64_t)x * old_w / s->width);
+                dst[x] = ((const uint32_t *)src)[sx];
+            }
+        }
+    }
+
+    g_free(old);
+    return true;
+}
+
+bool kqs_surface_scanout(KqsSurface *s, uint32_t width, uint32_t height,
+                         uint32_t stride, uint32_t pixman_format,
+                         const uint8_t *data, gsize len, GError **error)
+{
+    enum AVPixelFormat pf = kqs_pixman_to_av(pixman_format);
+    if (pf == AV_PIX_FMT_NONE) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+                    "unsupported pixman format 0x%08x", pixman_format);
+        return false;
+    }
+
+    if (!surface_realloc(s, width, height, pf, error))
+        return false;
+
+    const gsize need = (gsize)stride * height;
+    if (len < need) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+                    "Scanout short by %" G_GSIZE_FORMAT " bytes "
+                    "(got %" G_GSIZE_FORMAT ", need %" G_GSIZE_FORMAT ")",
+                    need - len, len, need);
+        return false;
+    }
+
+    const int copy = MIN((int)stride, s->stride);
+    for (int y = 0; y < s->height; y++)
+        memcpy(s->data + (gsize)y * s->stride,
+               data + (gsize)y * stride, copy);
+
+    s->dirty = true;
+    return true;
+}
+
+bool kqs_surface_resize(KqsSurface *s, uint32_t width, uint32_t height,
+                        GError **error)
+{
+    /* Nothing to grow into yet; the first Scanout will size us. */
+    if (!s->data || s->pix_fmt == AV_PIX_FMT_NONE)
+        return true;
+    if (s->width == (int)width && s->height == (int)height)
+        return true;
+
+    if (!surface_realloc(s, width, height, s->pix_fmt, error))
+        return false;
+
+    s->dirty = true;
+    return true;
+}
+
+bool kqs_surface_update(KqsSurface *s, int x, int y, int width, int height,
+                        uint32_t stride, uint32_t pixman_format,
+                        const uint8_t *data, gsize len, GError **error)
+{
+    if (!s->data) {
+        /* Update before Scanout: nothing to composite into. */
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_INITIALIZED,
+                    "Update before any Scanout");
+        return false;
+    }
+
+    if (kqs_pixman_to_av(pixman_format) != s->pix_fmt) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+                    "Update format 0x%08x does not match surface",
+                    pixman_format);
+        return false;
+    }
+
+    /* Clip to the surface; QEMU should never exceed it, but damage
+     * rectangles race geometry changes. */
+    if (x < 0) { width += x; x = 0; }
+    if (y < 0) { height += y; y = 0; }
+    if (x >= s->width || y >= s->height || width <= 0 || height <= 0)
+        return true;
+    width  = MIN(width,  s->width  - x);
+    height = MIN(height, s->height - y);
+
+    const gsize need = (gsize)stride * (height - 1) + (gsize)width * s->bpp;
+    if (len < need) {
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+                    "Update short by %" G_GSIZE_FORMAT " bytes", need - len);
+        return false;
+    }
+
+    for (int row = 0; row < height; row++)
+        memcpy(s->data + (gsize)(y + row) * s->stride + (gsize)x * s->bpp,
+               data + (gsize)row * stride,
+               (gsize)width * s->bpp);
+
+    s->dirty = true;
+    return true;
+}
-- 
2.55.0




  parent reply	other threads:[~2026-08-26  9:31 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-26  7:43 [RFC v2 pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber, kyberproxy,kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 qemu-server 05/13] virtio-gl : add vulkan option Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-26  7:43 ` Alexandre Derumier [this message]
2026-08-26  7:43 ` [RFC v2 pve-kyberproxy 09/13] Add pve-kyberproxy Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
2026-08-26  7:43 ` [RFC v2 pve-rdp-web 13/13] add pve-rdp-web: console's webassembly client Alexandre Derumier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260826074347.1256659-9-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal