diff --git a/Cargo.lock b/Cargo.lock index b241e0144..d3a6a96db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -286,6 +304,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -409,6 +436,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cipher" version = "0.4.4" @@ -500,6 +533,16 @@ dependencies = [ "unicode-xid", ] +[[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" version = "0.10.1" @@ -523,7 +566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -536,7 +579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -549,30 +592,6 @@ dependencies = [ "libc", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "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" @@ -586,7 +605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "subtle", "zeroize", ] @@ -598,7 +617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -689,6 +708,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -700,6 +731,17 @@ dependencies = [ "syn", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -735,7 +777,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core", "sec1", "subtle", "zeroize", @@ -747,18 +789,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -852,7 +882,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -878,6 +908,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1281,7 +1328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -1390,56 +1437,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hickory-resolver" -version = "0.25.2" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" @@ -1603,6 +1610,49 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation 0.9.4", + "fnv", + "futures", + "if-addrs 0.15.0", + "ipnet", + "log", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration", + "tokio", + "windows 0.62.2", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1652,7 +1702,7 @@ dependencies = [ "ashpd", "async-trait", "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "core-graphics", "futures", @@ -1671,7 +1721,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "wayland-protocols-wlr", - "windows", + "windows 0.61.3", "x11", ] @@ -1682,7 +1732,7 @@ dependencies = [ "ashpd", "async-trait", "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "core-graphics", "futures", @@ -1697,7 +1747,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", - "windows", + "windows 0.61.3", "x11", ] @@ -1713,19 +1763,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result 0.4.1", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1845,7 +1882,9 @@ dependencies = [ "clap", "env_logger", "futures", - "hickory-resolver", + "hostname", + "if-addrs 0.13.4", + "if-watch", "input-capture", "input-emulation", "input-event", @@ -1856,6 +1895,8 @@ dependencies = [ "libc", "local-channel", "log", + "mdns-sd", + "netdev", "notify", "rcgen", "rustls", @@ -2044,6 +2085,27 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mdns-sd" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2bb8ce26633738d98ffcef71ec58bff967c6675be50229823c2835f6316e67e" +dependencies = [ + "fastrand", + "flume", + "if-addrs 0.15.0", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2097,20 +2159,87 @@ dependencies = [ ] [[package]] -name = "moka" -version = "0.12.15" +name = "netdev" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", ] [[package]] @@ -2126,6 +2255,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2228,6 +2369,96 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2242,10 +2473,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -2405,6 +2632,33 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2530,18 +2784,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -2551,17 +2795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -2573,15 +2807,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rcgen" version = "0.13.2" @@ -2644,12 +2869,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "rfc6979" version = "0.4.0" @@ -2674,6 +2893,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2929,7 +3166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2944,6 +3181,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.60.2", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2954,6 +3202,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -3004,6 +3261,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -3017,12 +3295,6 @@ dependencies = [ "version-compare", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "target-lexicon" version = "0.13.3" @@ -3125,21 +3397,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.51.1" @@ -3417,7 +3674,6 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -3658,8 +3914,8 @@ dependencies = [ "p384", "pem", "portable-atomic", - "rand 0.8.5", - "rand_core 0.6.4", + "rand", + "rand_core", "rcgen", "ring", "rustls", @@ -3688,20 +3944,14 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.26.4", "portable-atomic", - "rand 0.8.5", + "rand", "thiserror 1.0.69", "tokio", "winapi", ] -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -3739,11 +3989,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "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]] @@ -3755,6 +4017,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +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" @@ -3789,7 +4060,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +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", ] [[package]] @@ -3837,14 +4119,13 @@ dependencies = [ ] [[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ + "windows-core 0.62.2", "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", ] [[package]] @@ -3961,6 +4242,15 @@ 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", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4186,7 +4476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core 0.6.4", + "rand_core", "serde", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index dc673fac3..e4923d14f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,6 @@ lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" } lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" } shadow-rs = { version = "1.2.0", features = ["metadata"] } -hickory-resolver = "0.25.2" toml = "0.8" toml_edit = { version = "0.22", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } @@ -68,6 +67,11 @@ rustls = { version = "0.23.12", default-features = false, features = [ rcgen = "0.13.1" sha2 = "0.10.8" notify = "8.2.0" +if-addrs = "0.13" +if-watch = { version = "3.2", features = ["tokio"] } +mdns-sd = "0.19" +netdev = "0.43" +hostname = "0.4" [target.'cfg(unix)'.dependencies] libc = "0.2.148" diff --git a/input-capture/Cargo.toml b/input-capture/Cargo.toml index 9b8d59859..bcdeb3246 100644 --- a/input-capture/Cargo.toml +++ b/input-capture/Cargo.toml @@ -58,6 +58,7 @@ bitflags = "2.6.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.61.2", features = [ "Win32_System_LibraryLoader", + "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_Foundation", "Win32_Graphics", diff --git a/input-capture/src/dummy.rs b/input-capture/src/dummy.rs index 3a2a734e1..b2296d9dd 100644 --- a/input-capture/src/dummy.rs +++ b/input-capture/src/dummy.rs @@ -42,7 +42,7 @@ impl Capture for DummyInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { Ok(()) } @@ -62,7 +62,7 @@ impl Stream for DummyInputCapture { let event = match self.start { None => { self.start.replace(current); - CaptureEvent::Begin + CaptureEvent::Begin { cursor: None } } Some(start) => { let elapsed = start.elapsed(); diff --git a/input-capture/src/layer_shell.rs b/input-capture/src/layer_shell.rs index 698c5849c..68436b392 100644 --- a/input-capture/src/layer_shell.rs +++ b/input-capture/src/layer_shell.rs @@ -149,6 +149,13 @@ struct Window { surface: WlSurface, layer_surface: ZwlrLayerSurfaceV1, pos: Position, + /// Output's top-left corner in compositor coordinate space — + /// used together with `wl_pointer::Enter`'s surface-local coords + /// to recover the host screen-space cursor position at the moment + /// of crossing, so we can populate `CaptureEvent::Begin { cursor }` + /// for cross-axis preservation. + output_pos: (i32, i32), + output_size: (i32, i32), } impl Window { @@ -157,6 +164,7 @@ impl Window { qh: &QueueHandle, output: &WlOutput, pos: Position, + output_pos: (i32, i32), size: (i32, i32), ) -> Window { log::debug!("creating window output: {output:?}, size: {size:?}"); @@ -208,6 +216,8 @@ impl Window { buffer, surface, layer_surface, + output_pos, + output_size: size, } } } @@ -221,6 +231,22 @@ impl Drop for Window { } } +/// Translate `wl_pointer.enter` surface-local coords into the host's +/// compositor coordinate space, using the layer-surface's anchor edge +/// and the output it's attached to. Layer surfaces here are 1 px on +/// the on-axis dimension and span the cross-axis, so the surface-local +/// cross-axis coord is the screen offset directly. +fn surface_to_screen(window: &Window, surface_x: f64, surface_y: f64) -> (i32, i32) { + let (ox, oy) = window.output_pos; + let (ow, oh) = window.output_size; + match window.pos { + Position::Left => (ox, oy + surface_y as i32), + Position::Right => (ox + ow.saturating_sub(1), oy + surface_y as i32), + Position::Top => (ox + surface_x as i32, oy), + Position::Bottom => (ox + surface_x as i32, oy + oh.saturating_sub(1)), + } +} + fn get_edges(outputs: &[Output], pos: Position) -> Vec<(Output, i32)> { outputs .iter() @@ -525,7 +551,8 @@ impl State { ); outputs.iter().for_each(|o| { if let Some(info) = o.info.as_ref() { - let window = Window::new(self, &self.qh, &o.wl_output, pos, info.size); + let window = + Window::new(self, &self.qh, &o.wl_output, pos, info.position, info.size); let window = Arc::new(window); self.active_windows.push(window); } @@ -628,7 +655,7 @@ impl Capture for LayerShellInputCapture { Ok(inner.flush_events()?) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { log::debug!("releasing pointer"); let inner = self.0.get_mut(); inner.state.ungrab(); @@ -638,6 +665,28 @@ impl Capture for LayerShellInputCapture { async fn terminate(&mut self) -> Result<(), CaptureError> { Ok(()) } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Union of every active output's rectangle in compositor + // coords. Mirrors the macOS impl so MotionAbsolute scaling + // stays consistent: cursor coords reported in this same + // space normalize cleanly against the returned dimensions. + let outputs = &self.0.get_ref().state.outputs; + let mut xmin = i32::MAX; + let mut ymin = i32::MAX; + let mut xmax = i32::MIN; + let mut ymax = i32::MIN; + for info in outputs.iter().filter_map(|o| o.info.as_ref()) { + xmin = xmin.min(info.position.0); + ymin = ymin.min(info.position.1); + xmax = xmax.max(info.position.0 + info.size.0); + ymax = ymax.max(info.position.1 + info.size.1); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } } impl Stream for LayerShellInputCapture { @@ -735,25 +784,26 @@ impl Dispatch for State { wl_pointer::Event::Enter { serial, surface, - surface_x: _, - surface_y: _, + surface_x, + surface_y, } => { - // get client corresponding to the focused surface - { - if let Some(window) = app.active_windows.iter().find(|w| w.surface == surface) { - app.focused = Some(window.clone()); - app.grab(&surface, pointer, serial, qh); - } else { - return; - } - } - let pos = app + let Some(window) = app .active_windows .iter() .find(|w| w.surface == surface) - .map(|w| w.pos) - .unwrap(); - app.pending_events.push_back((pos, CaptureEvent::Begin)); + .cloned() + else { + return; + }; + app.focused = Some(window.clone()); + app.grab(&surface, pointer, serial, qh); + let cursor = surface_to_screen(&window, surface_x, surface_y); + app.pending_events.push_back(( + window.pos, + CaptureEvent::Begin { + cursor: Some(cursor), + }, + )); } wl_pointer::Event::Leave { .. } => { /* There are rare cases, where when a window is opened in diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index b1ef6c0be..b2c9eb81d 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -1,15 +1,19 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fmt::Display, + future::Future, mem::swap, + pin::Pin, task::{Poll, ready}, + time::Duration, }; use async_trait::async_trait; use futures::StreamExt; use futures_core::Stream; +use tokio::time::Sleep; -use input_event::{Event, KeyboardEvent, scancode}; +use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; @@ -37,17 +41,35 @@ pub type CaptureHandle = u64; #[derive(Copy, Clone, Debug, PartialEq)] pub enum CaptureEvent { - /// capture on this capture handle is now active - Begin, + /// Capture on this handle is now active. `cursor`, when present, + /// is the host's screen-space cursor position (in pixels) at the + /// instant of the edge crossing — the capture loop normalizes it + /// against the host's display bounds and forwards it to the peer + /// as a [`ProtoEvent::CursorPos`] so the guest's cursor lands at + /// the visually-corresponding point on its own screen. Backends + /// that can't report cursor position emit `None`; the peer's + /// cursor stays where it was on remote-takeover (no forced + /// midpoint warp — that masquerades as a mid-screen crossing on + /// fast re-crosses). + Begin { cursor: Option<(i32, i32)> }, /// input event coming from capture handle Input(Event), + /// the capture wrapper detected sustained back-toward-host motion + /// past the configured threshold (the user has pinned the cursor + /// at the host-adjacent edge of the guest and kept pushing). The + /// capture loop should treat this like a release-bind chord. + AutoRelease, } impl Display for CaptureEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - CaptureEvent::Begin => write!(f, "begin capture"), + CaptureEvent::Begin { cursor: None } => write!(f, "begin capture"), + CaptureEvent::Begin { + cursor: Some((x, y)), + } => write!(f, "begin capture @ ({x}, {y})"), CaptureEvent::Input(e) => write!(f, "{e}"), + CaptureEvent::AutoRelease => write!(f, "auto-release"), } } } @@ -127,6 +149,86 @@ pub struct InputCapture { id_map: HashMap, /// pending events pending: VecDeque<(CaptureHandle, CaptureEvent)>, + /// pixel threshold for the cross-platform auto-release-on-wall- + /// press fallback. 0 disables. See `track_wall_press`. + release_threshold_px: u32, + /// position the cursor is currently captured into, if any. Tracks + /// `Begin`/release transitions so the wall-press accumulator + /// resets correctly across capture sessions. + capture_pos: Option, + /// Modeled cursor position on the guest along the entry axis, + /// relative to the host-adjacent edge. 0 = at the entry edge, + /// growing values = further into the guest. Clamped at 0 from + /// below; clamped at the cached peer extent from above when + /// available, otherwise unbounded (degraded fallback). + virtual_pos: f64, + /// Pixels of back-toward-host motion that the modeled cursor + /// could not absorb (proposed virtual_pos < 0). Resets whenever + /// the cursor is back in the interior or moving deeper. + wall_pressure: f64, + /// Modeled guest cursor position in the guest's screen space, + /// updated by accumulating Motion deltas while captured. Seeded + /// on `Begin` from the cross-axis warp target (if peer bounds + /// are known) or the entry-edge midpoint otherwise — i.e. wherever + /// the guest's cursor visually lands at Enter. Read on release + /// to compute a host-side warp so the local cursor reappears at + /// the matching point on the host's screen instead of jumping + /// back to where capture started. + virtual_cursor: Option<(f64, f64)>, + /// Host-coord cursor at the moment of `Begin`, retained until + /// `peer_bounds` arrives so we can retroactively seed + /// `virtual_cursor` once the round-trip completes. Without this, + /// a `Begin` that fires before the peer's `Bounds` reply leaves + /// `virtual_cursor` stuck at `None` for the rest of the session + /// — the wall-press accumulator skips updates and the + /// release-time warp falls back to the original crossing + /// y-value instead of where the cursor visually was on the peer. + pending_begin_cursor: Option<(i32, i32)>, + /// Motion deltas that arrived while `virtual_cursor` was still + /// `None` (between `Begin` and the late-arriving + /// `set_peer_bounds`). Drained into the freshly-seeded + /// `virtual_cursor` when the bootstrap completes so deltas + /// during the round-trip aren't lost. + pending_motion: (f64, f64), + /// Per-position cache of peer display geometry. Populated when + /// the peer responds with a `ProtoEvent::Bounds` event after + /// Ack. Used as the upper clamp for `virtual_pos` so that + /// pushing past the guest's actual far edge doesn't make the + /// model run away. Only the entry-axis dimension is consulted. + peer_bounds: HashMap, + /// True when wall_pressure has crossed `release_threshold_px` and + /// `wall_press_timer` has been armed but not yet either elapsed + /// or been cancelled. Cleared when the peer's handover Leave + /// arrives (which routes through `release_no_host_warp` → + /// `reset_wall_press_state`) or when the cursor moves back into + /// the interior. The wall-press auto-release fires only after + /// `wall_press_deadline` elapses without this being cleared — + /// turning the historically race-y "wall-press vs peer-Leave" + /// into an explicit fallback that only kicks in when the peer + /// can't deliver a Leave (lock screen, restricted DE, dead peer). + wall_press_pending: bool, + /// Window after the threshold is crossed during which a peer + /// Leave can cancel the deferred AutoRelease. Sized so a + /// healthy LAN round-trip beats it comfortably. + wall_press_deadline: Duration, + /// Timer driving the deferred fire. Reset to deadline-from-now + /// on first threshold crossing; polled in `poll_next` so the + /// fire happens even when no further backend events arrive + /// (the user pinned the cursor against the wall and stopped). + wall_press_timer: Pin>, +} + +/// Project a motion delta onto the entry axis. Positive return = +/// "into guest", so virtual_pos increases as the user pushes deeper. +fn entry_axis_delta(position: Position, dx: f64, dy: f64) -> f64 { + match position { + // Position::Left = guest is to the LEFT of host. User entered + // by moving left (-dx). Convention: positive = into guest. + Position::Left => -dx, + Position::Right => dx, + Position::Top => -dy, + Position::Bottom => dy, + } } impl InputCapture { @@ -167,8 +269,396 @@ impl InputCapture { /// release mouse pub async fn release(&mut self) -> Result<(), CaptureError> { + // Compute the host-side warp target before resetting the + // wall-press / virtual_cursor state — once those are cleared + // we lose the data needed to figure out where the guest's + // cursor visually was. + let warp_target = self + .capture_pos + .and_then(|pos| self.host_warp_target_on_release(pos)); + log::info!( + "[release-warp] capture_pos={:?} virtual_cursor={:?} peer_bounds={:?} display_bounds={:?} → warp_target={warp_target:?}", + self.capture_pos, + self.virtual_cursor, + self.capture_pos + .and_then(|p| self.peer_bounds.get(&p).copied()), + self.capture.display_bounds(), + ); self.pressed_keys.clear(); - self.capture.release().await + self.reset_wall_press_state(); + self.capture.release(warp_target).await + } + + /// Release without applying a host-side cursor warp. Used when + /// the remote peer is taking over (it just sent us Enter + + /// CursorPos): the proportional warp from CursorPos is the + /// authoritative final position for our shared cursor, and the + /// stale `virtual_cursor`-derived warp would race against it + /// and frequently win — clobbering the proportional landing + /// with whatever position Linux *thought* the peer's cursor was + /// at before the user moved it. + pub async fn release_no_host_warp(&mut self) -> Result<(), CaptureError> { + log::info!( + "[release-warp] handover release: capture_pos={:?} — skipping host warp, peer's CursorPos is authoritative", + self.capture_pos, + ); + self.pressed_keys.clear(); + self.reset_wall_press_state(); + self.capture.release(None).await + } + + /// Configure the wall-press auto-release pixel threshold. + /// 0 disables. Effective immediately for the next motion event; + /// no need to recreate the backend. + pub fn set_release_threshold(&mut self, threshold: u32) { + self.release_threshold_px = threshold; + } + + /// Cache the peer's display geometry for a position. Used by + /// the wall-press tracker as the upper bound for `virtual_pos` + /// so the model can't run away when the user pushes past the + /// peer's actual far edge. + /// + /// If `Begin` fired before this arrived (the round-trip + /// bootstrap case — `Bounds` is sent in response to `Enter`, + /// which is sent by the host AFTER `Begin` fires), seed + /// `virtual_cursor` retroactively so the wall-press / release + /// machinery has a baseline to track from. Drains any motion + /// that piled up in `pending_motion` so deltas during the + /// round-trip aren't lost. + pub fn set_peer_bounds(&mut self, pos: Position, width: u32, height: u32) { + log::debug!("peer at {pos} reports bounds {width}x{height}"); + self.peer_bounds.insert(pos, (width, height)); + + if self.virtual_cursor.is_none() + && self.capture_pos == Some(pos) + && self.pending_begin_cursor.is_some() + { + let begin_cursor = self.pending_begin_cursor; + let seeded = self.initial_virtual_cursor(pos, begin_cursor); + if let Some((sx, sy)) = seeded { + let (mx, my) = self.pending_motion; + let peer_w = width as f64; + let peer_h = height as f64; + self.virtual_cursor = + Some(((sx + mx).clamp(0.0, peer_w), (sy + my).clamp(0.0, peer_h))); + self.pending_motion = (0.0, 0.0); + log::info!( + "[bootstrap] seeded virtual_cursor={:?} after late peer_bounds at {pos} (drained pending_motion=({mx:.1}, {my:.1}))", + self.virtual_cursor + ); + } + } + } + + /// Forget the cached peer geometry for a position. Called when + /// the corresponding capture is destroyed so re-adding the same + /// peer later (potentially with new geometry) starts fresh. + pub fn clear_peer_bounds(&mut self, pos: Position) { + self.peer_bounds.remove(&pos); + } + + /// Host's own display geometry — width and height in pixels of + /// the union of all displays. Returns `None` when the active + /// backend can't query its own bounds (e.g. xdg-desktop-portal, + /// dummy). Used by `host_normalized_cursor` to compute the + /// [`ProtoEvent::CursorPos`] fraction the guest scales against + /// its own bounds on Enter. + pub fn display_bounds(&self) -> Option<(u32, u32)> { + self.capture.display_bounds() + } + + /// Top-left corner of the host's display union in pointer-event + /// coordinate space. See `Capture::display_origin` for why this + /// matters on multi-monitor macOS hosts. + fn display_origin(&self) -> (i32, i32) { + self.capture.display_origin() + } + + /// Host's screen-space cursor position normalized to the host's + /// own display bounds (each axis in 0..1, clamped). Returns + /// `None` when the active backend can't report its own bounds. + /// Used for the self-sufficient `ProtoEvent::CursorPos` event + /// (the receiver scales the normalized fraction against its + /// own bounds and pins the entry axis to the matching edge), so + /// the first crossing isn't blocked by the bootstrap problem + /// `peer_warp_target` has — that variant requires a prior + /// `Bounds` round-trip from the peer, which can't have happened + /// yet on the very first Enter. + pub fn host_normalized_cursor(&self, cursor: (i32, i32)) -> Option<(f32, f32)> { + let (host_w, host_h) = self.display_bounds()?; + if host_w == 0 || host_h == 0 { + return None; + } + let (origin_x, origin_y) = self.display_origin(); + let (cx, cy) = cursor; + // Subtract the union origin before normalizing so that + // points on a non-origin display (e.g. a macOS external + // monitor positioned to the left of the primary, where + // cursor x is negative) map correctly. Without this, the + // clamp masks every off-primary point as the screen edge. + let nx = ((cx - origin_x) as f32 / host_w as f32).clamp(0.0, 1.0); + let ny = ((cy - origin_y) as f32 / host_h as f32).clamp(0.0, 1.0); + Some((nx, ny)) + } + + /// Cursor warp target on the peer for a transition at `pos`, + /// given the host's screen-space cursor position at the moment + /// of crossing. Returns `None` when either the host's own + /// `display_bounds` or the cached peer geometry is unavailable — + /// in that case there's no warp target to compute and the peer's + /// cursor stays wherever the most recent `CursorPos` (or, if none + /// arrived this session, where it was) put it. + /// + /// Coordinates returned are pixels in the peer's screen space: + /// the cross-axis is preserved as a normalized fraction of the + /// host screen (so a host_y near the top maps to a peer_y near + /// the top regardless of resolution mismatch), the on-axis is + /// pinned to the peer's far edge for the entering side. + pub fn peer_warp_target(&self, pos: Position, cursor: (i32, i32)) -> Option<(i32, i32)> { + let (host_w, host_h) = self.display_bounds()?; + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let (origin_x, origin_y) = self.display_origin(); + let (cx, cy) = cursor; + // Subtract the union origin before normalizing — same + // rationale as in host_normalized_cursor. + let nx = ((cx - origin_x) as f64 / host_w as f64).clamp(0.0, 1.0); + let ny = ((cy - origin_y) as f64 / host_h as f64).clamp(0.0, 1.0); + let peer_w_i = peer_w as i32; + let peer_h_i = peer_h as i32; + let target = match pos { + // Peer to our Left → cursor exits on left, enters peer on right + Position::Left => (peer_w_i.saturating_sub(1), (ny * peer_h as f64) as i32), + // Peer to our Right → cursor enters peer on left + Position::Right => (0, (ny * peer_h as f64) as i32), + // Peer above → cursor enters peer on bottom + Position::Top => ((nx * peer_w as f64) as i32, peer_h_i.saturating_sub(1)), + // Peer below → cursor enters peer on top + Position::Bottom => ((nx * peer_w as f64) as i32, 0), + }; + Some(target) + } + + /// Returns the upper-clamp value (along the entry axis) for the + /// given position, or `f64::INFINITY` if the peer hasn't reported + /// bounds yet. + fn peer_extent(&self, pos: Position) -> f64 { + let Some(&(w, h)) = self.peer_bounds.get(&pos) else { + return f64::INFINITY; + }; + match pos { + Position::Left | Position::Right => f64::from(w), + Position::Top | Position::Bottom => f64::from(h), + } + } + + fn reset_wall_press_state(&mut self) { + self.capture_pos = None; + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + self.virtual_cursor = None; + self.pending_begin_cursor = None; + self.pending_motion = (0.0, 0.0); + // Cancel any deferred AutoRelease — release() / handover have + // taken responsibility for the transition. + self.wall_press_pending = false; + } + + /// Initial guest-space cursor position for a freshly-started + /// capture. Mirrors what the guest's emulation will visibly do on + /// the corresponding `Enter`: the `CursorPos` proportional warp + /// target if the host can compute one (capture backend reports + /// cursor), otherwise the entry-edge midpoint as a fallback for + /// the wall-press model's starting position. + fn initial_virtual_cursor( + &self, + pos: Position, + host_cursor: Option<(i32, i32)>, + ) -> Option<(f64, f64)> { + if let Some(host_cursor) = host_cursor { + if let Some((x, y)) = self.peer_warp_target(pos, host_cursor) { + return Some((x as f64, y as f64)); + } + } + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let pw = peer_w as f64; + let ph = peer_h as f64; + Some(match pos { + Position::Left => (0.0, ph / 2.0), + Position::Right => ((pw - 1.0).max(0.0), ph / 2.0), + Position::Top => (pw / 2.0, 0.0), + Position::Bottom => (pw / 2.0, (ph - 1.0).max(0.0)), + }) + } + + /// Where on the host's own screen the cursor should land when + /// capture is released, given the modeled guest cursor position + /// at the moment of release. Symmetric inverse of + /// `peer_warp_target`: cross-axis is preserved as a normalized + /// fraction of the peer's screen, on-axis is pinned to the + /// host's far edge for the side the guest is on so the cursor + /// reappears at the boundary it just crossed back through. + fn host_warp_target_on_release(&self, pos: Position) -> Option<(i32, i32)> { + let (gx, gy) = self.virtual_cursor?; + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let (host_w, host_h) = self.capture.display_bounds()?; + if peer_w == 0 || peer_h == 0 || host_w == 0 || host_h == 0 { + return None; + } + let (origin_x, origin_y) = self.display_origin(); + let nx = (gx / peer_w as f64).clamp(0.0, 1.0); + let ny = (gy / peer_h as f64).clamp(0.0, 1.0); + let host_w_i = host_w as i32; + let host_h_i = host_h as i32; + // Add the union origin back so the result is in pointer-event + // coordinate space (which is what `CGDisplay::warp_mouse_cursor_position` + // and friends consume), not "0..host_w" of the union rectangle. + // Matters on macOS hosts whose primary isn't anchored at (0, 0) + // — `display_bounds` returns just the size of the union, so the + // origin needs to be reapplied to recover absolute coords. + Some(match pos { + // Peer to our Left → cursor returns through host's left edge + Position::Left => (origin_x, origin_y + (ny * host_h as f64) as i32), + // Peer to our Right → cursor returns through host's right edge + Position::Right => ( + origin_x + host_w_i.saturating_sub(1), + origin_y + (ny * host_h as f64) as i32, + ), + // Peer above → cursor returns through host's top edge + Position::Top => (origin_x + (nx * host_w as f64) as i32, origin_y), + // Peer below → cursor returns through host's bottom edge + Position::Bottom => ( + origin_x + (nx * host_w as f64) as i32, + origin_y + host_h_i.saturating_sub(1), + ), + }) + } + + /// Update the wall-press accumulator from one event coming up + /// from the backend. Sets `wall_press_pending` (and arms the + /// timer) when the threshold is first crossed; the actual + /// `AutoRelease` synthesis happens in `poll_next` once the + /// deadline elapses without a peer Leave clearing the flag. + fn track_wall_press(&mut self, pos: Position, event: &CaptureEvent) { + match event { + CaptureEvent::Begin { cursor } => { + self.capture_pos = Some(pos); + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + self.virtual_cursor = self.initial_virtual_cursor(pos, *cursor); + // Stash the host-coord cursor so set_peer_bounds can + // retroactively seed virtual_cursor if peer_bounds + // arrives after Begin. + self.pending_begin_cursor = *cursor; + self.pending_motion = (0.0, 0.0); + log::info!( + "[wp-begin] pos={pos} cursor={cursor:?} peer_bounds={:?} virtual_cursor={:?}", + self.peer_bounds.get(&pos).copied(), + self.virtual_cursor, + ); + } + CaptureEvent::AutoRelease => { + // Don't reset virtual_cursor here — release() needs it + // to compute the host-side warp target. The wrapper's + // release() resets state after consuming it. + } + CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { dx, dy, .. })) => { + let Some(active_pos) = self.capture_pos else { + return; + }; + if active_pos != pos { + return; + } + + // Track guest-space cursor for the on-release warp + // back to the host. Clamped to the peer's bounds so + // the model doesn't drift past the guest's screen + // when the user pushes obliviously. + match ( + self.virtual_cursor.as_mut(), + self.peer_bounds.get(&active_pos), + ) { + (Some(vc), Some(&(peer_w, peer_h))) => { + vc.0 = (vc.0 + *dx).clamp(0.0, peer_w as f64); + vc.1 = (vc.1 + *dy).clamp(0.0, peer_h as f64); + } + // virtual_cursor not yet seeded (peer_bounds was + // None at Begin time and the round-trip hasn't + // completed yet). Buffer the deltas so they can + // be applied retroactively in set_peer_bounds + // once the bootstrap finishes — otherwise the + // motion that happened during the round-trip is + // silently lost and the release-time warp picks + // the wrong y. + (None, _) => { + self.pending_motion.0 += *dx; + self.pending_motion.1 += *dy; + log::debug!( + "[wp-motion] deferred dx={dx:.1} dy={dy:.1} (peer_bounds for {active_pos}: {:?})", + self.peer_bounds.get(&active_pos).copied(), + ); + } + _ => {} + } + + if self.release_threshold_px == 0 { + return; + } + + let delta = entry_axis_delta(active_pos, *dx, *dy); + let proposed = self.virtual_pos + delta; + let upper = self.peer_extent(active_pos); + // Clamp at 0 from below (host-adjacent edge — wall + // pressure accumulates here) and at the peer's + // entry-axis extent from above when known. The upper + // clamp prevents the model from running away if the + // user obliviously pushes their physical mouse past + // the guest's actual far edge. When the peer hasn't + // reported bounds yet (older peer, or pre-Ack + // window), `upper` is INFINITY and we fall back to + // the heuristic behavior. + self.virtual_pos = proposed.clamp(0.0, upper); + + if proposed < 0.0 { + // Motion overshot the host-adjacent edge — + // accumulate the unabsorbed amount as wall + // pressure. + self.wall_pressure += -proposed; + } else { + // Cursor moved into the interior or further in; + // reset so a brief bump against the wall followed + // by motion deeper into the guest doesn't combine + // with a later wall-press to fire spuriously. + self.wall_pressure = 0.0; + if std::mem::take(&mut self.wall_press_pending) { + log::info!( + "wall-press deferred AutoRelease cancelled (cursor moved away from entry edge)" + ); + } + } + + if self.wall_pressure >= f64::from(self.release_threshold_px) + && !self.wall_press_pending + { + self.wall_press_pending = true; + self.wall_press_timer + .as_mut() + .reset(tokio::time::Instant::now() + self.wall_press_deadline); + log::info!( + "wall-press threshold reached ({:.0}px past entry edge, {}px threshold) — \ + deferring AutoRelease for {}ms pending peer Leave", + self.wall_pressure, + self.release_threshold_px, + self.wall_press_deadline.as_millis(), + ); + } + // Fire is now driven by the timer in `poll_next`, not + // directly from this event — keeps the behavior gated + // on "peer didn't claim handover in time" instead of + // racing the peer's Leave. + } + _ => {} + } } /// Drain and return every key the capture has forwarded as @@ -198,6 +688,17 @@ impl InputCapture { pending: Default::default(), position_map: Default::default(), pressed_keys: HashSet::new(), + release_threshold_px: 0, + capture_pos: None, + virtual_pos: 0.0, + wall_pressure: 0.0, + virtual_cursor: None, + pending_begin_cursor: None, + pending_motion: (0.0, 0.0), + peer_bounds: HashMap::new(), + wall_press_pending: false, + wall_press_deadline: Duration::from_millis(150), + wall_press_timer: Box::pin(tokio::time::sleep(Duration::from_secs(0))), }) } @@ -228,6 +729,33 @@ impl Stream for InputCapture { return Poll::Ready(Some(Ok(e))); } + // Deferred wall-press fallback. If the threshold was crossed + // and the deadline elapsed without a peer Leave clearing + // `wall_press_pending` (release_no_host_warp → + // reset_wall_press_state), synthesize AutoRelease for every + // capture handle at the active position. Polled before the + // backend so a fire still happens when the user pinned the + // cursor against the wall and stopped moving (no further + // backend events, but the deadline still has to elapse). + if self.wall_press_pending && self.wall_press_timer.as_mut().poll(cx).is_ready() { + self.wall_press_pending = false; + log::info!( + "wall-press deadline elapsed ({}ms) — firing AutoRelease (no peer Leave; \ + assuming peer-side capture is unavailable, e.g. lock screen)", + self.wall_press_deadline.as_millis(), + ); + if let Some(pos) = self.capture_pos { + if let Some(ids) = self.position_map.get(&pos).cloned() { + for id in ids { + self.pending.push_back((id, CaptureEvent::AutoRelease)); + } + } + } + if let Some(e) = self.pending.pop_front() { + return Poll::Ready(Some(Ok(e))); + } + } + // ready let event = ready!(self.capture.poll_next_unpin(cx)); @@ -248,6 +776,13 @@ impl Stream for InputCapture { self.update_pressed_keys(key, state); } + // wall-press auto-release tracking. Runs against every event + // before routing so a single global accumulator stays consistent + // regardless of how many handles exist at this position. The + // fire itself is deferred and driven by `wall_press_timer` + // above so the peer's Leave can cancel it. + self.track_wall_press(pos, &event); + let len = self .position_map .get(&pos) @@ -256,10 +791,10 @@ impl Stream for InputCapture { match len { 0 => Poll::Pending, - 1 => Poll::Ready(Some(Ok(( - self.position_map.get(&pos).expect("no id")[0], - event, - )))), + 1 => { + let id = self.position_map.get(&pos).expect("no id")[0]; + Poll::Ready(Some(Ok((id, event)))) + } _ => { let mut position_map = HashMap::new(); swap(&mut self.position_map, &mut position_map); @@ -284,11 +819,39 @@ trait Capture: Stream> + U /// destroy the client with the given id, if it exists async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError>; - /// release mouse - async fn release(&mut self) -> Result<(), CaptureError>; + /// release mouse. `warp_target`, when present, is a screen-space + /// pixel point on the host's own display where the local cursor + /// should be placed before becoming visible again — used to + /// preserve cross-axis continuity when capture ends so the cursor + /// reappears next to where it visually was on the guest, not at + /// the spot where capture started. Backends that don't hide the + /// system cursor or can't warp it can ignore the parameter. + async fn release(&mut self, warp_target: Option<(i32, i32)>) -> Result<(), CaptureError>; /// destroy the input capture async fn terminate(&mut self) -> Result<(), CaptureError>; + + /// Host's own display geometry. Default implementation returns + /// `None`; backends that can query their own dimensions override + /// (currently macOS via CGDisplay; others may add this later). + fn display_bounds(&self) -> Option<(u32, u32)> { + None + } + + /// Top-left corner of the union of all displays in the host's + /// global pointer-coordinate system. Defaults to (0, 0) — fine + /// for any backend whose primary display is the origin (Windows, + /// most X11/Wayland setups). Returns the actual `(xmin, ymin)` + /// on macOS, where the global coordinate system is anchored at + /// the primary's top-left and a left-attached external display + /// occupies negative x. Used by `host_normalized_cursor` and + /// `peer_warp_target` to correctly normalize cursor positions + /// outside the primary display — without this, the + /// `clamp(0.0, 1.0)` in those helpers silently maps every point + /// on a non-origin display to the screen edge. + fn display_origin(&self) -> (i32, i32) { + (0, 0) + } } async fn create_backend( diff --git a/input-capture/src/libei.rs b/input-capture/src/libei.rs index fa168957c..15b393840 100644 --- a/input-capture/src/libei.rs +++ b/input-capture/src/libei.rs @@ -417,7 +417,10 @@ async fn do_capture_session( current_pos.replace(Some(pos)); // client entered => send event - event_tx.send((pos, CaptureEvent::Begin)).await.expect("no channel"); + event_tx + .send((pos, CaptureEvent::Begin { cursor: None })) + .await + .expect("no channel"); tokio::select! { _ = notify_release.notified() => { /* capture release */ @@ -589,7 +592,7 @@ impl LanMouseInputCapture for LibeiInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { self.notify_release.notify_waiters(); Ok(()) } diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index dc941b28c..915346717 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -9,7 +9,7 @@ use core_foundation::{ string::{CFStringCreateWithCString, CFStringRef, kCFStringEncodingUTF8}, }; use core_graphics::{ - base::{CGError, kCGErrorSuccess}, + base::{CGError, CGFloat, kCGErrorSuccess}, display::{CGDisplay, CGPoint}, event::{ CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, @@ -62,7 +62,14 @@ struct InputCaptureState { #[derive(Debug)] enum ProducerEvent { - Release, + /// `warp_target`, when present, is a screen-space (Quartz) point + /// at which to warp the local cursor before showing it. Used to + /// preserve cross-axis continuity on release: the visible cursor + /// reappears at the host point matching where it visually was on + /// the guest, instead of snapping back to the capture-start edge. + Release { + warp_target: Option<(i32, i32)>, + }, Create(Position), Destroy(Position), Grab(Position), @@ -153,8 +160,26 @@ impl InputCaptureState { ) -> Result<(), CaptureError> { log::debug!("handling event: {producer_event:?}"); match producer_event { - ProducerEvent::Release => { + ProducerEvent::Release { warp_target } => { + log::info!( + "[release-warp] handle_producer_event Release: current_pos={:?} warp_target={warp_target:?}", + self.current_pos + ); if self.current_pos.is_some() { + // Warp BEFORE clearing current_pos so the + // event-tap callback can't see Some(pos) and + // re-snap the cursor to the entry edge before we + // make it visible again. Then show_cursor() reveals + // it at the warped point. + if let Some((x, y)) = warp_target { + log::info!("[release-warp] warping local cursor to ({x}, {y})"); + if let Err(e) = CGDisplay::warp_mouse_cursor_position(CGPoint { + x: x as CGFloat, + y: y as CGFloat, + }) { + log::warn!("[release-warp] warp_mouse_cursor_position failed: {e:?}"); + } + } self.show_cursor()?; self.current_pos = None; } @@ -518,14 +543,34 @@ fn create_event_tap<'a>( } else if matches!(event_type, CGEventType::MouseMoved) { // Did we cross a barrier? if let Some(new_pos) = state.crossed(cg_ev) { - capture_position = Some(new_pos); - state - .start_capture(cg_ev, new_pos) - .unwrap_or_else(|e| log::warn!("{e}")); - res_events.push(CaptureEvent::Begin); - notify_tx - .blocking_send(ProducerEvent::Grab(new_pos)) - .expect("Failed to send notification"); + // About to commit the cross — final gate: skip if the + // host is locked, since the lock screen consumes + // keyboard before our tap sees it and allowing the + // cursor to leave would produce a mouse-only-on-peer + // half-broken state. Polling CGSession only at this + // commit point (rather than every MouseMoved) keeps + // the per-event cost zero — `is_screen_locked()` is + // an XPC to WindowServer (~10–50µs); a typical user + // crosses a wall a few times per minute. + if is_screen_locked() { + log::info!("host screen locked; suppressing cross to {new_pos:?}"); + } else { + capture_position = Some(new_pos); + // Snapshot the cursor's screen-space position at the + // instant of crossing — before start_capture's + // reset_cursor() snaps it to the edge. The peer uses + // this for the visually-corresponding warp on Enter + // so the cursor doesn't jump to the entry-edge midpoint. + let cross_loc = cg_ev.location(); + let cursor = Some((cross_loc.x as i32, cross_loc.y as i32)); + state + .start_capture(cg_ev, new_pos) + .unwrap_or_else(|e| log::warn!("{e}")); + res_events.push(CaptureEvent::Begin { cursor }); + notify_tx + .blocking_send(ProducerEvent::Grab(new_pos)) + .expect("Failed to send notification"); + } } } @@ -624,6 +669,37 @@ fn event_tap_thread( let _ = exit.send(()); } +/// Query whether the host's screen is locked. Asks the WindowServer +/// for the current login session dictionary and looks up the +/// `CGSSessionScreenIsLocked` key. The key is `kCFBooleanTrue` when +/// locked; on Sequoia 15+ it's typically absent when unlocked rather +/// than `kCFBooleanFalse`, so missing-or-nil is treated as unlocked. +/// Costs ~10–50µs per call (an XPC round-trip to WindowServer); +/// called from the event tap callback only on `MouseMoved`, so the +/// amortized cost is negligible (<2% CPU at typical mouse rates). +fn is_screen_locked() -> bool { + let key = unsafe { + let cstr = CString::new("CGSSessionScreenIsLocked").unwrap(); + CFStringCreateWithCString( + kCFAllocatorDefault, + cstr.as_ptr() as *const c_char, + kCFStringEncodingUTF8, + ) + }; + let dict = unsafe { CGSessionCopyCurrentDictionary() }; + if dict.is_null() { + unsafe { CFRelease(key as *const c_void) }; + return false; + } + let value = unsafe { CFDictionaryGetValue(dict, key as *const c_void) }; + let locked = !value.is_null() && unsafe { CFBooleanGetValue(value as CFBooleanRef) }; + unsafe { + CFRelease(dict as *const c_void); + CFRelease(key as *const c_void); + } + locked +} + /// Quartz display-reconfiguration callback. Fires twice per change: /// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the /// change is applied — the bounds are still stale at this point), @@ -772,11 +848,12 @@ impl Capture for MacOSInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { + log::info!("[release-warp] macOS backend release(warp_target={warp_target:?})"); let notify_tx = self.notify_tx.clone(); tokio::task::spawn_local(async move { log::debug!("notifying Release"); - let _ = notify_tx.send(ProducerEvent::Release).await; + let _ = notify_tx.send(ProducerEvent::Release { warp_target }).await; }); Ok(()) } @@ -784,6 +861,54 @@ impl Capture for MacOSInputCapture { async fn terminate(&mut self) -> Result<(), CaptureError> { Ok(()) } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Mirror the InputEmulation-side implementation: the union of + // every active display's rectangle, in points (which match + // the units used by CGEvent.location() so the + // MotionAbsolute math stays internally consistent). + let displays = CGDisplay::active_displays().ok()?; + let mut xmin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymin = f64::INFINITY; + let mut ymax = f64::NEG_INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + xmax = xmax.max(bounds.origin.x + bounds.size.width); + ymin = ymin.min(bounds.origin.y); + ymax = ymax.max(bounds.origin.y + bounds.size.height); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } + + fn display_origin(&self) -> (i32, i32) { + // Top-left of the union of all active displays. Matters when + // a secondary monitor is positioned LEFT of (or ABOVE) the + // primary — the global pointer-coordinate system is anchored + // at the primary's top-left, so a left-attached external + // gives cursor x ∈ [-w, 0). Without this offset, + // host_normalized_cursor / peer_warp_target's clamp(0, 1) + // silently maps every point on the external to "left edge" + // and the receiver warps to the wrong column. + let Ok(displays) = CGDisplay::active_displays() else { + return (0, 0); + }; + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + ymin = ymin.min(bounds.origin.y); + } + if xmin.is_infinite() || ymin.is_infinite() { + return (0, 0); + } + (xmin as i32, ymin as i32) + } } impl Stream for MacOSInputCapture { @@ -810,6 +935,19 @@ extern "C" { fn _CGSDefaultConnection() -> CGSConnectionID; } +type CFDictionaryRef = *mut c_void; + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef; +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: *const c_void) -> *const c_void; + fn CFBooleanGetValue(boolean: CFBooleanRef) -> bool; +} + extern "C" { fn CGEventSourceSetLocalEventsSuppressionInterval( event_source: CGEventSource, diff --git a/input-capture/src/windows.rs b/input-capture/src/windows.rs index 0d0ed7c21..059abe480 100644 --- a/input-capture/src/windows.rs +++ b/input-capture/src/windows.rs @@ -29,7 +29,7 @@ impl Capture for WindowsInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { self.event_thread.release_capture(); Ok(()) } diff --git a/input-capture/src/windows/event_thread.rs b/input-capture/src/windows/event_thread.rs index 2a44c0fc2..a4711023d 100644 --- a/input-capture/src/windows/event_thread.rs +++ b/input-capture/src/windows/event_thread.rs @@ -14,6 +14,9 @@ use windows::Win32::Graphics::Gdi::{ EnumDisplayDevicesW, EnumDisplaySettingsW, }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::RemoteDesktop::{ + NOTIFY_FOR_THIS_SESSION, WTSRegisterSessionNotification, WTSUnRegisterSessionNotification, +}; use windows::Win32::System::Threading::GetCurrentThreadId; use windows::core::{PCWSTR, w}; @@ -23,7 +26,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ RegisterClassW, SetWindowsHookExW, TranslateMessage, WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_STYLE, WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, - WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WNDPROC, + WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_WTSSESSION_CHANGE, WM_XBUTTONDOWN, WM_XBUTTONUP, + WNDCLASSW, WNDPROC, WTS_SESSION_LOCK, WTS_SESSION_UNLOCK, }; use input_event::{ @@ -122,6 +126,14 @@ thread_local! { static PREV_POS: Cell> = const { Cell::new(None) }; /// displays and generation counter static DISPLAYS: RefCell<(Vec, i32)> = const { RefCell::new((Vec::new(), 0)) }; + /// True while the host's session is locked. Set/cleared from the + /// `WM_WTSSESSION_CHANGE` window message. While true, barrier + /// crossings are suppressed and any active capture is released — + /// matches macOS's lock-screen suppression and what Wayland does + /// for free on locked Linux. Without this, low-level mouse hooks + /// would happily forward motion to the peer while the lock screen + /// consumes keyboard events, leaving a half-broken state. + static HOST_LOCKED: Cell = const { Cell::new(false) }; } fn get_msg() -> Option { @@ -202,8 +214,10 @@ fn start_routine( } } - /* window is used ro receive WM_DISPLAYCHANGE messages */ - unsafe { + /* window is used to receive WM_DISPLAYCHANGE and + * WM_WTSSESSION_CHANGE messages. Keep the HWND so we can register + * for session notifications and unregister on exit. */ + let msg_window = unsafe { CreateWindowExW( Default::default(), w!("lan-mouse-message-window-class"), @@ -218,15 +232,25 @@ fn start_routine( Some(instance), None, ) - .expect("CreateWindowExW"); + .expect("CreateWindowExW") + }; + + /* register for WM_WTSSESSION_CHANGE notifications so we can + * detect lock/unlock and suppress crossings while locked. Failure + * is logged but non-fatal — the rest of the capture still works, + * we just lose the lock-screen suppression. */ + unsafe { + if let Err(e) = WTSRegisterSessionNotification(msg_window, NOTIFY_FOR_THIS_SESSION) { + log::warn!( + "WTSRegisterSessionNotification failed: {e:?} — host-lock suppression disabled" + ); + } } - /* run message loop */ - loop { - // mouse / keybrd proc do not actually return a message - let Some(msg) = get_msg() else { - break; - }; + /* run message loop. mouse / keybrd procs don't actually return + * a message, so `get_msg() == None` ends the loop; an Exit-typed + * thread message breaks out from inside the body. */ + while let Some(msg) = get_msg() { if msg.hwnd.0.is_null() { /* messages sent via PostThreadMessage */ match msg.wParam.0 { @@ -258,6 +282,11 @@ fn start_routine( } } } + + /* unregister session-notification before the window goes away. */ + unsafe { + let _ = WTSUnRegisterSessionNotification(msg_window); + } } fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { @@ -277,6 +306,14 @@ fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { return ret; } + /* host session locked — don't let the cursor leave the lock + * screen. The lock screen consumes keyboard before our hook sees + * it; allowing a cross would put the mouse on the peer with no + * keyboard, a confusing half-broken state. */ + if HOST_LOCKED.get() { + return ret; + } + /* check if a client was activated */ let entered = DISPLAYS.with_borrow_mut(|(displays, generation)| { update_display_regions(displays, generation); @@ -302,7 +339,7 @@ fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { /* notify main thread */ log::debug!("ENTERED @ {prev_pos:?} -> {curr_pos:?}"); let active = ACTIVE_CLIENT.get().expect("active client"); - blocking_send_event(active, CaptureEvent::Begin); + blocking_send_event(active, CaptureEvent::Begin { cursor: None }); ret } @@ -356,12 +393,29 @@ unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) unsafe extern "system" fn window_proc( _hwnd: HWND, uint: u32, - _wparam: WPARAM, + wparam: WPARAM, _lparam: LPARAM, ) -> LRESULT { if uint == WM_DISPLAYCHANGE { log::debug!("display resolution changed"); DISPLAY_RESOLUTION_GENERATION.fetch_add(1, Ordering::Release); + } else if uint == WM_WTSSESSION_CHANGE { + match wparam.0 as u32 { + WTS_SESSION_LOCK => { + HOST_LOCKED.set(true); + if let Some(pos) = ACTIVE_CLIENT.take() { + log::info!("host session locked mid-capture; releasing"); + let _ = try_send_event(pos, CaptureEvent::AutoRelease); + } else { + log::info!("host session locked"); + } + } + WTS_SESSION_UNLOCK => { + HOST_LOCKED.set(false); + log::info!("host session unlocked"); + } + _ => {} + } } LRESULT(1) } diff --git a/input-capture/src/x11.rs b/input-capture/src/x11.rs index 6fc917ec9..cf74940dc 100644 --- a/input-capture/src/x11.rs +++ b/input-capture/src/x11.rs @@ -23,7 +23,7 @@ impl Capture for X11InputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { Ok(()) } diff --git a/input-emulation/src/lib.rs b/input-emulation/src/lib.rs index 930695f9a..5b3db3763 100644 --- a/input-emulation/src/lib.rs +++ b/input-emulation/src/lib.rs @@ -178,6 +178,19 @@ impl InputEmulation { self.emulation.terminate().await } + /// Display geometry of this device (union of all active + /// displays), if the backend can report it. See + /// `Emulation::display_bounds`. + pub fn display_bounds(&self) -> Option<(u32, u32)> { + self.emulation.display_bounds() + } + + /// Warp the local cursor to the given absolute position. See + /// `Emulation::warp_cursor`. + pub async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + self.emulation.warp_cursor(x, y).await + } + pub async fn release_keys(&mut self, handle: EmulationHandle) -> Result<(), EmulationError> { if let Some(keys) = self.pressed_keys.get_mut(&handle) { let keys = keys.drain().collect::>(); @@ -237,4 +250,27 @@ trait Emulation: Send { async fn create(&mut self, handle: EmulationHandle); async fn destroy(&mut self, handle: EmulationHandle); async fn terminate(&mut self); + + /// Geometry (width, height) of the union of this device's + /// active displays in pixels. Used by the protocol-level + /// `Bounds` event so a capturing peer can model the guest + /// cursor's position. Backends that can't report geometry + /// should leave the default `None` and the wall-press + /// auto-release fallback will degrade to "no upper clamp" + /// behavior on the host. + fn display_bounds(&self) -> Option<(u32, u32)> { + None + } + + /// Warp the cursor to an absolute position on the receiving + /// device's primary display, if the backend supports absolute + /// positioning. Called when an `Enter` event arrives so the + /// guest cursor lands at the entry edge instead of staying + /// wherever the previous capture session left it. Backends + /// without absolute positioning can leave the default no-op + /// — the wall-press auto-release will be inaccurate but the + /// connection still works. + async fn warp_cursor(&mut self, _x: i32, _y: i32) -> Result<(), EmulationError> { + Ok(()) + } } diff --git a/input-emulation/src/libei.rs b/input-emulation/src/libei.rs index 3ac6e8992..d17f2eab0 100644 --- a/input-emulation/src/libei.rs +++ b/input-emulation/src/libei.rs @@ -19,8 +19,8 @@ use async_trait::async_trait; use reis::{ ei::{ - self, Button, Keyboard, Pointer, Scroll, button::ButtonState, handshake::ContextType, - keyboard::KeyState, + self, Button, Keyboard, Pointer, PointerAbsolute, Scroll, button::ButtonState, + handshake::ContextType, keyboard::KeyState, }, event::{self, Connection, DeviceCapability, DeviceEvent, EiEvent, SeatEvent}, tokio::EiConvertEventStream, @@ -35,6 +35,7 @@ use super::{Emulation, EmulationHandle, error::LibeiEmulationCreationError}; #[derive(Clone, Default)] struct Devices { pointer: Arc>>, + pointer_abs: Arc>>, scroll: Arc>>, button: Arc>>, keyboard: Arc>>, @@ -261,6 +262,37 @@ impl Emulation for LibeiEmulation { let _ = self.session.close().await; self.ei_task.abort(); } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // TODO: derive from ei::Region events on the + // PointerAbsolute device, or query wl_output via a side + // wayland-client connection. For now we return None and + // the host falls back to the no-upper-clamp heuristic. + None + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let pointer_abs = self.devices.pointer_abs.read().unwrap(); + if let Some((device, pointer_abs)) = pointer_abs.as_ref() { + pointer_abs.motion_absolute(x as f32, y as f32); + device.frame(self.conn.serial(), now); + self.context + .flush() + .map_err(|e| io::Error::new(e.kind(), e))?; + } else { + // Compositor didn't grant a PointerAbsolute device. + // Nothing we can do to warp the cursor; the host's + // wall-press model will be off by however far the + // user pushed forward in the prior session, but + // operation continues. + log::debug!("warp_cursor: no PointerAbsolute device available, skipping"); + } + Ok(()) + } } async fn ei_task( @@ -323,6 +355,13 @@ async fn ei_event_handler( .unwrap() .replace((device.device().clone(), pointer)); } + if let Some(pointer_abs) = e.device().interface::() { + devices + .pointer_abs + .write() + .unwrap() + .replace((device.device().clone(), pointer_abs)); + } if let Some(keyboard) = e.device().interface::() { devices .keyboard diff --git a/input-emulation/src/macos.rs b/input-emulation/src/macos.rs index 881fc2299..35cda3763 100644 --- a/input-emulation/src/macos.rs +++ b/input-emulation/src/macos.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use bitflags::bitflags; use core_graphics::base::CGFloat; use core_graphics::display::{ - CGDirectDisplayID, CGDisplayBounds, CGGetDisplaysWithRect, CGPoint, CGRect, CGSize, + CGDirectDisplayID, CGDisplay, CGDisplayBounds, CGGetDisplaysWithRect, CGPoint, CGRect, CGSize, }; use core_graphics::event::{ CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGKeyCode, CGMouseButton, EventField, @@ -489,6 +489,39 @@ impl Emulation for MacOSEmulation { async fn destroy(&mut self, _handle: EmulationHandle) {} async fn terminate(&mut self) {} + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Union of every active display's rectangle. Matches the + // shape used on the input-capture side so the host's + // wall-press model is consistent across both ends. + let displays = CGDisplay::active_displays().ok()?; + let mut xmin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymin = f64::INFINITY; + let mut ymax = f64::NEG_INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + xmax = xmax.max(bounds.origin.x + bounds.size.width); + ymin = ymin.min(bounds.origin.y); + ymax = ymax.max(bounds.origin.y + bounds.size.height); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let pt = CGPoint { + x: x as CGFloat, + y: y as CGFloat, + }; + // CGDisplay::warp_mouse_cursor_position is a global Quartz + // call; it doesn't matter which CGDisplay receiver we use. + let _ = CGDisplay::warp_mouse_cursor_position(pt); + Ok(()) + } } fn update_modifiers(modifiers: &Cell, key: u32, state: u8) -> bool { diff --git a/input-emulation/src/windows.rs b/input-emulation/src/windows.rs index 6610ec257..18ca7782c 100644 --- a/input-emulation/src/windows.rs +++ b/input-emulation/src/windows.rs @@ -17,7 +17,9 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ use windows::Win32::UI::Input::KeyboardAndMouse::{ INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, SendInput, }; -use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SetCursorPos, XBUTTON1, XBUTTON2, +}; use super::{Emulation, EmulationHandle}; @@ -80,6 +82,27 @@ impl Emulation for WindowsEmulation { async fn destroy(&mut self, _handle: EmulationHandle) {} async fn terminate(&mut self) {} + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Virtual-screen metrics cover the union of every monitor + // attached to the system, matching the host-side capture + // model that uses the union of all displays. + unsafe { + let w = GetSystemMetrics(SM_CXVIRTUALSCREEN); + let h = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if w <= 0 || h <= 0 { + return None; + } + Some((w as u32, h as u32)) + } + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + unsafe { + let _ = SetCursorPos(x, y); + } + Ok(()) + } } impl WindowsEmulation { diff --git a/input-emulation/src/wlroots.rs b/input-emulation/src/wlroots.rs index f79f8d9eb..34cbb9557 100644 --- a/input-emulation/src/wlroots.rs +++ b/input-emulation/src/wlroots.rs @@ -12,8 +12,13 @@ use wayland_client::WEnum; use wayland_client::backend::WaylandError; use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; +use wayland_client::protocol::wl_output::{self, WlOutput}; use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState}; use wayland_client::protocol::wl_seat::WlSeat; +use wayland_protocols::xdg::xdg_output::zv1::client::{ + zxdg_output_manager_v1::ZxdgOutputManagerV1, + zxdg_output_v1::{self, ZxdgOutputV1}, +}; use wayland_protocols_wlr::virtual_pointer::v1::client::{ zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager, zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1 as Vp, @@ -25,7 +30,7 @@ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ }; use wayland_client::{ - Connection, Dispatch, EventQueue, QueueHandle, delegate_noop, + Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, globals::{GlobalListContents, registry_queue_init}, protocol::{wl_registry, wl_seat}, }; @@ -42,6 +47,38 @@ struct State { qh: QueueHandle, vpm: VpManager, vkm: VkManager, + /// All wl_outputs the compositor advertises, keyed by their + /// proxy id. Updated via Geometry/Mode events. We keep the + /// `WlOutput` proxy alive in the value so events keep flowing. + outputs: HashMap)>, + /// Dedicated virtual pointer used only for absolute-position + /// warps on `Enter`. Separate from per-handle pointers so warp + /// works regardless of which client is active. + warp_pointer: Vp, +} + +#[derive(Default, Clone, Copy)] +struct OutputInfo { + /// Position in the compositor's global coordinate space, from + /// wl_output::Event::Geometry. Raw-pixel coordinates. + x: i32, + y: i32, + /// Pixel dimensions of the active mode, from wl_output::Event::Mode. + width: i32, + height: i32, + /// Logical position in the compositor's coordinate space, from + /// zxdg_output_v1::Event::LogicalPosition. Reflects software + /// scaling (e.g. fractional or HiDPI). Falls back to (x, y) when + /// xdg-output isn't available. + logical_x: Option, + logical_y: Option, + /// Logical dimensions, from zxdg_output_v1::Event::LogicalSize. + /// This is the coordinate space the compositor uses for cursor + /// positions and the same one the capture side uses, so we + /// prefer it for `display_bounds()` to keep both sides in sync. + /// Falls back to (width, height) when xdg-output isn't available. + logical_width: Option, + logical_height: Option, } // App State, implements Dispatch event handlers @@ -67,6 +104,39 @@ impl WlrootsEmulation { let vkm: VkManager = globals .bind(&qh, 1..=1, ()) .map_err(|e| WaylandBindError::new(e, "virtual-keyboard-unstable-v1"))?; + // xdg-output gives us LogicalSize/LogicalPosition — the + // coordinate space the compositor actually uses (with + // software/fractional scaling applied). The capture side + // already reports bounds in this space, so emulation needs + // it too or warps land on different proportions than the + // sender computed. Optional: if the compositor doesn't + // advertise xdg_output_manager we fall back to wl_output's + // raw mode dimensions. + let xdg_output_manager: Option = globals.bind(&qh, 1..=3, ()).ok(); + + // Bind every advertised wl_output so we receive Geometry + + // Mode events for each one. Used to compute display_bounds. + let mut outputs: HashMap)> = + HashMap::new(); + for global in globals.contents().clone_list() { + if global.interface == "wl_output" { + // version 2 is enough for Geometry + Mode events. + let output: WlOutput = + globals + .registry() + .bind(global.name, global.version.min(2), &qh, ()); + let id = output.id().protocol_id(); + let xdg_output = xdg_output_manager + .as_ref() + .map(|mgr| mgr.get_xdg_output(&output, &qh, id)); + outputs.insert(id, (output, OutputInfo::default(), xdg_output)); + } + } + + // Dedicated warp pointer — used only for motion_absolute on + // Enter, so warp works even when no per-handle virtual + // pointer is currently active. + let warp_pointer: Vp = vpm.create_virtual_pointer(None, &qh, ()); let input_for_client: HashMap = HashMap::new(); @@ -79,6 +149,8 @@ impl WlrootsEmulation { vpm, vkm, qh, + outputs, + warp_pointer, }, queue, }; @@ -119,6 +191,39 @@ impl State { input.keyboard.destroy(); } } + + /// Bounding rectangle of every active wl_output in the + /// compositor's logical coordinate space (with software / + /// fractional scaling applied). Falls back per-output to raw + /// mode dimensions when xdg-output is unavailable. Returns + /// None if no output has reported usable size info yet. + fn union_bounds(&self) -> Option<(u32, u32)> { + let mut xmin = i32::MAX; + let mut ymin = i32::MAX; + let mut xmax = i32::MIN; + let mut ymax = i32::MIN; + let mut any = false; + for (_, o, _) in self.outputs.values() { + let w = o.logical_width.unwrap_or(o.width); + let h = o.logical_height.unwrap_or(o.height); + if w <= 0 || h <= 0 { + continue; + } + let ox = o.logical_x.unwrap_or(o.x); + let oy = o.logical_y.unwrap_or(o.y); + any = true; + xmin = xmin.min(ox); + ymin = ymin.min(oy); + xmax = xmax.max(ox + w); + ymax = ymax.max(oy + h); + } + if !any { + return None; + } + let w = (xmax - xmin) as u32; + let h = (ymax - ymin) as u32; + Some((w, h)) + } } #[async_trait] @@ -172,7 +277,31 @@ impl Emulation for WlrootsEmulation { } } async fn terminate(&mut self) { - /* nothing to do */ + self.state.warp_pointer.destroy(); + } + + fn display_bounds(&self) -> Option<(u32, u32)> { + self.state.union_bounds() + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let Some((width, height)) = self.state.union_bounds() else { + return Ok(()); + }; + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u32; + let cx = x.clamp(0, width as i32) as u32; + let cy = y.clamp(0, height as i32) as u32; + self.state + .warp_pointer + .motion_absolute(now, cx, cy, width, height); + self.state.warp_pointer.frame(); + if let Err(e) = self.queue.flush() { + log::warn!("warp_cursor flush failed: {e}"); + } + Ok(()) } } @@ -254,6 +383,33 @@ delegate_noop!(State: Vp); delegate_noop!(State: Vk); delegate_noop!(State: VpManager); delegate_noop!(State: VkManager); +delegate_noop!(State: ZxdgOutputManagerV1); + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZxdgOutputV1, + event: ::Event, + id: &u32, + _: &Connection, + _: &QueueHandle, + ) { + let Some((_, info, _)) = state.outputs.get_mut(id) else { + return; + }; + match event { + zxdg_output_v1::Event::LogicalPosition { x, y } => { + info.logical_x = Some(x); + info.logical_y = Some(y); + } + zxdg_output_v1::Event::LogicalSize { width, height } => { + info.logical_width = Some(width); + info.logical_height = Some(height); + } + _ => {} + } + } +} impl Dispatch for State { fn event( @@ -282,6 +438,38 @@ impl Dispatch for State { } } +impl Dispatch for State { + fn event( + state: &mut Self, + output: &WlOutput, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let id = output.id().protocol_id(); + let Some((_, info, _)) = state.outputs.get_mut(&id) else { + return; + }; + match event { + wl_output::Event::Geometry { x, y, .. } => { + info.x = x; + info.y = y; + } + wl_output::Event::Mode { + flags: WEnum::Value(flags), + width, + height, + .. + } if flags.contains(wl_output::Mode::Current) => { + info.width = width; + info.height = height; + } + _ => {} + } + } +} + impl Dispatch for State { fn event( _: &mut Self, diff --git a/input-emulation/src/x11.rs b/input-emulation/src/x11.rs index aadca2945..d62e1cdaa 100644 --- a/input-emulation/src/x11.rs +++ b/input-emulation/src/x11.rs @@ -151,4 +151,31 @@ impl Emulation for X11Emulation { async fn terminate(&mut self) { /* nothing to do */ } + + fn display_bounds(&self) -> Option<(u32, u32)> { + unsafe { + // DisplayWidth/DisplayHeight on the default screen + // returns the union extent of the X server's logical + // screen across all monitors (Xinerama / RandR). + let screen = xlib::XDefaultScreen(self.display); + let w = xlib::XDisplayWidth(self.display, screen); + let h = xlib::XDisplayHeight(self.display, screen); + if w <= 0 || h <= 0 { + return None; + } + Some((w as u32, h as u32)) + } + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + unsafe { + let root = xlib::XDefaultRootWindow(self.display); + // XWarpPointer with src_w = 0 means "no source window", + // so the cursor moves to (x, y) relative to dest_w + // (the root window) regardless of where it currently is. + xlib::XWarpPointer(self.display, 0, root, 0, 0, 0, 0, x, y); + xlib::XFlush(self.display); + } + Ok(()) + } } diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index 609ea4e88..c5be6b239 100644 --- a/lan-mouse-gtk/resources/window.ui +++ b/lan-mouse-gtk/resources/window.ui @@ -32,11 +32,18 @@ - - Lan Mouse - easily use your mouse and keyboard on multiple computers - de.feschber.LanMouse - + + true + true + never + automatic + true + + + Lan Mouse + easily use your mouse and keyboard on multiple computers + de.feschber.LanMouse + 600 0 @@ -198,6 +205,58 @@ + + + Outgoing Auto-Release + Fallback release when the peer can't tell us the cursor reached its edge — typically because the peer's screen is locked. No effect on peers that release themselves; the cursor-release keybind still works either way. + + + Release threshold + pixels of wall-press past the entry edge before auto-release fires + + + disabled + center + 8 + 1.0 + + + + + + + + horizontal + true + false + 0 + 12 + 12 + 4 + 8 + + + 0 + 500 + 10 + 50 + 0 + + + + off + 50 + 100 + 200 + 500 + + + + + Connections @@ -264,11 +323,30 @@ + + + Network Discovery + Advertise (and consume) Bonjour records so peers can pick the OS-preferred interface on multi-homed hosts. Turn off on networks where mDNS multicast is firewalled. + + + mDNS Discovery + Use _lan-mouse._udp.local. for service order hints. + + + center + + + + + + + + diff --git a/lan-mouse-gtk/src/client_object.rs b/lan-mouse-gtk/src/client_object.rs index 9f148fdc1..0e34fda67 100644 --- a/lan-mouse-gtk/src/client_object.rs +++ b/lan-mouse-gtk/src/client_object.rs @@ -26,6 +26,7 @@ impl ClientObject { .collect::>(), ) .property("resolving", state.resolving) + .property("peer-commit", peer_commit_to_string(state.peer_commit)) .build() } @@ -34,6 +35,14 @@ impl ClientObject { } } +/// Render the 8-byte ASCII commit hash carried in +/// [`lan_mouse_ipc::ClientState::peer_commit`] as a `String`. `None` +/// in → `None` out (peer hasn't sent a Hello yet, or speaks an older +/// proto). +pub fn peer_commit_to_string(commit: Option<[u8; 8]>) -> Option { + commit.and_then(|c| std::str::from_utf8(&c).ok().map(str::to_string)) +} + #[derive(Default, Clone)] pub struct ClientData { pub handle: ClientHandle, @@ -43,4 +52,5 @@ pub struct ClientData { pub position: String, pub resolving: bool, pub ips: Vec, + pub peer_commit: Option, } diff --git a/lan-mouse-gtk/src/client_object/imp.rs b/lan-mouse-gtk/src/client_object/imp.rs index 016ae8c84..2096584f7 100644 --- a/lan-mouse-gtk/src/client_object/imp.rs +++ b/lan-mouse-gtk/src/client_object/imp.rs @@ -19,6 +19,7 @@ pub struct ClientObject { #[property(name = "position", get, set, type = String, member = position)] #[property(name = "resolving", get, set, type = bool, member = resolving)] #[property(name = "ips", get, set, type = Vec, member = ips)] + #[property(name = "peer-commit", get, set, type = Option, member = peer_commit)] pub data: RefCell, } diff --git a/lan-mouse-gtk/src/client_row.rs b/lan-mouse-gtk/src/client_row.rs index cd3d02b31..51a3788fd 100644 --- a/lan-mouse-gtk/src/client_row.rs +++ b/lan-mouse-gtk/src/client_row.rs @@ -123,6 +123,12 @@ impl ClientRow { bindings.push(position_binding); bindings.push(resolve_binding); bindings.push(ip_binding); + + // Render the initial collapsed subtitle from whatever + // peer_commit the ClientObject was created with. Subsequent + // changes are pushed by `Window::update_client_state` calling + // `refresh_version_status` after writing the new property. + self.refresh_version_status(); } pub fn unbind(&self) { @@ -150,4 +156,34 @@ impl ClientRow { pub fn set_dns_state(&self, resolved: bool) { self.imp().set_dns_state(resolved); } + + /// Recompute the collapsed subtitle (Pango markup) based on the + /// current `peer-commit` property and the local build's commit. + /// Soft-warn semantics: a missing or mismatched peer commit + /// surfaces as orange text but never blocks traffic. Called by + /// the window after `update_client_state` writes the new + /// `peer-commit`. The dns-status icon is left to its existing + /// `set_dns_state` handler so the two indicators don't fight + /// for the same CSS class. + pub fn refresh_version_status(&self) { + let peer: Option = self + .imp() + .client_object + .borrow() + .as_ref() + .and_then(|co| co.property::>("peer-commit")); + let local = crate::local_commit_str(); + let markup = match peer.as_deref() { + None => format!( + r##"Peer version: unknown · Ours: {local}"## + ), + Some(p) if p == local.as_str() => { + format!(r##"Peer version: {p} · matched"##) + } + Some(p) => { + format!(r##"Peer version: {p} · Ours: {local}"##) + } + }; + self.set_subtitle(&markup); + } } diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index ecdd7080c..853da561f 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -10,10 +10,27 @@ mod macos_privacy; mod macos_status_item; mod window; -use std::{env, process, str}; +use std::{env, process, str, sync::OnceLock}; use window::Window; +/// Local build's commit hash, set once by [`run`] before the GTK +/// main loop starts. Read by per-row UI to compare against each +/// peer's [`lan_mouse_ipc::ClientState::peer_commit`] for the +/// soft-warn version-mismatch indicator. +pub(crate) static LOCAL_COMMIT: OnceLock<[u8; 8]> = OnceLock::new(); + +/// Convenience: returns the local commit as an 8-char ASCII string, +/// or a placeholder if unset (which would indicate a programmer +/// error since [`run`] always sets it). +pub(crate) fn local_commit_str() -> String { + LOCAL_COMMIT + .get() + .and_then(|c| std::str::from_utf8(c).ok()) + .unwrap_or("????????") + .to_string() +} + use lan_mouse_ipc::FrontendEvent; use adw::Application; @@ -31,8 +48,12 @@ pub enum GtkError { NonZeroExitCode(i32), } -pub fn run() -> Result<(), GtkError> { +pub fn run(local_commit: [u8; 8]) -> Result<(), GtkError> { log::debug!("running gtk frontend"); + LOCAL_COMMIT + .set(local_commit) + .expect("local_commit set once"); + #[cfg(windows)] let ret = std::thread::Builder::new() .stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows @@ -269,6 +290,12 @@ fn build_ui(app: &Application) { FrontendEvent::IncomingDisconnected(addr) => { window.show_toast(format!("{addr} disconnected").as_str()); } + FrontendEvent::ReleaseThreshold(threshold) => { + window.set_release_threshold(threshold); + } + FrontendEvent::MdnsDiscovery(enabled) => { + window.set_mdns_discovery(enabled); + } } } } diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index f65015725..ac25a7d98 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -365,6 +365,13 @@ impl Window { .map(|ip| ip.to_string()) .collect::>(); client_object.set_ips(ips); + + /* peer build version (drives the version-match indicator) */ + client_object.set_property( + "peer-commit", + crate::client_object::peer_commit_to_string(state.peer_commit), + ); + row.refresh_version_status(); } fn client_object_for_handle(&self, handle: ClientHandle) -> Option { @@ -407,6 +414,14 @@ impl Window { self.request(FrontendRequest::Create); } + pub(super) fn request_release_threshold(&self, threshold: u32) { + self.request(FrontendRequest::SetReleaseThreshold(threshold)); + } + + pub(super) fn request_mdns_discovery(&self, enabled: bool) { + self.request(FrontendRequest::SetMdnsDiscovery(enabled)); + } + fn open_fingerprint_dialog(&self, fp: Option) { let window = FingerprintWindow::new(fp); window.set_transient_for(Some(self)); @@ -461,6 +476,42 @@ impl Window { self.update_capture_emulation_status(); } + pub(super) fn set_mdns_discovery(&self, enabled: bool) { + let imp = self.imp(); + let switch = &imp.mdns_discovery_switch; + let handler = imp.mdns_discovery_handler.borrow(); + if let Some(id) = handler.as_ref() { + switch.block_signal(id); + } + switch.set_active(enabled); + switch.set_state(enabled); + if let Some(id) = handler.as_ref() { + switch.unblock_signal(id); + } + } + + pub(super) fn set_release_threshold(&self, threshold: u32) { + let imp = self.imp(); + // Block the value-changed handler so programmatically setting + // the slider value (e.g. on Sync from the daemon) doesn't + // ricochet back as a SetReleaseThreshold request. + let scale = &imp.release_threshold_scale; + let handler_id = imp.release_threshold_handler.borrow(); + if let Some(id) = handler_id.as_ref() { + scale.block_signal(id); + } + scale.set_value(threshold as f64); + if let Some(id) = handler_id.as_ref() { + scale.unblock_signal(id); + } + let label = if threshold == 0 { + "disabled".to_string() + } else { + format!("{threshold} px") + }; + imp.release_threshold_value.set_label(&label); + } + #[cfg(target_os = "macos")] pub(super) fn refresh_capture_emulation_status(&self) { self.update_capture_emulation_status(); diff --git a/lan-mouse-gtk/src/window/imp.rs b/lan-mouse-gtk/src/window/imp.rs index bb7d48cba..a1ceab701 100644 --- a/lan-mouse-gtk/src/window/imp.rs +++ b/lan-mouse-gtk/src/window/imp.rs @@ -4,7 +4,10 @@ use adw::subclass::prelude::*; use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*}; use glib::subclass::InitializingObject; use gtk::glib::clone; -use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib}; +use gtk::{ + Button, CompositeTemplate, Entry, EventControllerScroll, EventControllerScrollFlags, Image, + Label, ListBox, PropagationPhase, Scale, ScrolledWindow, Switch, gdk, gio, glib, +}; use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter}; @@ -45,6 +48,16 @@ pub struct Window { pub input_capture_button: TemplateChild