all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console
Date: Tue, 25 Aug 2026 13:34:33 +0200	[thread overview]
Message-ID: <20260825113442.947620-9-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-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                                  |  592 +++++
 src/kqs.h                                     |  194 ++
 src/listener.c                                |  568 +++++
 src/main.c                                    |  193 ++
 src/sink.c                                    |  483 ++++
 src/surface.c                                 |  188 ++
 28 files changed, 7987 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..0265b6d
--- /dev/null
+++ b/src/dmabuf.c
@@ -0,0 +1,592 @@
+/*
+ * 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 <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;
+};
+
+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.
+     */
+    /*
+     * Which correction is right depends on where the scanout came from, and
+     * y0_top on its own has not been a reliable guide.
+     *
+     * Off the vhost-user-gpu helper the picture arrives the right way up even
+     * though y0_top is false, so correcting it is what breaks it: with
+     * dir=hflip the result is mirrored left to right and nothing else. That
+     * also settles an earlier doubt - transpose_vaapi does apply on this
+     * driver, and applies exactly what it is asked for, so the 180-rotated
+     * picture seen on the in-process path was a genuinely different input,
+     * not a filter that was being ignored.
+     *
+     * "none" is therefore the default. The flag stays so the in-process
+     * virtio-gpu-gl path can be corrected differently once it is usable at
+     * all - today it crashes long before orientation matters.
+     */
+    const char *dir = g_getenv("KQS_DMABUF_FLIP");
+    if (!dir || !*dir)
+        dir = "none";
+
+    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;
+}
+
+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;
+    }
+
+    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);
+        g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                    "av_hwframe_map DRM_PRIME->VAAPI failed "
+                    "(fourcc '%s', modifier 0x%" G_GINT64_MODIFIER "x)",
+                    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:30 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:34 [RFC 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-25 11:34 ` [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-kyberproxy 09/13] Add pve-kyberproxy Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
2026-08-25 11:34 ` [RFC 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=20260825113442.947620-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal