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
next prev 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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox