diff --git a/.gitignore b/.gitignore index 75836d1..04039d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target/ +target/ opencode.json AGENTS.md .DS_Store \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d1c6a1..2ddaf7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,10 +34,27 @@ As such, while my CPU, GPU, and RAM are all quite spec'd up, the VM is not a per I develop Hearthspace on Ubuntu 26.04 LTS, and the following packages are required to build and run the compositor and shell: ```sh -sudo apt-get install -y build-essential cargo rustc rustfmt pkg-config clang libclang-dev libwayland-dev wayland-protocols wayland-utils libinput-dev libxkbcommon-dev libxkbcommon-x11-dev libudev-dev libseat-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev libdrm-dev libsystemd-dev foot +sudo apt-get install -y build-essential cargo rustc rustfmt pkg-config clang libclang-dev libwayland-dev wayland-protocols wayland-utils libinput-dev libxkbcommon-dev libxkbcommon-x11-dev libudev-dev libseat-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev libdrm-dev libsystemd-dev ``` -`foot` is installed as a small Wayland-native terminal for server-side decoration testing. +#### E2E Testing Dependencies + +For E2E testing, the WayDriver adapter depends on the published `waydriver` crate, which +links GStreamer even when the Hearthspace backend overrides screenshot capture. +Install the development packages before building tests that include +`waydriver-hearthspace`: + +```sh +sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev +``` + +### Optional Dependencies + +For testing, `foot` is installed as a small Wayland-native terminal for server-side decoration testing. + +``` +sudo apt-get install -y foot +``` ### Optional Test Apps @@ -114,6 +131,18 @@ Keyboard commands currently take Linux evdev key codes, not XKB keysyms. Pointer button commands use Linux input button codes, for example `272` (`0x110`) for the left mouse button. +The WayDriver backend adapter lives in `crates/waydriver-hearthspace` and uses +the published `waydriver` crate. Its ignored smoke tests can be run with: + +```sh +cargo test --test waydriver_hearthspace -- --ignored +cargo test --features test-apps --test waydriver_hearthspace -- --ignored +``` + +The full WayDriver `Session` XPath test is compiled with `test-apps` but only +exercises AT-SPI when `HEARTHSPACE_REQUIRE_ATSPI=1` is set, because the VM can +render the GTK test client without exposing it as an AT-SPI application root. + ### Xilem Fork (git dependency) The shell UI is built with [Xilem](https://github.com/linebender/xilem). Stock diff --git a/Cargo.lock b/Cargo.lock index 47bc9f5..aea04e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,8 +111,8 @@ dependencies = [ "accesskit_consumer 0.35.0", "hashbrown 0.16.1", "static_assertions", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -157,6 +157,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -206,6 +224,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "anymap3" version = "1.1.0" @@ -233,6 +257,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -251,6 +292,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -403,6 +453,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + [[package]] name = "atspi" version = "0.29.0" @@ -410,6 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ "atspi-common 0.13.0", + "atspi-connection 0.13.0", "atspi-proxies 0.13.0", ] @@ -420,7 +477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0" dependencies = [ "atspi-common 0.14.0", - "atspi-connection", + "atspi-connection 0.14.0", "atspi-proxies 0.14.0", ] @@ -456,6 +513,18 @@ dependencies = [ "zvariant", ] +[[package]] +name = "atspi-connection" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b28a59b7c709ce0f1196405e6eacee35424dad07b4b005c0f9e417d8c2116f" +dependencies = [ + "atspi-common 0.13.0", + "atspi-proxies 0.13.0", + "futures-lite", + "zbus", +] + [[package]] name = "atspi-connection" version = "0.14.0" @@ -496,6 +565,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.22.1" @@ -517,6 +629,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -529,6 +647,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.1.1" @@ -578,6 +705,12 @@ dependencies = [ "piper", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -900,6 +1033,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1003,6 +1155,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "endi" version = "1.1.1" @@ -1030,6 +1188,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1082,12 +1260,33 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1202,8 +1401,8 @@ dependencies = [ "read-fonts", "roxmltree 0.21.1", "smallvec", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", "yeslogic-fontconfig-sys", ] @@ -1307,6 +1506,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -1406,7 +1611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1575,7 +1780,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.61.3", + "windows", ] [[package]] @@ -1652,6 +1857,99 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gstreamer" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ca0c594cac4e86f5444aaa767c7bb810340c0710667a6467d3ead248e35e84" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib", + "gstreamer-sys", + "itertools", + "kstring", + "libc", + "muldiv", + "num-integer", + "num-rational", + "option-operations", + "pastey 0.2.3", + "pin-project-lite", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-app" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f8ae9238c2352398dcc084de28df3f7099af216ac6c160b52318d23f25c010" +dependencies = [ + "futures-core", + "futures-sink", + "glib", + "gstreamer", + "gstreamer-app-sys", + "gstreamer-base", + "libc", +] + +[[package]] +name = "gstreamer-app-sys" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a74a8211e5d7df2f45b612c284ddf56b92bdf4e879e8ed72e7c46dd0842e158" +dependencies = [ + "glib-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-base" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c279df2918be97fb9570e589a32ade33598f643b0c4f0c92c17f06be6940574e" +dependencies = [ + "atomic_refcell", + "cfg-if", + "glib", + "gstreamer", + "gstreamer-base-sys", + "libc", +] + +[[package]] +name = "gstreamer-base-sys" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6569606feeb89cfcf95a6476a64a0f0aec83fadcef0e91c24e576f7851ceac3a" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-sys" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533fa8d28fc830eafccbcfcfddb390563ea5d3a351af2c3aab99e197e5f5b1ba" +dependencies = [ + "cfg-if", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "gtk4" version = "0.11.3" @@ -1784,7 +2082,11 @@ dependencies = [ "rstest", "smithay", "tempfile", + "tokio", + "tokio-util", "tracing-subscriber", + "waydriver", + "waydriver-hearthspace", "wayland-protocols", "wayland-server", "xilem", @@ -1949,9 +2251,20 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2016,6 +2329,12 @@ dependencies = [ "wgpu", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "2.14.0" @@ -2026,6 +2345,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "jni" version = "0.22.4" @@ -2040,7 +2379,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2132,6 +2471,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2150,6 +2498,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" @@ -2174,6 +2528,16 @@ dependencies = [ "libdeflate-sys", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2181,7 +2545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2256,6 +2620,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2357,6 +2730,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2427,6 +2810,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + [[package]] name = "naga" version = "28.0.0" @@ -2483,6 +2872,36 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2492,12 +2911,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2786,6 +3246,15 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-operations" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754" +dependencies = [ + "pastey 0.2.3", +] + [[package]] name = "orbclient" version = "0.3.55" @@ -2888,7 +3357,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2931,6 +3400,18 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "peniko" version = "0.6.1" @@ -2950,6 +3431,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + [[package]] name = "phf" version = "0.13.1" @@ -3199,6 +3686,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3292,12 +3788,82 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -3672,6 +4238,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3890,6 +4465,27 @@ dependencies = [ "siphasher", ] +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error 1.2.3", + "sxd-document", +] + [[package]] name = "syn" version = "2.0.118" @@ -4008,6 +4604,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.51" @@ -4121,9 +4731,12 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4151,6 +4764,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4307,6 +4933,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-arena" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" + [[package]] name = "typenum" version = "1.20.1" @@ -4452,6 +5084,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4644,6 +5287,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "waydriver" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854c98e78696424ad6d61d8e3979e47c2ffd6e357c793c607cddde8e78c90eb4" +dependencies = [ + "async-trait", + "atspi 0.29.0", + "gstreamer", + "gstreamer-app", + "image", + "libc", + "sxd-document", + "sxd-xpath", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "zbus", +] + +[[package]] +name = "waydriver-hearthspace" +version = "0.1.0" +dependencies = [ + "async-trait", + "tempfile", + "tokio", + "tokio-util", + "uuid", + "waydriver", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -4964,8 +5640,8 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -4990,38 +5666,16 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -5030,20 +5684,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -5054,20 +5695,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -5076,9 +5706,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -5103,45 +5733,20 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -5150,16 +5755,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -5168,7 +5764,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5195,7 +5791,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5214,22 +5810,13 @@ dependencies = [ "windows_x86_64_msvc", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5489,6 +6076,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" @@ -5702,6 +6295,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index 19fc301..9b62aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,10 @@ wayland-server = "0.31.9" proptest = "1" rstest = "0.26" tempfile = "3" +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } +tokio-util = "0.7" +waydriver = "0.3.9" +waydriver-hearthspace = { path = "crates/waydriver-hearthspace" } # Workaround for an upstream incompatibility pulled in via Xilem/Masonry: # `vello_shaders` 0.8's build script depends on `naga` 28 with the `wgsl-in` diff --git a/crates/waydriver-hearthspace/Cargo.toml b/crates/waydriver-hearthspace/Cargo.toml new file mode 100644 index 0000000..b34ab4f --- /dev/null +++ b/crates/waydriver-hearthspace/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "waydriver-hearthspace" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +async-trait = "0.1" +tempfile = "3" +tokio = { version = "1", features = ["io-util", "net", "process", "time"] } +tokio-util = "0.7" +uuid = { version = "1", features = ["v4"] } +waydriver = "0.3.9" diff --git a/crates/waydriver-hearthspace/src/lib.rs b/crates/waydriver-hearthspace/src/lib.rs new file mode 100644 index 0000000..c796a5a --- /dev/null +++ b/crates/waydriver-hearthspace/src/lib.rs @@ -0,0 +1,522 @@ +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixStream, + process::{Child, Command}, +}; +use tokio_util::sync::CancellationToken; +use waydriver::{ + CaptureBackend, CompositorRuntime, Error, InputBackend, PipeWireStream, PointerAxis, + PointerButton, Result, StreamToken, backend::cancellable_tail, +}; + +const WAYLAND_DISPLAY: &str = "wayland-99"; +const COMMAND_SOCKET: &str = "hearthspace-shell.sock"; +const DEFAULT_RESOLUTION: &str = "1280x720"; +const INPUT_TAIL_DELAY: Duration = Duration::from_millis(30); +const PRESS_RELEASE_DELAY: Duration = Duration::from_millis(20); + +#[derive(Debug)] +pub struct HearthspaceState { + id: String, + runtime_dir: PathBuf, +} + +impl HearthspaceState { + pub fn id(&self) -> &str { + &self.id + } + + pub fn runtime_dir(&self) -> &Path { + &self.runtime_dir + } + + pub fn command_socket_path(&self) -> PathBuf { + self.runtime_dir.join(COMMAND_SOCKET) + } +} + +pub struct HearthspaceCompositor { + id: String, + binary: PathBuf, + runtime_dir: TempDir, + child: Option, + state: Option>, +} + +impl HearthspaceCompositor { + pub fn new(binary: impl Into) -> Result { + let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); + let runtime_dir = tempfile::Builder::new() + .prefix(&format!("wd-hearthspace-{id}-")) + .tempdir() + .map_err(|e| Error::process_with("create runtime dir", e))?; + + Ok(Self { + id, + binary: binary.into(), + runtime_dir, + child: None, + state: None, + }) + } + + pub fn state(&self) -> Result> { + self.state + .clone() + .ok_or_else(|| Error::process("Hearthspace compositor has not been started")) + } + + fn command_socket_path(&self) -> PathBuf { + self.runtime_dir.path().join(COMMAND_SOCKET) + } +} + +#[async_trait] +impl CompositorRuntime for HearthspaceCompositor { + async fn start(&mut self, resolution: Option<&str>, scale: Option) -> Result<()> { + if self.child.is_some() { + return Ok(()); + } + + let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION); + validate_resolution(resolution)?; + let scale = scale_to_integer(scale)?; + + let mut command = Command::new(&self.binary); + command + .args(["--headless", "--no-shell", "--headless-size", resolution]) + .env("XDG_RUNTIME_DIR", self.runtime_dir.path()) + .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + if let Some(scale) = scale { + command.args(["--headless-scale", &scale.to_string()]); + } + + let child = command + .spawn() + .map_err(|e| Error::process_with("spawn hearthspace", e))?; + self.child = Some(child); + + let command_socket_path = self.command_socket_path(); + wait_for_socket(self.child.as_mut(), &command_socket_path).await?; + self.state = Some(Arc::new(HearthspaceState { + id: self.id.clone(), + runtime_dir: self.runtime_dir.path().to_path_buf(), + })); + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + let Some(mut child) = self.child.take() else { + return Ok(()); + }; + + if child + .try_wait() + .map_err(|e| Error::process_with("poll hearthspace", e))? + .is_none() + { + let _ = send_text_command(&self.command_socket_path(), "quit").await; + if tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .is_err() + { + child + .kill() + .await + .map_err(|e| Error::process_with("kill hearthspace", e))?; + let _ = child.wait().await; + } + } + + self.state = None; + Ok(()) + } + + fn id(&self) -> &str { + &self.id + } + + fn wayland_display(&self) -> &str { + WAYLAND_DISPLAY + } + + fn runtime_dir(&self) -> &Path { + self.runtime_dir.path() + } +} + +pub struct HearthspaceInput { + state: Arc, +} + +impl HearthspaceInput { + pub fn new(state: Arc) -> Self { + Self { state } + } + + async fn send(&self, command: impl AsRef) -> Result<()> { + send_text_command(&self.state.command_socket_path(), command.as_ref()).await?; + Ok(()) + } +} + +#[async_trait] +impl InputBackend for HearthspaceInput { + async fn press_keysym(&self, keysym: u32, cancel: &CancellationToken) -> Result<()> { + self.key_down(keysym, cancel).await?; + tokio::time::sleep(PRESS_RELEASE_DELAY).await; + self.key_up(keysym, cancel).await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } + + async fn key_down(&self, keysym: u32, _cancel: &CancellationToken) -> Result<()> { + let keycode = keysym_to_evdev(keysym)?; + self.send(format!("key-down {keycode}")).await + } + + async fn key_up(&self, keysym: u32, _cancel: &CancellationToken) -> Result<()> { + let keycode = keysym_to_evdev(keysym)?; + self.send(format!("key-up {keycode}")).await + } + + async fn pointer_motion_relative( + &self, + dx: f64, + dy: f64, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-motion-rel {dx} {dy}")).await + } + + async fn pointer_motion_absolute( + &self, + x: f64, + y: f64, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-motion-abs {x} {y}")).await + } + + async fn pointer_button_down( + &self, + button: PointerButton, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-button-down {}", button.evdev_code())) + .await + } + + async fn pointer_button_up( + &self, + button: PointerButton, + cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-button-up {}", button.evdev_code())) + .await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } + + async fn pointer_axis_discrete( + &self, + axis: PointerAxis, + steps: i32, + cancel: &CancellationToken, + ) -> Result<()> { + let (horizontal, vertical) = match axis { + PointerAxis::Vertical => (0, steps * 120), + PointerAxis::Horizontal => (steps * 120, 0), + }; + self.send(format!("axis {horizontal} {vertical}")).await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } +} + +pub struct HearthspaceCapture { + state: Arc, +} + +impl HearthspaceCapture { + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait] +impl CaptureBackend for HearthspaceCapture { + async fn start_stream(&self) -> Result { + Ok(PipeWireStream { + node_id: 0, + token: StreamToken::new(()), + }) + } + + async fn stop_stream(&self, _stream: PipeWireStream) -> Result<()> { + Ok(()) + } + + fn pipewire_socket(&self) -> PathBuf { + self.state.runtime_dir().join("pipewire-0") + } + + async fn grab_screenshot(&self, _stream: &PipeWireStream) -> Result> { + take_screenshot(&self.state.command_socket_path()).await + } +} + +async fn wait_for_socket(child: Option<&mut Child>, path: &Path) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut child = child; + let mut last_error = None; + + while tokio::time::Instant::now() < deadline { + if let Some(child) = child.as_deref_mut() { + if let Some(status) = child + .try_wait() + .map_err(|e| Error::process_with("poll hearthspace", e))? + { + return Err(Error::process(format!( + "hearthspace exited before accepting commands: {status}" + ))); + } + } + + match UnixStream::connect(path).await { + Ok(_) => return Ok(()), + Err(error) => last_error = Some(error), + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Err(Error::Timeout(format!( + "timed out connecting to {}: {:?}", + path.display(), + last_error + ))) +} + +async fn send_text_command(path: &Path, command: &str) -> Result { + let mut stream = UnixStream::connect(path) + .await + .map_err(|e| Error::process_with("connect command socket", e))?; + stream + .write_all(format!("{command}\n").as_bytes()) + .await + .map_err(|e| Error::process_with("write command", e))?; + + let line = read_line(&mut stream).await?; + if line == "ok\n" || line.starts_with("ok ") { + Ok(line) + } else if let Some(message) = line.strip_prefix("err ") { + Err(Error::process(message.trim_end().to_string())) + } else { + Err(Error::process(format!("unexpected reply {line:?}"))) + } +} + +async fn take_screenshot(path: &Path) -> Result> { + let mut stream = UnixStream::connect(path) + .await + .map_err(|e| Error::screenshot_with("connect command socket", e))?; + stream + .write_all(b"screenshot\n") + .await + .map_err(|e| Error::screenshot_with("write command", e))?; + + let header = read_line(&mut stream).await?; + let mut parts = header.split_whitespace(); + match parts.next() { + Some("ok") => {} + Some("err") => { + let message = parts.collect::>().join(" "); + return Err(Error::screenshot(message)); + } + _ => return Err(Error::screenshot(format!("unexpected reply {header:?}"))), + } + let len = parts + .next() + .ok_or_else(|| Error::screenshot("missing screenshot byte length"))? + .parse::() + .map_err(|e| Error::screenshot_with("parse screenshot byte length", e))?; + if parts.next().is_some() { + return Err(Error::screenshot(format!("unexpected reply {header:?}"))); + } + + let mut payload = vec![0; len]; + stream + .read_exact(&mut payload) + .await + .map_err(|e| Error::screenshot_with("read screenshot payload", e))?; + Ok(payload) +} + +async fn read_line(stream: &mut UnixStream) -> Result { + let mut bytes = Vec::new(); + loop { + let mut byte = [0]; + stream + .read_exact(&mut byte) + .await + .map_err(|e| Error::process_with("read reply", e))?; + bytes.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + String::from_utf8(bytes).map_err(|e| Error::process_with("decode reply", e)) +} + +fn validate_resolution(resolution: &str) -> Result<()> { + let Some((width, height)) = resolution + .split_once('x') + .or_else(|| resolution.split_once('X')) + else { + return Err(Error::process(format!( + "invalid resolution {resolution:?}; expected WIDTHxHEIGHT" + ))); + }; + for (label, value) in [("width", width), ("height", height)] { + let value = value + .parse::() + .map_err(|e| Error::process_with(format!("parse {label}"), e))?; + if value <= 0 { + return Err(Error::process(format!("{label} must be positive"))); + } + } + Ok(()) +} + +fn scale_to_integer(scale: Option) -> Result> { + let Some(scale) = scale else { + return Ok(None); + }; + if !scale.is_finite() || scale <= 0.0 { + return Err(Error::process("scale must be a positive finite number")); + } + let rounded = scale.round(); + if (scale - rounded).abs() > f64::EPSILON { + return Err(Error::process(format!( + "Hearthspace headless scale must be an integer, got {scale}" + ))); + } + let scale = + i32::try_from(rounded as i64).map_err(|e| Error::process_with("convert scale", e))?; + Ok((scale != 1).then_some(scale)) +} + +fn keysym_to_evdev(keysym: u32) -> Result { + if let Ok(byte) = u8::try_from(keysym) { + if let Some(keycode) = ascii_keysym_to_evdev(byte) { + return Ok(keycode); + } + } + + let keycode = match keysym { + 0xff08 => 14, // BackSpace + 0xff09 => 15, // Tab + 0xff0d => 28, // Return + 0xff1b => 1, // Escape + 0xffe1 => 42, // Shift_L + 0xffe2 => 54, // Shift_R + 0xffe3 => 29, // Control_L + 0xffe4 => 97, // Control_R + 0xffe9 => 56, // Alt_L + 0xffea => 100, // Alt_R + _ => { + return Err(Error::process(format!( + "unsupported keysym 0x{keysym:x}; add a keysym-to-evdev mapping" + ))); + } + }; + Ok(keycode) +} + +fn ascii_keysym_to_evdev(keysym: u8) -> Option { + Some(match keysym.to_ascii_lowercase() { + b'a' => 30, + b'b' => 48, + b'c' => 46, + b'd' => 32, + b'e' => 18, + b'f' => 33, + b'g' => 34, + b'h' => 35, + b'i' => 23, + b'j' => 36, + b'k' => 37, + b'l' => 38, + b'm' => 50, + b'n' => 49, + b'o' => 24, + b'p' => 25, + b'q' => 16, + b'r' => 19, + b's' => 31, + b't' => 20, + b'u' => 22, + b'v' => 47, + b'w' => 17, + b'x' => 45, + b'y' => 21, + b'z' => 44, + b'1' => 2, + b'2' => 3, + b'3' => 4, + b'4' => 5, + b'5' => 6, + b'6' => 7, + b'7' => 8, + b'8' => 9, + b'9' => 10, + b'0' => 11, + b' ' => 57, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scale_must_be_integer() { + assert_eq!(scale_to_integer(None).unwrap(), None); + assert_eq!(scale_to_integer(Some(1.0)).unwrap(), None); + assert_eq!(scale_to_integer(Some(2.0)).unwrap(), Some(2)); + assert!(scale_to_integer(Some(1.5)).is_err()); + } + + #[test] + fn maps_common_keysyms_to_evdev() { + assert_eq!(keysym_to_evdev(b'a' as u32).unwrap(), 30); + assert_eq!(keysym_to_evdev(b'A' as u32).unwrap(), 30); + assert_eq!(keysym_to_evdev(0xff0d).unwrap(), 28); + assert!(keysym_to_evdev(0x2603).is_err()); + } + + #[test] + fn validates_resolution_shape() { + assert!(validate_resolution("800x600").is_ok()); + assert!(validate_resolution("800X600").is_ok()); + assert!(validate_resolution("800").is_err()); + assert!(validate_resolution("0x600").is_err()); + } +} diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs new file mode 100644 index 0000000..e4013d4 --- /dev/null +++ b/tests/waydriver_hearthspace.rs @@ -0,0 +1,118 @@ +use std::{path::PathBuf, sync::Arc}; + +use tokio_util::sync::CancellationToken; +use waydriver::{CaptureBackend, CompositorRuntime, InputBackend, PointerAxis, PointerButton}; +#[cfg(feature = "test-apps")] +use waydriver::{Session, SessionConfig}; +use waydriver_hearthspace::{HearthspaceCapture, HearthspaceCompositor, HearthspaceInput}; + +static WAYDRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[tokio::test] +#[ignore = "requires surfaceless EGL and the headless compositor socket"] +async fn waydriver_backends_drive_input_capture_and_teardown() { + let _guard = WAYDRIVER_TEST_LOCK.lock().await; + let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); + compositor.start(Some("320x240"), Some(1.0)).await.unwrap(); + + let state = compositor.state().unwrap(); + let input = HearthspaceInput::new(Arc::clone(&state)); + let capture = HearthspaceCapture::new(state); + let cancel = CancellationToken::new(); + + input + .pointer_motion_absolute(10.0, 10.0, &cancel) + .await + .unwrap(); + input + .pointer_motion_relative(5.0, 5.0, &cancel) + .await + .unwrap(); + input + .pointer_button(PointerButton::Left, &cancel) + .await + .unwrap(); + input + .pointer_axis_discrete(PointerAxis::Vertical, -1, &cancel) + .await + .unwrap(); + input.press_keysym(b'a' as u32, &cancel).await.unwrap(); + + let stream = capture.start_stream().await.unwrap(); + let screenshot = capture.grab_screenshot(&stream).await.unwrap(); + assert!(screenshot.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(png_dimensions(&screenshot), (320, 240)); + capture.stop_stream(stream).await.unwrap(); + + compositor.stop().await.unwrap(); +} + +#[cfg(feature = "test-apps")] +#[tokio::test] +#[ignore = "requires AT-SPI exposure for the GTK test client under headless Hearthspace"] +async fn waydriver_session_locates_real_client_by_xpath() { + let _guard = WAYDRIVER_TEST_LOCK.lock().await; + if std::env::var_os("HEARTHSPACE_REQUIRE_ATSPI").is_none() { + eprintln!( + "skipping full WayDriver Session XPath test; set HEARTHSPACE_REQUIRE_ATSPI=1 to enable" + ); + return; + } + + let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); + compositor.start(Some("800x600"), Some(1.0)).await.unwrap(); + + let state = compositor.state().unwrap(); + let input = Box::new(HearthspaceInput::new(Arc::clone(&state))); + let capture = Box::new(HearthspaceCapture::new(state)); + let compositor = Box::new(compositor); + let session = Arc::new( + Session::start( + compositor, + input, + capture, + SessionConfig { + command: hearthspace_binary().to_string_lossy().into_owned(), + args: vec!["--gtk-test-app".to_string()], + cwd: None, + app_name: "Hearthspace Research Demo".to_string(), + video_output: None, + video_bitrate: None, + video_fps: None, + prewarm_visual: false, + visual_region_tuning: Default::default(), + visual_text_tuning: Default::default(), + visual_click_tuning: Default::default(), + gsettings_isolated: true, + xdg_isolated: true, + extra_env: vec![("GDK_BACKEND".to_string(), "wayland".to_string())], + capture_external_effects: false, + }, + ) + .await + .unwrap(), + ); + + { + let header = session.locate("//*[@name='Research Workspace']"); + header.click().await.unwrap(); + } + let screenshot = session.take_screenshot().await.unwrap(); + assert_eq!(png_dimensions(&screenshot), (800, 600)); + let session = match Arc::try_unwrap(session) { + Ok(session) => session, + Err(_) => panic!("session still has live references"), + }; + session.kill().await.unwrap(); +} + +fn hearthspace_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_hearthspace")) +} + +fn png_dimensions(png: &[u8]) -> (u32, u32) { + assert!(png.len() >= 24); + let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); + let height = u32::from_be_bytes(png[20..24].try_into().expect("PNG height bytes")); + (width, height) +} diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index fbcc8fa..baca260 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -188,13 +188,18 @@ in place: - [x] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. -### Phase 3 — WayDriver backend crates ⬜ +### Phase 3 — WayDriver backend crates ◩ -- [ ] Implement `HearthspaceCompositor` (`CompositorRuntime`), +- [x] Implement `HearthspaceCompositor` (`CompositorRuntime`), `HearthspaceInput` (`InputBackend`), `HearthspaceCapture` (`CaptureBackend`, overriding `grab_screenshot`/`take_screenshot` to bypass PipeWire). -- [ ] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test - against a real client (e.g. the in-repo a11y test app). +- [x] Add an ignored WayDriver-trait smoke test that starts headless Hearthspace, + drives input, captures a PNG screenshot, and tears down through the + adapter. +- [x] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test + against the in-repo a11y test app. The test is currently opt-in with + `HEARTHSPACE_REQUIRE_ATSPI=1` because the GTK client renders in this VM + but does not register as an AT-SPI application root on the host bus. - [ ] **Done when:** a `cargo test` E2E spins up headless Hearthspace, launches a client, locates a widget by XPath, clicks it, and asserts on the result. @@ -205,12 +210,10 @@ in place: ## Decisions -- **Where do the backend crates live?** Since the goal is testing Hearthspace - itself, keep `waydriver-{compositor,input,capture}-hearthspace` **in this - repo** (e.g. under `tests/` support crates or a workspace member), depending on - the upstream `waydriver` library crate. Avoids coupling our test harness to - WayDriver's release cadence. Upstreaming later stays possible (additive - siblings). +- **Where does the backend crate live?** Since the goal is testing Hearthspace + itself, keep the adapter in this repo as `crates/waydriver-hearthspace`, using + the published `waydriver` crate from crates.io for the shared traits. Upstreaming + later stays possible. - **Control-socket reply protocol.** Implemented as a minimal request/reply protocol over the existing Unix stream: `ok\n`, `err \n`, or `ok \n` for screenshots. This keeps the current shell