From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 268791FF0C1 for ; Wed, 26 Aug 2026 11:31:11 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1956A216B2; Wed, 26 Aug 2026 11:29:30 +0200 (CEST) ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none; b=ZMYYBe8L/cQkwX8zUtRhxP5QvKdwqoIEVw8JWgM+XNhddH8ObIfJPR1QXcrNeWh8KBIxGcMv+2i9fBebKa/6w+pScov7Q3sMKTclcWz4AB2z4EVZP6XChXJLSTPyl4hQmRroi2xO8PXEcajcBtIS9B7UD25qnA+SRd3m3sKhCjfwZVFPsdLt+u7Bx6eRQHOeOSgTiSWiMheY3Bx4E9Di7A9gVqtlpK4HthENJIXAPqap/hySvCDX/gV/gpp3F+xOAOP+IW73jfKQ9dBI/YGzUudaW1X8dS8aqahQ3tU2wBNvtuWSbAkNQgpG23vv8fGmtAi6aQSxj83Rkq7X5N+67A== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector10001; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=ukctQ4jgqCknHnYRF8ylOE4bKx1JwATGW0NHJpeOTMg=; b=I38GQnX/CJN4GlaZ52A5DvOZlWWHW1v9Nf5Irgvtk+mpx6WDpKwgAfnX7dRblQziVRiNi3ky2jagIWqkZuqesZAIZQjc1yc1zompvMfea7n3UQAJHAI4h3DxS8JhM68Z5ifPUxYqdqa6rUN6w0nDpb+sNsIfPVgiYwB6PFE1fyddG3XqTxZStH/MbdTCTyPVuKoejXhqmfuxY+ndPX8QPYXEQVu1LuGykkmUQMO/Bly2yKDckgijVsRFrFZgxaOMGh+xWM2Yh+LX8/Aqqq63m56cL+Xhejvv6s+g/X6razBln5wj09cUOocoLv1uXr49BTdRPy+8NmlMf7Ouod+aaw== ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=pass smtp.mailfrom=groupe-cyllene.com; dmarc=pass action=none header.from=groupe-cyllene.com; dkim=pass header.d=groupe-cyllene.com; arc=none DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=groupecyllene.onmicrosoft.com; s=selector2-groupecyllene-onmicrosoft-com; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; bh=ukctQ4jgqCknHnYRF8ylOE4bKx1JwATGW0NHJpeOTMg=; b=OQvyWMg+L6FscKlMG5OMk245CP1xEn+bc/6gC5UuY5RH3WGOOy4ioTsRff6f7WKbDdBkq+F4L/3P9rirQ8C+9Gf5L+imktjYX3D5cAkk5qRe5gHwrWy5aMZytHKAc/KrvsK51/BHcba3CaeBB4/4vnMqifQ4SWFCtyebp5py4IY= Authentication-Results: dkim=none (message not signed) header.d=none;dmarc=none action=none header.from=groupe-cyllene.com; From: Alexandre Derumier To: pve-devel@lists.proxmox.com Subject: [RFC v2 pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console Date: Wed, 26 Aug 2026 09:43:40 +0200 Message-ID: <20260826074347.1256659-9-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> References: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> Content-Transfer-Encoding: 8bit Content-Type: text/plain X-ClientProxiedBy: PA7P264CA0229.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:372::8) To PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:143::11) MIME-Version: 1.0 X-MS-PublicTrafficType: Email X-MS-TrafficTypeDiagnostic: PR1P264MB3696:EE_|PR0P264MB2257:EE_ X-MS-Office365-Filtering-Correlation-Id: 08d5e286-055c-47c1-e351-08df0345d583 X-MS-Exchange-SenderADCheck: 1 X-MS-Exchange-AntiSpam-Relay: 0 X-Microsoft-Antispam: BCL:0;ARA:13230040|376014|23010399003|52116014|1800799024|366016|13003099007|38350700014|6133799003|3023799007|56012099006|10067099003|5023799004|22082099003|18002099003; X-Microsoft-Antispam-Message-Info: Kau0N0xzGkXOPRajR//Sir3wviYPfTkAlpFaVBgavsfefso2ITgAj3en5LCFt85JnvKsNf+2519ojMykmxwHOoR0IrWMJDqF+Zi32IOU4dYevSItLJFaiza+N9DL64vCoEWGzRCojzzO9qXW48fKGOZLK7PBvt2nu8pAvX9OFPjqYkRLT+9jZiOT5CyhgQuzYM8wwBNhDi2AMLBGJXuWWUroVWFg0N1b4wv9RbYWLaZjX1NZTG516bcyXe3ad45zGJ3qGlvxnMNtvmWFeXt0T2Ah86tlw+CrAg6A7YNJqqT2mQENOjHTwHBF6JxaaVoPpcFaCGIjlMYqVnEgG+zVoSg6Que7pfcdGZFZkEVRg3yUwXRQ0qpnBN/tlJhAcPtCsDZzF3daQLVxfMpvxq7RpF0bcc46IePKCqm5MBlGWJwzxrbgMXGAL2jGB72nllKLPeFn0CF3roKR1Ifhe3TzSooeT3woNBpVCqSW6w8YeQGU0lXT7wMYAOZFulJiD3KVvcVqPMa41vy9i7s5/Xef1Kbn9m2BVY88zcBVfwkOjUF4n0D++Eb2TXJ0yBos+gsIwMpqg7OmrBU1OzXPfbYFLA3UPWxZj4+Ut6hsRc88PZung+oA6CFe0gm7WNB6S9mI2idPvTNnKIintKxfxIicx0KBtDqXWasT6mIVCRbdg8E+rNOtbf4DmL3TUEfinixL6LuqikhBazXdMfjRvyTUem1rKQiGP5/nCJy++v4001Q= X-Forefront-Antispam-Report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(13230040)(376014)(23010399003)(52116014)(1800799024)(366016)(13003099007)(38350700014)(6133799003)(3023799007)(56012099006)(10067099003)(5023799004)(22082099003)(18002099003);DIR:OUT;SFP:1102; X-MS-Exchange-AntiSpam-MessageData-ChunkCount: 1 X-MS-Exchange-AntiSpam-MessageData-0: =?us-ascii?Q?1yI7GBuSmv1XxfqVuWPz4AdMRQJpfx5K48AkypZugVPwZDdQ9++RDE5L593y?= =?us-ascii?Q?81FFQO1kTGw3Ok6KIQfvJUvTB2wBtcE26RS2FArr/eoXk5y9keOvE0yEW5IW?= =?us-ascii?Q?4bzMIYWXELb4T99M9xXPT0VqqX3UskErfHKO5gtiaDaraUPKMOd2URaZoPwr?= =?us-ascii?Q?NAB2FOzjktMRlVaNF6FTwe7x7DR+TtqZiMYIBrfgmocFMEKO0bKXHaYYPFT6?= =?us-ascii?Q?GwmBzR4nPBsjvCO3x1mAWUzjyCAfKjkcIqhMuufCMKT4ayK4/x+VhsnBs1P2?= =?us-ascii?Q?rmeXitrRoLLAnjIaJipFVLF+0YVz/eeXEE8b0g5AKWZwAX7I3LX8AgN7Kn6n?= =?us-ascii?Q?pGl/3eQDIkOooPs1xb1y8xHJ1dOy2Agdog9ODYhlejRRl5kupfWM9DtGLHdm?= =?us-ascii?Q?Sgi+IzMNrA5lfaYV1/7NqfyAxXCv7wQ6tQjddLXGK6icmF/20cy03GA1BXpN?= =?us-ascii?Q?6JQ0TUp7JgQlTFllH6oAVf3OoA9QIwIlTzHPWVUp6cSiZiW1lQVo9MpLqMAz?= =?us-ascii?Q?0srzNjN3Lpi17fhT2B7+mC2EMeRKwnSSob43hwTE3urolEMwivNboP10fcyD?= =?us-ascii?Q?8QOgoQtUNFh34dRzJZaB5i4i4raM910bjSzXW6+cjDM2/FMjI7yWhLiUukGh?= =?us-ascii?Q?KYXnSuVLPnONRdMQAt0JwU1f7dtX5YEIYxUuA3gNQJFhPzsCu/toz7NKoLpH?= =?us-ascii?Q?ZLs3tLXS1kazJluZm6ZXieYEyID6/5o3hZaisFCtToh12D/dYi/V83TeaTgO?= =?us-ascii?Q?cY1hT+DYps2zUEXuCH2UwzBbwu9rrWzwjUp7gNaUmCHkK8CkjnnqP0t+Q33v?= =?us-ascii?Q?THWnrry7Gpg94axWVD5M7rXSm2jBDu5Cvd9Bctbwkt8nVK5Hx6erHi2OVrPp?= =?us-ascii?Q?re4WemjjvT9Kth8NEZxpEuMJAzHjhAU2b74zvSDHwsIdXmCpNKm0rx92ZjWk?= =?us-ascii?Q?0pvWw5Ifx5fEcYqDQzzO7FGjphZJMU2Qw4BFRou3och52m3R+ZhnQcfrRb/y?= =?us-ascii?Q?e95iqLRwAhgsBGnDAAifV/1ROt+qQGLNHDJdrFis2hpP2kXBGsfSnTm56siA?= =?us-ascii?Q?80Hp4/dvonBZrot+UY8908EshUN6EK2CC8acA0Xs9FCGshEDRPe5AXDjX28I?= =?us-ascii?Q?tRjrFqvxfY2GX3ByzCj6j6k1zGhaTRzjIkronZcIkXz7hwH1bT+nwaYEui6d?= =?us-ascii?Q?n7LF2De8qNZg6fYXdwbis8IEuKyn+eOlPHNqHL899fEaJy7Xl8g1SwUrM+64?= =?us-ascii?Q?aUOV7/IlvlDEXQsN5M/dvQ8EO0gbejfVjLe20viVMgbk7nM1h/FacmGUoXSF?= =?us-ascii?Q?zEQkz9rd+K35ZA1vogvbq1wwmHEY+mnKp/VRRKrOZF0VVtR80d1QtB+V+Ke6?= =?us-ascii?Q?/i9l6A74FxK6o2i2CeRmu+Ya1EOLe0HG475EhJXbUHjXiTTFK/pbJOOTUnPC?= =?us-ascii?Q?ecJvIgfwpQu7AiTPlw/Zl2N/y4pT65FZbWc3Hjzer44bhxjoOZ1vpxyt/2FE?= =?us-ascii?Q?md3WMJXK169j0QtzjJdraeP9p4RGsrozbDVdO275e9AE9JxJkHZhPAOJ1/br?= =?us-ascii?Q?scBkwT1cG7bwnQuQTPA7fZo+VDMMr/gQLwYd/JQeNTOkhC/rWBQhSabD3yFD?= =?us-ascii?Q?OUWDaKsDs0MMpAFoLid9IrOcO0O1cmKis6IUs1ker9szkUY1u0GpSSLh5BiV?= =?us-ascii?Q?qx+2L5k8cNFAAvi7jaqODdBah2pTIvp5gxGFxfM5O4hxS/KCKLbh/ffanUpz?= =?us-ascii?Q?/dN5NgstPXKFgKuRGUHzv7sV2s8eAhlG4Mb8zXJZOzzeH8qe9YO/?= X-OriginatorOrg: groupe-cyllene.com X-MS-Exchange-CrossTenant-Network-Message-Id: 08d5e286-055c-47c1-e351-08df0345d583 X-MS-Exchange-CrossTenant-AuthSource: PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM X-MS-Exchange-CrossTenant-AuthAs: Internal X-MS-Exchange-CrossTenant-OriginalArrivalTime: 26 Aug 2026 07:44:18.4724 (UTC) X-MS-Exchange-CrossTenant-FromEntityHeader: Hosted X-MS-Exchange-CrossTenant-Id: ee11ccf7-112c-4284-848b-f229745e715b X-MS-Exchange-CrossTenant-MailboxType: HOSTED X-MS-Exchange-CrossTenant-UserPrincipalName: h9V8GJWgfMo6jMx5bgVUgQ9L+skTQeSY+TBee/pRiuX5U1qOC/ktKC5fOl2LmlFFHvu/jgjBx74ENlxd4tLailL/zyrnd6sEWACJ0ofPs0ILeYblF/awwf9J7Tt7xQtU X-MS-Exchange-Transport-CrossTenantHeadersStamped: PR0P264MB2257 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.439 Adjusted score from AWL reputation of From: address DKIM_INVALID 0.1 DKIM or DK signature exists, but is not valid DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DMARC_PASS -0.1 DMARC pass policy KAM_ASCII_DIVIDERS 0.8 Email that uses ascii formatting dividers and possible spam tricks KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LOTSOFHASH 0.25 Emails with lots of hash-like gibberish RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust SPF_HELO_PASS -0.001 SPF: HELO matches SPF record SPF_PASS -0.001 SPF: sender matches SPF record X-MailFrom: Alexandre.DERUMIER@groupe-cyllene.com X-Mailman-Rule-Hits: max-size X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; news-moderation; no-subject; digests; suspicious-header Message-ID-Hash: 6KDZZHDREUNKMHL75GXVGAY7EYYMYH2Y X-Message-ID-Hash: 6KDZZHDREUNKMHL75GXVGAY7EYYMYH2Y X-Mailman-Approved-At: Wed, 26 Aug 2026 11:28:46 +0200 X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- .gitignore | 12 + .gitmodules | 3 + Makefile | 130 ++ avservice/Cargo.lock | 1125 +++++++++ avservice/Cargo.toml | 24 + avservice/src/main.rs | 535 +++++ debian/changelog | 5 + debian/control | 49 + debian/copyright | 20 + debian/install | 2 + debian/rules | 29 + debian/source/format | 1 + inputservice/Cargo.lock | 2063 +++++++++++++++++ inputservice/Cargo.toml | 30 + inputservice/src/clipboard.rs | 318 +++ inputservice/src/main.rs | 494 ++++ kyber-desktop | 1 + kyber-qemu-server.mk | 56 + kycontroller.wrapper | 13 + ...-reset-log-component-count-on-uninit.patch | 12 + ...ller-configure-from-the-command-line.patch | 353 +++ src/audio.c | 494 ++++ src/dmabuf.c | 701 ++++++ src/kqs.h | 194 ++ src/listener.c | 568 +++++ src/main.c | 193 ++ src/sink.c | 483 ++++ src/surface.c | 188 ++ 28 files changed, 8096 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Makefile create mode 100644 avservice/Cargo.lock create mode 100644 avservice/Cargo.toml create mode 100644 avservice/src/main.rs create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/install create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 inputservice/Cargo.lock create mode 100644 inputservice/Cargo.toml create mode 100644 inputservice/src/clipboard.rs create mode 100644 inputservice/src/main.rs create mode 160000 kyber-desktop create mode 100644 kyber-qemu-server.mk create mode 100755 kycontroller.wrapper create mode 100644 patches/0001-txproto-reset-log-component-count-on-uninit.patch create mode 100644 patches/0002-kycontroller-configure-from-the-command-line.patch create mode 100644 src/audio.c create mode 100644 src/dmabuf.c create mode 100644 src/kqs.h create mode 100644 src/listener.c create mode 100644 src/main.c create mode 100644 src/sink.c create mode 100644 src/surface.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2229803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Assembled by `make staging` from a built Kyber SDK; not vendored here. +/staging/ +/kyber-qemu-server +/src/*.o +/avservice/target/ +/inputservice/target/ +/pve-kyber-[0-9]*/ +# the SDK writes these beside whatever runs it +log/ +*.deb +*.buildinfo +*.changes diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f7af402 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "kyber-desktop"] + path = kyber-desktop + url = https://gitlab.com/kyber/apps/kyber-desktop.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..92dd711 --- /dev/null +++ b/Makefile @@ -0,0 +1,130 @@ +include /usr/share/dpkg/architecture.mk +include /usr/share/dpkg/pkg-info.mk + +PACKAGE=pve-qemu-kyber +ARCH:=$(DEB_HOST_ARCH) +DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb +DSC=$(PACKAGE)_$(DEB_VERSION).dsc +BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM) + +# Kyber's SDK: AGPL-3.0-or-later like this package, but a separate upstream +# with its own cadence, so it is cloned at build time rather than vendored or +# carried as a submodule. This repo then holds nothing but packaging, and the +# revision it builds is one greppable line rather than a gitlink. +# +# Pinned by commit, so a moved tag cannot change what this builds. +KYBER_DIR = kyber-desktop +ROOTFS = $(CURDIR)/$(KYBER_DIR)/rootfs-x86_64-linux-gnu + +export PKG_CONFIG_PATH := $(ROOTFS)/lib/pkgconfig:$(ROOTFS)/lib/x86_64-linux-gnu/pkgconfig:$(ROOTFS)/lib64/pkgconfig +export LD_LIBRARY_PATH := $(ROOTFS)/lib:$(ROOTFS)/lib/x86_64-linux-gnu:$(ROOTFS)/lib64 + +all: $(DEB) + +# --- upstream --------------------------------------------------------------- +# Fetched once, and left alone after that, following pve-qemu: a build must not +# depend on re-fetching, and a checkout someone has been working in is theirs. +# +# The patch below is what needs the tree pristine, so it resets just the files +# it touches rather than the whole submodule - which would discard the build +# output beside them for nothing. +.PHONY: submodule +submodule: +ifeq ($(shell test -f "$(KYBER_DIR)/Cargo.toml" && echo 1 || echo 0), 0) + git submodule update --init --recursive $(KYBER_DIR) +endif + +# 0002 is what the per-VM design rests on: a controller configured from its +# command line. 0001 is a txproto fix. Each applies in the repo it was cut from. +.PHONY: patch +patch: submodule + git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto checkout --force -- . + git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto apply $(CURDIR)/patches/0001-*.patch + git -C $(KYBER_DIR)/kysdk/kyctl checkout --force -- . + git -C $(KYBER_DIR)/kysdk/kyctl apply $(CURDIR)/patches/0002-*.patch + +# --- build ------------------------------------------------------------------ +# Long: FFmpeg and VLC are built from source. The SDK's own script does it, and +# needs cargo-c and meson 1.10+ - see debian/control. +.PHONY: sdk +sdk: patch + cd $(KYBER_DIR) && ./build-linux.sh -o $(ROOTFS) + +# The adapters stand in for Kyber's own capture and input servers, and +# kyber-qemu-server is what actually talks to QEMU. +.PHONY: adapters +adapters: sdk + cd avservice && cargo build --release + cd inputservice && cargo build --release + +# TX_RPATH empty: an rpath here would be the build tree, and the wrapper +# already puts the package prefix on LD_LIBRARY_PATH. +.PHONY: server +server: sdk + $(MAKE) -f kyber-qemu-server.mk TX_RPATH= \ + TX_INC=$(ROOTFS)/include TX_LIB=$(ROOTFS)/lib/x86_64-linux-gnu + +.PHONY: build +build: adapters server + +# --- packaging -------------------------------------------------------------- +# Only the libraries the four binaries actually resolve against: the build tree +# also carries a client, a player and VLC plugins a node never loads. +.PHONY: staging +staging: build + rm -rf staging + mkdir -p staging/bin staging/lib + install -m 0755 $(ROOTFS)/bin/kycontroller staging/bin/ + install -m 0755 kyber-qemu-server staging/bin/ + install -m 0755 avservice/target/release/kqs-avservice staging/bin/ + install -m 0755 inputservice/target/release/kqs-inputservice staging/bin/ + ln -sf kqs-avservice staging/bin/kyavserver + ln -sf kqs-inputservice staging/bin/kynputserver + for b in staging/bin/kycontroller staging/bin/kyber-qemu-server \ + staging/bin/kqs-avservice staging/bin/kqs-inputservice; do \ + ldd $$b | awk "/=> \//{print \$$3}"; \ + done | sort -u | grep "^$(ROOTFS)" | xargs -r cp -aL -t staging/lib/ + +.PHONY: builddir +builddir: + rm -rf $(BUILDDIR) + $(MAKE) $(BUILDDIR) + +$(BUILDDIR): staging + rm -rf $@ $@.tmp + mkdir $@.tmp + cp -a staging debian Makefile kycontroller.wrapper $@.tmp/ + mv $@.tmp $@ + +deb: $(DEB) +$(DEB): $(BUILDDIR) + cd $(BUILDDIR); dpkg-buildpackage -b -us -uc + lintian $(DEB) || true + +# A source package, for sbuild and for review: Proxmox builds every package +# this way, so it has to work even when the binary path is what gets used. +.PHONY: dsc +dsc: + rm -rf $(BUILDDIR) $(DSC) + $(MAKE) $(DSC) + lintian $(DSC) + +$(DSC): $(BUILDDIR) + cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d + +sbuild: $(DSC) + sbuild $< + +.PHONY: dinstall +dinstall: deb + dpkg -i $(DEB) + +.PHONY: clean +clean: + rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/ staging/ + rm -f kyber-qemu-server src/*.o + cd avservice && cargo clean + cd inputservice && cargo clean + +.PHONY: distclean +distclean: clean diff --git a/avservice/Cargo.lock b/avservice/Cargo.lock new file mode 100644 index 0000000..441c68d --- /dev/null +++ b/avservice/Cargo.lock @@ -0,0 +1,1125 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqs-avservice" +version = "0.1.0" +dependencies = [ + "env_logger", + "kyavservice-types", + "libc", + "libkypc", + "log", + "tokio", + "zbus", +] + +[[package]] +name = "kyavservice-types" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libkypc" +version = "0.1.0" +dependencies = [ + "async-trait", + "log", + "rmp-serde", + "serde", + "thiserror 1.0.69", + "tokio", + "windows-sys", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/avservice/Cargo.toml b/avservice/Cargo.toml new file mode 100644 index 0000000..d65503f --- /dev/null +++ b/avservice/Cargo.toml @@ -0,0 +1,24 @@ +# Thin adapter that lets kycontroller drive kyber-qemu-server. +# +# Written in Rust purely so it can link the real libkypc rather than +# reimplement its MessagePack IPC. It owns no media code: on KymuxStartVideo +# it launches the C server with the kymux URI the controller assigned. + +[package] +name = "kqs-avservice" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" + +[[bin]] +name = "kqs-avservice" +path = "src/main.rs" + +[dependencies] +libkypc = { path = "../kyber-desktop/kysdk/kyutil/libkypc" } +kyavservice-types = { path = "../kyber-desktop/kysdk/kymedia/kyavservice-types" } +tokio = { version = "1", features = ["full"] } +env_logger = "0.11" +log = "0.4" +zbus = { version = "5", default-features = false, features = ["tokio"] } +libc = "0.2" diff --git a/avservice/src/main.rs b/avservice/src/main.rs new file mode 100644 index 0000000..8abc8a4 --- /dev/null +++ b/avservice/src/main.rs @@ -0,0 +1,535 @@ +// kqs-avservice - lets Kyber's controller drive kyber-qemu-server. +// +// kycontroller does not talk to its AV server over a URI; it spawns a child, +// passes a Unix socket address in KYBER_PARENT_ADDR, and issues MessagePack +// commands over it. The kymux URI that video must be published to arrives +// inside KymuxStartVideo, so nothing downstream can be wired up until we +// answer that conversation. +// +// Rather than reimplement that protocol in C, this adapter links the real +// libkypc and translates. It carries no media code at all: on KymuxStartVideo +// it launches the C server with the URI it was handed. +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// (AGPL because it links libkypc; the C server stays LGPL in its own process.) + +use std::os::unix::process::CommandExt; +use std::process::{Child, Command as SysCommand, Stdio}; + +use kyavservice_types::{Command, Display, Event}; +use log::{error, info, warn}; + +/// Where to find the C server. Overridable so it can be run from a build tree. +fn server_binary() -> String { + std::env::var("KQS_SERVER_BIN").unwrap_or_else(|_| "kyber-qemu-server".to_string()) +} + +/// QEMU's D-Bus display name, matching kyber-qemu-server's own default. +fn qemu_bus_name() -> String { + std::env::var("KQS_BUS_NAME").unwrap_or_else(|_| "org.qemu".to_string()) +} + +/// One display, the guest's console. +fn qemu_display(width: i32, height: i32) -> Display { + Display { + id: 1, + name: "QEMU".to_string(), + width, + height, + x: 0, + y: 0, + } +} + +/// Ask QEMU how big the guest console is. +/// +/// The controller needs a display to offer before it will start video, and the +/// client sizes its window from it. Falling back to a plausible default is +/// better than refusing to enumerate: the guest can resize later and the +/// stream carries its own dimensions regardless. +async fn enumerate_qemu_displays() -> Vec { + 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, 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 { + 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 { + 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> { + 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, + /// 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, +} + +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::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> { + 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::().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 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 +Uploaders: Alexandre Derumier +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 +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 . 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 { + // 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>>, +} + +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>>, + state: Mutex, +} + +impl Bridge { + pub async fn new( + conn: &zbus::Connection, + bus_name: String, + ) -> Result, Box> { + 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) { + *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) { + 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 { + let mimes = [mime_for(format)]; + let reply: (String, Vec) = 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) { + if selection != SELECTION_CLIPBOARD { + return; + } + + let mut formats: Vec = + 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) -> Option<(String, Vec)> { + 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, +} + +#[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) { + 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, + ) -> zbus::fdo::Result<(String, Vec)> { + 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, + /// 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>, + /// 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>, + /// 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>, +} + +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> { + 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 { + 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, + _consumer: Arc, +} + +fn start_stream( + uri: &str, + tx: mpsc::UnboundedSender, + clip_tx: Option>, + size: Arc>, + space: Arc>, + clipboard: Option<&Arc>, +) -> Result> { + { + 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 = Arc::downgrade(&consumer) as Weak; + 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); + } + + 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 { + 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> { + 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::(); + + // 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::(); + 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::().await?; + let (mut receiver, _sender) = worker.get_sender_receiver()?; + + let mut session: Option = 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, + } + ++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 { +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, ++ ++ /// 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, ++ ++ /// Listening port, for both the HTTP control plane and QUIC. ++ #[arg(long = "port", value_name = "PORT")] ++ pub port: Option, ++ ++ /// 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, ++ ++ /// 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, ++ ++ /// Directory served as the web client. ++ #[arg(long = "webclient", value_name = "PATH")] ++ pub webclient: Option, ++ ++ /// TLS certificate chain, in PEM form. ++ #[arg(long = "tls-cert", value_name = "PATH")] ++ pub tls_cert: Option, ++ ++ /// Private key for --tls-cert, in PEM form. ++ #[arg(long = "tls-key", value_name = "PATH")] ++ pub tls_key: Option, ++ ++ /// 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, ++ ++ /// 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, + listen_mode: Option, + dataplane_port: Option, ++ dataplane_addr: Option, + webtransport_gen_certificate: Option, + tls_cert: Option, + tls_key: Option, +@@ -70,6 +71,8 @@ pub(crate) struct Config { + watchdog: Option, + multi_client: Option, + webclient: Option, ++ #[serde(skip)] ++ listen_socket: Option, + } + + 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 { + self.dataplane_port + } + ++ pub(crate) fn dataplane_addr(&self) -> Option { ++ 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, + pub(crate) listening_port: u16, + pub(crate) webtransport_gen_certificate: bool, + pub(crate) cert_chain: Vec>, +@@ -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 { ++ 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 { + 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 +#include +#include + +#include + +#include +#include +#include + +#include +#include + +#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[] = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +struct KqsAudio { + TXMainContext *tx; /* our own: tx_init() is once per process */ + char *kymux_uri; + int bitrate_bps; + + GDBusConnection *peer; + GDBusNodeInfo *node; + guint reg_id; + + AVBufferRef *encoder; + AVBufferRef *muxer; + AVBufferRef *fifo; /* borrowed: encoder's src_frames */ + + /* The stream QEMU announced in Init, and whether it is playing. */ + guint64 stream_id; + gboolean have_stream; + gboolean enabled; + guint32 freq; + guint8 channels; + guint32 bytes_per_frame; + + /* + * Opus takes fixed-size frames, and QEMU writes whatever the guest + * produced, so samples are accumulated here until a frame's worth exists. + */ + GByteArray *pending; + int64_t samples_sent; + + uint64_t frames; + uint64_t dropped; +}; + +/* -- pipeline ------------------------------------------------------------ */ + +static bool audio_build(KqsAudio *a, GError **error) +{ + AVDictionary *opts = NULL; + /* Same options Kyber's own audio path uses, so a client sees no + * difference between a guest stream and a desktop one. */ + av_dict_set_int(&opts, "b", a->bitrate_bps, 0); + av_dict_set(&opts, "application", "audio", 0); + av_dict_set_int(&opts, "frame_duration", KQS_AUDIO_FRAME_MS, 0); + av_dict_set(&opts, "vbr", "on", 0); + + /* + * No sample rate or format here: TxEncoderOptions carries video fields + * only, and txproto configures an encoder from the first frame on its + * src_frames FIFO - which is why Init pushes a seed before committing. + */ + TxEncoderOptions eopts = { + .enc_name = "libopus", + .name = "guest-audio", + .options = opts, + .pix_fmt = AV_PIX_FMT_NONE, + }; + + a->encoder = tx_encoder_create(a->tx, &eopts); + if (!a->encoder) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "libopus unavailable"); + return false; + } + + /* + * The packet sink refuses an encoder without AV_CODEC_FLAG_GLOBAL_HEADER + * ("Packet sink requires global header"), and linking an encoder to one + * asks for it explicitly - link.c calls encoder_mode_negotiate(src, 0). + * So the producer sets it, as sink.c does; the flag is applied when the + * encoder configures itself from the first frame. + */ + ((EncodingContext *)a->encoder->data)->need_global_header = 1; + + a->muxer = tx_packetsink_create(a->tx, a->kymux_uri); + if (!a->muxer) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "packet sink for %s failed", a->kymux_uri); + return false; + } + + if (tx_link(a->tx, a->encoder, a->muxer, 0) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_link failed"); + return false; + } + + a->fifo = ((EncodingContext *)a->encoder->data)->src_frames; + return true; +} + +/* + * txproto derives the encoder's parameters from frame->opaque_ref, not from an + * AVCodecContext - there is none when the first frame lands. init_avctx() + * dereferences it unconditionally, so a frame without one segfaults inside + * tx_commit(); its audio branch reads time_base and bits_per_sample from here. + * sink.c's attach_timing() does the same for video. + * + * pts is counted in samples, so the time base is 1/rate. + */ +static bool audio_attach_timing(AVFrame *f) +{ + FormatExtraData *fe = av_mallocz(sizeof(*fe)); + if (!fe) + return false; + + fe->time_base = (AVRational){ 1, KQS_AUDIO_RATE }; + fe->avg_frame_rate = (AVRational){ KQS_AUDIO_RATE, KQS_AUDIO_FRAME_SAMPLES }; + fe->bits_per_sample = 16; + + f->opaque_ref = av_buffer_create((uint8_t *)fe, sizeof(*fe), NULL, NULL, 0); + if (!f->opaque_ref) { + av_free(fe); + return false; + } + return true; +} + +/* An empty frame of the shape the encoder should configure itself for. */ +static AVFrame *audio_blank_frame(void) +{ + AVFrame *f = av_frame_alloc(); + if (!f) + return NULL; + + f->format = AV_SAMPLE_FMT_S16; + f->sample_rate = KQS_AUDIO_RATE; + f->nb_samples = KQS_AUDIO_FRAME_SAMPLES; + av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS); + + if (av_frame_get_buffer(f, 0) < 0) { + av_frame_free(&f); + return NULL; + } + + av_samples_set_silence(f->data, 0, f->nb_samples, KQS_AUDIO_CHANNELS, + AV_SAMPLE_FMT_S16); + f->pts = 0; + + if (!audio_attach_timing(f)) { + av_frame_free(&f); + return NULL; + } + return f; +} + +/* One 20ms frame out of the accumulator. */ +static AVFrame *audio_take_frame(KqsAudio *a) +{ + const guint need = KQS_AUDIO_FRAME_SAMPLES * KQS_AUDIO_CHANNELS + * sizeof(int16_t); + if (a->pending->len < need) + return NULL; + + AVFrame *f = av_frame_alloc(); + if (!f) + return NULL; + + f->format = AV_SAMPLE_FMT_S16; + f->sample_rate = KQS_AUDIO_RATE; + f->nb_samples = KQS_AUDIO_FRAME_SAMPLES; + av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS); + + if (av_frame_get_buffer(f, 0) < 0) { + av_frame_free(&f); + return NULL; + } + + memcpy(f->data[0], a->pending->data, need); + g_byte_array_remove_range(a->pending, 0, need); + + /* In samples, which is the encoder's time base. */ + f->pts = a->samples_sent; + a->samples_sent += KQS_AUDIO_FRAME_SAMPLES; + + if (!audio_attach_timing(f)) { + av_frame_free(&f); + return NULL; + } + return f; +} + +/* -- D-Bus --------------------------------------------------------------- */ + +static void audio_init_stream(KqsAudio *a, GVariant *params) +{ + guint64 id; + guint8 bits, nchannels; + gboolean is_signed, is_float, be; + guint32 freq, bytes_per_frame, bytes_per_second; + + /* t id, y bits, b signed, b float, u freq, y channels, u bpf, u bps, b be */ + g_variant_get(params, "(tybbuyuub)", &id, &bits, &is_signed, &is_float, + &freq, &nchannels, &bytes_per_frame, &bytes_per_second, &be); + + g_message("audio stream %" G_GUINT64_FORMAT ": %uHz %uch %ubit " + "(signed=%d float=%d be=%d)", + id, freq, nchannels, bits, is_signed, is_float, be); + + /* + * QEMU converts to whatever the audiodev was configured for, so this is + * the format asked for on the command line. Anything else would need a + * resampler, and saying so is better than sending noise. + */ + if (bits != 16 || is_float || !is_signed || be || + freq != KQS_AUDIO_RATE || nchannels != KQS_AUDIO_CHANNELS) { + g_warning("unsupported audio format; expected %dHz %dch s16le", + KQS_AUDIO_RATE, KQS_AUDIO_CHANNELS); + return; + } + + if (!a->encoder) { + g_autoptr(GError) error = NULL; + if (!audio_build(a, &error)) { + g_warning("audio pipeline failed: %s", error->message); + return; + } + + /* + * The encoder takes its parameters from the first frame, and the + * muxer takes its from the encoder, so tx_commit() blocks until one + * exists. 20ms of silence costs nothing and unblocks both. + */ + AVFrame *seed = audio_blank_frame(); + if (!seed || sp_frame_fifo_push(a->fifo, seed) < 0) { + g_warning("could not seed the audio encoder"); + av_frame_free(&seed); + return; + } + av_frame_free(&seed); + a->samples_sent = KQS_AUDIO_FRAME_SAMPLES; + + if (tx_commit(a->tx) < 0) { + g_warning("tx_commit failed for audio"); + return; + } + g_message("audio: streaming to %s", a->kymux_uri); + } + + a->stream_id = id; + a->have_stream = TRUE; + a->freq = freq; + a->channels = nchannels; + a->bytes_per_frame = bytes_per_frame; +} + +static void audio_write(KqsAudio *a, GVariant *params) +{ + guint64 id; + g_autoptr(GVariant) data = NULL; + + g_variant_get(params, "(t@ay)", &id, &data); + + if (!a->have_stream || id != a->stream_id || !a->enabled) + return; + + gsize len = 0; + const guint8 *pcm = g_variant_get_fixed_array(data, &len, 1); + if (!len) + return; + + g_byte_array_append(a->pending, pcm, len); + + AVFrame *f; + while ((f = audio_take_frame(a))) { + if (sp_frame_fifo_push(a->fifo, f) < 0) { + /* Full: the encoder is behind. Dropping is right for audio - + * queueing would only add latency that never comes back. */ + a->dropped++; + av_frame_free(&f); + continue; + } + av_frame_free(&f); + a->frames++; + } +} + +static void audio_method(GDBusConnection *conn, const char *sender, + const char *path, const char *iface, + const char *method, GVariant *params, + GDBusMethodInvocation *inv, gpointer user_data) +{ + KqsAudio *a = user_data; + + if (!g_strcmp0(method, "Init")) { + audio_init_stream(a, params); + } else if (!g_strcmp0(method, "Write")) { + audio_write(a, params); + } else if (!g_strcmp0(method, "SetEnabled")) { + guint64 id; + gboolean enabled; + g_variant_get(params, "(tb)", &id, &enabled); + if (a->have_stream && id == a->stream_id) { + a->enabled = enabled; + g_message("audio %s", enabled ? "playing" : "stopped"); + /* Stale samples would be played at the wrong time on resume. */ + if (!enabled) + g_byte_array_set_size(a->pending, 0); + } + } else if (!g_strcmp0(method, "Fini")) { + guint64 id; + g_variant_get(params, "(t)", &id); + if (a->have_stream && id == a->stream_id) { + a->have_stream = FALSE; + a->enabled = FALSE; + g_byte_array_set_size(a->pending, 0); + } + } + /* SetVolume is accepted and ignored: the guest's mixer is the guest's. */ + + g_dbus_method_invocation_return_value(inv, NULL); +} + +static const GDBusInterfaceVTable audio_vtable = { + .method_call = audio_method, +}; + +/* -- lifecycle ----------------------------------------------------------- */ + +KqsAudio *kqs_audio_new(GDBusConnection *bus, const char *bus_name, + const char *kymux_uri, int bitrate_bps, + GError **error) +{ + KqsAudio *a = g_new0(KqsAudio, 1); + a->kymux_uri = g_strdup(kymux_uri); + a->bitrate_bps = bitrate_bps; + a->pending = g_byte_array_new(); + + /* See sink.c: tx_init() is not re-entrant, so this process serves audio + * and nothing else. avservice launches it separately from the video one. */ + a->tx = tx_new(); + if (!a->tx || tx_init(a->tx) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_init failed"); + kqs_audio_free(a); + return NULL; + } + + /* The pipeline waits for Init: only then is the guest's format known, + * and an encoder cannot be committed without a frame to configure from. */ + + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "socketpair: %s", g_strerror(errno)); + kqs_audio_free(a); + return NULL; + } + + g_autoptr(GUnixFDList) fds = g_unix_fd_list_new_from_array(&sv[1], 1); + g_autoptr(GVariant) reply = g_dbus_connection_call_with_unix_fd_list_sync( + bus, bus_name, "/org/qemu/Display1/Audio", + "org.qemu.Display1.Audio", "RegisterOutListener", + g_variant_new("(h)", 0), NULL, G_DBUS_CALL_FLAGS_NONE, -1, + fds, NULL, NULL, error); + + if (!reply) { + g_prefix_error(error, "RegisterOutListener failed: "); + close(sv[0]); + kqs_audio_free(a); + return NULL; + } + + g_autoptr(GSocket) sock = g_socket_new_from_fd(sv[0], error); + if (!sock) { + close(sv[0]); + kqs_audio_free(a); + return NULL; + } + g_autoptr(GSocketConnection) sconn = g_socket_connection_factory_create_connection(sock); + + /* QEMU is the authentication server on its end, as for the display. */ + a->peer = g_dbus_connection_new_sync( + G_IO_STREAM(sconn), NULL, + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT, + NULL, NULL, error); + if (!a->peer) { + kqs_audio_free(a); + return NULL; + } + + a->node = g_dbus_node_info_new_for_xml(kqs_audio_xml, error); + if (!a->node) { + kqs_audio_free(a); + return NULL; + } + + a->reg_id = g_dbus_connection_register_object( + a->peer, "/org/qemu/Display1/AudioOutListener", + a->node->interfaces[0], &audio_vtable, a, NULL, error); + if (!a->reg_id) { + kqs_audio_free(a); + return NULL; + } + + g_message("audio: registered with QEMU, waiting for a stream"); + return a; +} + +void kqs_audio_stats(const KqsAudio *a, uint64_t *frames, uint64_t *dropped) +{ + if (frames) *frames = a ? a->frames : 0; + if (dropped) *dropped = a ? a->dropped : 0; +} + +void kqs_audio_free(KqsAudio *a) +{ + if (!a) + return; + + if (a->fifo) + sp_frame_fifo_push(a->fifo, NULL); /* flush sentinel, as sink.c */ + + if (a->reg_id && a->peer) + g_dbus_connection_unregister_object(a->peer, a->reg_id); + g_clear_pointer(&a->node, g_dbus_node_info_unref); + g_clear_object(&a->peer); + + if (a->pending) + g_byte_array_free(a->pending, TRUE); + g_free(a->kymux_uri); + g_free(a); +} diff --git a/src/dmabuf.c b/src/dmabuf.c new file mode 100644 index 0000000..f7cc761 --- /dev/null +++ b/src/dmabuf.c @@ -0,0 +1,701 @@ +/* + * DMA-BUF import: QEMU scanout descriptors -> VAAPI surfaces. + * + * This is the path that makes the design worth building. QEMU hands over file + * descriptors for the guest's scanout buffer instead of pixels, and the + * encoder reads them where they already are. No CPU copy, no sws_scale. + * + * The mapping goes DRM_PRIME -> VAAPI in two steps because that is how + * libavutil models it: wrap the fds in an AVDRMFrameDescriptor, then + * av_hwframe_map() that into a VAAPI frames context. txproto's encoder then + * sees an ordinary hardware frame. + * + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kqs.h" + +void kqs_dmabuf_init(KqsDmabuf *d) +{ + memset(d, 0, sizeof(*d)); + for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++) + d->fds[i] = -1; +} + +void kqs_dmabuf_clear(KqsDmabuf *d) +{ + for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++) { + if (d->fds[i] >= 0) + close(d->fds[i]); + } + kqs_dmabuf_init(d); +} + +/* + * QEMU sends DRM fourccs. Only the formats a virtio-gpu scanout actually + * produces are listed; anything else should fail loudly rather than be + * silently misinterpreted as the wrong colour order. + */ +/* + * The alpha-less twin of a scanout fourcc, or the fourcc itself. + * + * A scanout is already composited, so its alpha channel carries nothing, and + * the conversion to NV12 discards it regardless - but VAAPI on this driver + * refuses to import an alpha-bearing buffer at all: + * + * Failed to create surface from DRM object: 2 (resource allocation failed) + * + * and then no frame is ever mapped, the encoder never commits, and the client + * sits on a frozen picture with nothing in the log to suggest a format + * problem. The guest picks the format per mode rather than per session - + * 1280x800 and 1920x1080 arrive as XRGB, 800x600 and 1280x768 as ARGB - so + * this presents as a resolution that randomly fails to stream. + * + * This has to be applied to the DRM descriptor, not just to the AVPixelFormat: + * libva imports by the fourcc in the descriptor, so declaring an alpha-less + * AVPixelFormat while still handing over an ARGB layer changes nothing. + */ +uint32_t kqs_fourcc_opaque(uint32_t fourcc) +{ + switch (fourcc) { + case DRM_FORMAT_ARGB8888: return DRM_FORMAT_XRGB8888; + case DRM_FORMAT_ABGR8888: return DRM_FORMAT_XBGR8888; + default: return fourcc; + } +} + +enum AVPixelFormat kqs_fourcc_to_av(uint32_t fourcc) +{ + switch (fourcc) { + /* + * Alpha is dropped on purpose. A scanout is already composited, so its + * alpha channel carries nothing, and it is discarded again by the + * conversion to NV12 - but asking VAAPI to import an alpha-bearing + * surface fails outright on this driver: + * + * Failed to create surface from DRM object: 2 (resource allocation + * failed) + * + * and then no frame is ever mapped, so the encoder never commits and the + * client sits on a frozen picture. The guest chooses between XRGB and + * ARGB per mode for reasons of its own - 1280x800 and 1920x1080 came + * through as XRGB, 800x600 and 1280x768 as ARGB - which is what made this + * look like a random failure rather than a format one. + */ + case DRM_FORMAT_XRGB8888: + case DRM_FORMAT_ARGB8888: return AV_PIX_FMT_BGR0; + case DRM_FORMAT_XBGR8888: + case DRM_FORMAT_ABGR8888: return AV_PIX_FMT_RGB0; + case DRM_FORMAT_NV12: return AV_PIX_FMT_NV12; + default: return AV_PIX_FMT_NONE; + } +} + +const char *kqs_fourcc_str(uint32_t f, char buf[5]) +{ + buf[0] = (char)(f & 0xff); + buf[1] = (char)((f >> 8) & 0xff); + buf[2] = (char)((f >> 16) & 0xff); + buf[3] = (char)((f >> 24) & 0xff); + buf[4] = '\0'; + return buf; +} + +struct KqsHwCtx { + AVBufferRef *drm_device; + AVBufferRef *vaapi_device; + AVBufferRef *drm_frames; /* rebuilt when geometry changes */ + AVBufferRef *vaapi_frames; + int frames_w, frames_h; + enum AVPixelFormat frames_sw; + + /* RGB -> NV12, on the GPU. Rebuilt alongside the frames contexts. */ + AVFilterGraph *graph; + AVFilterContext *gsrc; + AVFilterContext *gsink; + bool graph_vflip; + int graph_out_w, graph_out_h; + bool warned_linear; + bool warned_upload; +}; + +KqsHwCtx *kqs_hw_new(const char *render_node, GError **error) +{ + KqsHwCtx *h = g_new0(KqsHwCtx, 1); + int err; + + err = av_hwdevice_ctx_create(&h->drm_device, AV_HWDEVICE_TYPE_DRM, + render_node, NULL, 0); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "cannot open DRM device %s", render_node); + g_free(h); + return NULL; + } + + /* + * Deriving VAAPI from the same DRM device keeps both on one GPU, which is + * what makes the import free. Creating VAAPI independently can land on a + * different node and silently turn the map into a copy. + */ + err = av_hwdevice_ctx_create_derived(&h->vaapi_device, + AV_HWDEVICE_TYPE_VAAPI, + h->drm_device, 0); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "cannot derive VAAPI from %s", render_node); + av_buffer_unref(&h->drm_device); + g_free(h); + return NULL; + } + + return h; +} + +void kqs_hw_free(KqsHwCtx *h) +{ + if (!h) + return; + avfilter_graph_free(&h->graph); + av_buffer_unref(&h->drm_frames); + av_buffer_unref(&h->vaapi_frames); + av_buffer_unref(&h->vaapi_device); + av_buffer_unref(&h->drm_device); + g_free(h); +} + +AVBufferRef *kqs_hw_vaapi_device(KqsHwCtx *h) +{ + return h->vaapi_device; +} + +static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip, + int out_w, int out_h, GError **error); + +static bool hw_ensure_frames(KqsHwCtx *h, int w, int h_px, + enum AVPixelFormat sw, bool vflip, + int out_w, int out_h, GError **error) +{ + if (h->drm_frames && h->vaapi_frames && + h->frames_w == w && h->frames_h == h_px && h->frames_sw == sw) + return h->graph && h->graph_vflip == vflip && + h->graph_out_w == out_w && h->graph_out_h == out_h + ? true + : hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error); + + avfilter_graph_free(&h->graph); + av_buffer_unref(&h->drm_frames); + av_buffer_unref(&h->vaapi_frames); + + h->drm_frames = av_hwframe_ctx_alloc(h->drm_device); + if (!h->drm_frames) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames alloc"); + return false; + } + AVHWFramesContext *dfc = (AVHWFramesContext *)h->drm_frames->data; + dfc->format = AV_PIX_FMT_DRM_PRIME; + dfc->sw_format = sw; + dfc->width = w; + dfc->height = h_px; + if (av_hwframe_ctx_init(h->drm_frames) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames init"); + return false; + } + + /* + * VAAPI encoders want NV12; the scanout is packed RGB. Mapping cannot + * change the colour space, so the frames context is created with the + * scanout's own sw_format and the encoder converts on the GPU. + */ + h->vaapi_frames = av_hwframe_ctx_alloc(h->vaapi_device); + if (!h->vaapi_frames) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "VAAPI frames alloc"); + return false; + } + AVHWFramesContext *vfc = (AVHWFramesContext *)h->vaapi_frames->data; + vfc->format = AV_PIX_FMT_VAAPI; + vfc->sw_format = sw; + vfc->width = w; + vfc->height = h_px; + if (av_hwframe_ctx_init(h->vaapi_frames) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "VAAPI frames init (%s %dx%d)", + av_get_pix_fmt_name(sw), w, h_px); + return false; + } + + h->frames_w = w; + h->frames_h = h_px; + h->frames_sw = sw; + + /* The graph is bound to that frames context, so it goes with it. */ + return hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error); +} + +static void drm_desc_free(void *opaque, uint8_t *data) +{ + av_free(data); +} + +/* + * Wrap the descriptor QEMU sent as an AVFrame. The fds stay owned by the + * KqsDmabuf - libavutil does not close them here - so the caller must keep + * them alive until the returned frame is unreferenced. + */ +static AVFrame *drm_frame_from(KqsHwCtx *h, const KqsDmabuf *d, GError **error, uint64_t modifier) +{ + AVDRMFrameDescriptor *desc = av_mallocz(sizeof(*desc)); + if (!desc) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc alloc"); + return NULL; + } + + desc->nb_objects = d->n_fds; + for (int i = 0; i < d->n_fds; i++) { + desc->objects[i].fd = d->fds[i]; + desc->objects[i].size = 0; + desc->objects[i].format_modifier = modifier; + } + + desc->nb_layers = 1; + desc->layers[0].format = kqs_fourcc_opaque(d->fourcc); + desc->layers[0].nb_planes = d->num_planes; + for (int p = 0; p < d->num_planes; p++) { + /* One fd may back every plane, or each plane may have its own. */ + desc->layers[0].planes[p].object_index = (d->n_fds == 1) ? 0 : p; + desc->layers[0].planes[p].offset = d->offsets[p]; + desc->layers[0].planes[p].pitch = d->strides[p]; + } + + AVFrame *f = av_frame_alloc(); + if (!f) { + av_free(desc); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame alloc"); + return NULL; + } + + f->format = AV_PIX_FMT_DRM_PRIME; + f->width = d->backing_width; + f->height = d->backing_height; + f->data[0] = (uint8_t *)desc; + f->buf[0] = av_buffer_create((uint8_t *)desc, sizeof(*desc), + drm_desc_free, NULL, 0); + if (!f->buf[0]) { + av_free(desc); + av_frame_free(&f); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc buffer"); + return NULL; + } + + f->hw_frames_ctx = av_buffer_ref(h->drm_frames); + if (!f->hw_frames_ctx) { + av_frame_free(&f); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frames ref"); + return NULL; + } + + return f; +} + +/* + * A virtio-gpu scanout is packed RGB and h264_vaapi has no RGB input profile - + * it answers "Input surface format is rgba" and then "No usable encoding + * profile found". Something has to convert, and it has to stay on the GPU or + * the whole zero-copy path is pointless. + * + * Doing it here rather than as a stage in the txproto pipeline is deliberate. + * A filtergraph between the import and the encoder was tried and deadlocks: + * seeding the filter's input leaves the encoder with no frame to configure + * from, so tx_commit() blocks exactly as it does on an empty encoder FIFO, + * one stage deeper. Converting before the frame is ever handed over keeps the + * pipeline a plain encoder that can still be seeded directly. + */ +static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip, + int out_w, int out_h, GError **error) +{ + int err; + + avfilter_graph_free(&h->graph); + + h->graph = avfilter_graph_alloc(); + if (!h->graph) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter graph alloc"); + return false; + } + + /* + * Build the source in two steps rather than from an args string. The hw + * frames context can only be attached through AVBufferSrcParameters, and + * that has to happen before the filter is initialised - which rules out + * avfilter_graph_create_filter(), since it inits for you. + */ + h->gsrc = avfilter_graph_alloc_filter(h->graph, avfilter_get_by_name("buffer"), + "in"); + if (!h->gsrc) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc alloc"); + return false; + } + + av_opt_set_int(h->gsrc, "width", w, AV_OPT_SEARCH_CHILDREN); + av_opt_set_int(h->gsrc, "height", h_px, AV_OPT_SEARCH_CHILDREN); + av_opt_set(h->gsrc, "pix_fmt", av_get_pix_fmt_name(AV_PIX_FMT_VAAPI), + AV_OPT_SEARCH_CHILDREN); + av_opt_set(h->gsrc, "time_base", "1/1000000", AV_OPT_SEARCH_CHILDREN); + av_opt_set(h->gsrc, "pixel_aspect", "1/1", AV_OPT_SEARCH_CHILDREN); + + AVBufferSrcParameters *par = av_buffersrc_parameters_alloc(); + if (!par) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc params"); + return false; + } + par->format = AV_PIX_FMT_VAAPI; + par->width = w; + par->height = h_px; + par->hw_frames_ctx = h->vaapi_frames; + err = av_buffersrc_parameters_set(h->gsrc, par); + av_freep(&par); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "buffersrc params set (%d)", err); + return false; + } + + err = avfilter_init_str(h->gsrc, NULL); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "buffersrc init (%d)", err); + return false; + } + + /* + * Scale to the encoder's geometry, not the scanout's. The encoder is built + * once per session and cannot be resized mid-stream, so this is what has + * to absorb a guest that changes resolution - exactly what swscale does on + * the CPU path. + * + * Leaving w/h unset passes the scanout size straight through, and the + * encoder then quietly emits nothing usable: OVMF hands over at 640x480, + * the guest comes up at 1280x800, and the client is black while every log + * line still reports a healthy pipeline. + */ + g_autofree char *scale_arg = + g_strdup_printf("w=%d:h=%d:format=nv12", out_w, out_h); + + AVFilterContext *scale = NULL; + err = avfilter_graph_create_filter(&scale, + avfilter_get_by_name("scale_vaapi"), + "nv12", scale_arg, NULL, h->graph); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "scale_vaapi create (%d) - is FFmpeg built with VAAPI " + "filters?", err); + return false; + } + + err = avfilter_graph_create_filter(&h->gsink, + avfilter_get_by_name("buffersink"), + "out", NULL, NULL, h->graph); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersink create"); + return false; + } + + /* + * A virgl scanout has its origin at the bottom left, the way OpenGL does, + * and QEMU says so per scanout with y0_top. Video wants the top row first, + * so an unflipped GL guest arrives upside down. transpose_vaapi does it as + * part of the same VPP pass, so it stays on the GPU and costs nothing + * measurable. + */ + /* + * vflip is the correction, and the caller decides when to ask for it - + * see the note on y0_top there. KQS_DMABUF_FLIP overrides the direction, + * and "none" disables it for the vhost-user helper, whose buffers arrive + * upright with the flag clear. + */ + const char *dir = g_getenv("KQS_DMABUF_FLIP"); + if (!dir || !*dir) + dir = "vflip"; + + AVFilterContext *flip = NULL; + if (vflip && g_strcmp0(dir, "none") != 0) { + g_autofree char *dir_arg = g_strdup_printf("dir=%s", dir); + + err = avfilter_graph_create_filter(&flip, + avfilter_get_by_name("transpose_vaapi"), + "flip", dir_arg, NULL, h->graph); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "transpose_vaapi create (%d)", err); + return false; + } + } + + if (avfilter_link(h->gsrc, 0, scale, 0) < 0 || + (flip && avfilter_link(scale, 0, flip, 0) < 0) || + avfilter_link(flip ? flip : scale, 0, h->gsink, 0) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter link"); + return false; + } + + /* + * scale_vaapi allocates its output frames from a context derived from the + * input's device, so the device reference has to be visible to the graph + * before configuration. + */ + h->graph->filters[0]->hw_device_ctx = av_buffer_ref(h->vaapi_device); + + err = avfilter_graph_config(h->graph, NULL); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "filter graph config (%d)", err); + return false; + } + + h->graph_vflip = vflip; + h->graph_out_w = out_w; + h->graph_out_h = out_h; + return true; +} + +/* Push one imported RGB surface through the graph, get NV12 back. */ +static AVFrame *hw_to_nv12(KqsHwCtx *h, AVFrame *va, GError **error) +{ + int err = av_buffersrc_add_frame_flags(h->gsrc, va, + AV_BUFFERSRC_FLAG_KEEP_REF); + av_frame_free(&va); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "buffersrc push failed (%d)", err); + return NULL; + } + + AVFrame *out = av_frame_alloc(); + if (!out) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "nv12 frame alloc"); + return NULL; + } + + err = av_buffersink_get_frame(h->gsink, out); + if (err < 0) { + av_frame_free(&out); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "buffersink pull failed (%d)", err); + return NULL; + } + + return out; +} + +/* + * What to do when the driver will not import the buffer at all. + * + * radeonsi needs a 256-byte aligned pitch to import a linear surface, and a + * scanout's pitch is exactly width * 4, so 800x600 (3200) and 1360x768 (5440) + * are refused however they are labelled. Mapping the memory and uploading it + * costs a copy per frame, which is the whole point of this path - but a mode + * that encodes slowly beats a mode that never appears. + * + * The upload reads straight out of the mapping: the sw frame borrows those + * pointers rather than owning a second buffer, so there is one copy in total, + * the one av_hwframe_transfer_data makes into the surface. + */ +static AVFrame *upload_from_map(KqsHwCtx *h, const KqsDmabuf *d, + enum AVPixelFormat sw, GError **error) +{ + void *maps[KQS_DMABUF_MAX_PLANES] = { NULL }; + size_t lens[KQS_DMABUF_MAX_PLANES] = { 0 }; + AVFrame *cpu = NULL, *va = NULL; + + for (int p = 0; p < d->num_planes; p++) { + int obj = (d->n_fds == 1) ? 0 : p; + size_t need = (size_t)d->offsets[p] + + (size_t)d->strides[p] * (size_t)d->backing_height; + if (need > lens[obj]) + lens[obj] = need; + } + + for (int i = 0; i < d->n_fds; i++) { + if (!lens[i]) + continue; + maps[i] = mmap(NULL, lens[i], PROT_READ, MAP_SHARED, d->fds[i], 0); + if (maps[i] == MAP_FAILED) { + maps[i] = NULL; + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "mmap of the scanout dmabuf failed: %s", g_strerror(errno)); + goto out; + } + /* The GPU wrote it; ask for a coherent read before touching it. */ + struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ }; + ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync); + } + + cpu = av_frame_alloc(); + va = av_frame_alloc(); + if (!cpu || !va) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame alloc"); + goto out; + } + + cpu->format = sw; + cpu->width = d->backing_width; + cpu->height = d->backing_height; + for (int p = 0; p < d->num_planes; p++) { + int obj = (d->n_fds == 1) ? 0 : p; + cpu->data[p] = (uint8_t *)maps[obj] + d->offsets[p]; + cpu->linesize[p] = (int)d->strides[p]; + } + + if (av_hwframe_get_buffer(h->vaapi_frames, va, 0) < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "VAAPI surface alloc"); + goto out; + } + + int err = av_hwframe_transfer_data(va, cpu, 0); + if (err < 0) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "upload to VAAPI failed (%dx%d, pitch %u)", + d->backing_width, d->backing_height, d->strides[0]); + goto out; + } + + if (!h->warned_upload) { + h->warned_upload = true; + g_message("dmabuf: pitch %u is not 256-byte aligned, uploading instead " + "of mapping - this mode costs a copy per frame", + d->strides[0]); + } + + av_frame_free(&cpu); + for (int i = 0; i < d->n_fds; i++) { + if (!maps[i]) + continue; + struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ }; + ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync); + munmap(maps[i], lens[i]); + } + return va; + +out: + av_frame_free(&cpu); + av_frame_free(&va); + for (int i = 0; i < d->n_fds; i++) { + if (!maps[i]) + continue; + struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ }; + ioctl(d->fds[i], DMA_BUF_IOCTL_SYNC, &sync); + munmap(maps[i], lens[i]); + } + return NULL; +} + +AVFrame *kqs_dmabuf_to_vaapi(KqsHwCtx *h, const KqsDmabuf *d, + int out_w, int out_h, GError **error) +{ + enum AVPixelFormat sw = kqs_fourcc_to_av(d->fourcc); + char fcc[5]; + + if (sw == AV_PIX_FMT_NONE) { + g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, + "unsupported scanout fourcc '%s' (0x%08x)", + kqs_fourcc_str(d->fourcc, fcc), d->fourcc); + return NULL; + } + + /* + * Measured, not deduced: on the in-process virgl path the scanouts that + * arrive upside down are exactly the ones QEMU marks y0_top - the ARGB + * buffers virgl renders, from plymouth onward. GRUB and the early kernel + * come through XRGB with y0_top false and are already upright, so the + * flag reads inverted here against its documented meaning. + */ + if (!hw_ensure_frames(h, d->backing_width, d->backing_height, sw, + d->y0_top, out_w, out_h, error)) + return NULL; + + AVFrame *drm = drm_frame_from(h, d, error, d->modifier); + if (!drm) + return NULL; + + AVFrame *va = av_frame_alloc(); + if (!va) { + av_frame_free(&drm); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc"); + return NULL; + } + + va->format = AV_PIX_FMT_VAAPI; + va->width = d->backing_width; + va->height = d->backing_height; + va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames); + + int err = av_hwframe_map(va, drm, AV_HWFRAME_MAP_READ); + av_frame_free(&drm); + + /* + * DRM_FORMAT_MOD_INVALID means "no modifier was negotiated", so the driver + * has to guess the layout, and on AMD it guesses a tiled render target. + * These scanouts are linear - the stride is exactly width * 4 - so when + * the guess loses, say so explicitly and try once more. + * + * This only ever runs after a failure, so a buffer that imports on the + * driver's own terms keeps taking the original path untouched. + */ + if (err < 0 && d->modifier == DRM_FORMAT_MOD_INVALID) { + av_frame_free(&va); + + AVFrame *lin = drm_frame_from(h, d, error, DRM_FORMAT_MOD_LINEAR); + if (!lin) + return NULL; + + va = av_frame_alloc(); + if (!va) { + av_frame_free(&lin); + g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc"); + return NULL; + } + va->format = AV_PIX_FMT_VAAPI; + va->width = d->backing_width; + va->height = d->backing_height; + va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames); + + err = av_hwframe_map(va, lin, AV_HWFRAME_MAP_READ); + av_frame_free(&lin); + + if (err >= 0 && !h->warned_linear) { + h->warned_linear = true; + g_message("dmabuf: import needed an explicit linear modifier"); + } + } + + if (err < 0) { + av_frame_free(&va); + + va = upload_from_map(h, d, sw, error); + if (!va) { + g_prefix_error(error, + "av_hwframe_map DRM_PRIME->VAAPI failed " + "(fourcc '%s', modifier 0x%" G_GINT64_MODIFIER "x) " + "and the upload fallback did not work either: ", + kqs_fourcc_str(d->fourcc, fcc), + (uint64_t)d->modifier); + return NULL; + } + } + + /* The encoder wants NV12; hand it NV12 rather than what the guest drew. */ + return hw_to_nv12(h, va, error); +} diff --git a/src/kqs.h b/src/kqs.h new file mode 100644 index 0000000..df945fc --- /dev/null +++ b/src/kqs.h @@ -0,0 +1,194 @@ +/* + * kyber-qemu-server - route a QEMU guest display into a txproto pipeline + * + * Copyright (C) 2026 Alexandre Derumier + * SPDX-License-Identifier: LGPL-2.1-or-later + * + * LGPL-2.1+ to match libtxproto, which this links against. Kyber's AGPL + * service layer stays in other processes; see the design note. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#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 +#include +#include + +#include + +#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[] = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +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 +#include + +#include + +#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 +#include +#include + +#include +#include + +#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 + +#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